yoda: cosmic-files customizations (squashed 21 commits)

This commit squashes the 21 local commits that customize cosmic-files for
the yoda stack, to allow a clean rebase on upstream/master.

Original commits (chronological):

- 9bcfe7a Cargo.toml: patch libcosmic via local path for dev builds
- 04abd13 yoda: depend on libcosmic-yoda (path) instead of upstream libcosmic
- 02adcc3 lockfile: libcosmic-yoda 0.1.0-yoda -> 0.1.0-yoda.2
- a025fd6 yoda: prefer cosmic-yoterm over upstream cosmic-term in terminal fallback
- e8d62ae yoda: add "Always use this app" toggle to OpenWith dialog
- 8fb2b15 yoda wayland-v5: redirect window_clipboard + cosmic-text to local forks
- 0595296 yoda: Dolphin-style quick actions toolbar under the headerbar
- 4b6d345 yoda: fix missing rename icon in toolbar
- 8b51af1 yoda: use pencil-symbolic for the Rename toolbar button
- 33a5c8f yoda: phase 2 - customizable toolbar (settings toggles per button)
- 1cf17dc yoda: phase 3 - drag-drop toolbar editor in Settings
- 11d4357 yoda: add up/down buttons next to drag handle in toolbar editor
- af843d2 yoda: direct drag-drop reorder on the toolbar itself
- 94c3e6c yoda: toolbar as segmented_button for working drag reorder
- f053819 yoda: toolbar icon-only + clean visual (Control style, 32px squares)
- 338354c Improve initial directory listing latency
- d080bc8 Resolve cosmic-files warnings without masking
- 69c35ab yoda: switch window_clipboard patch to public Forgejo fork
- 35e115f yoda: switch cosmic-text patch to public Forgejo fork
- 6f3adcd chore: clean feature-gated warnings
- 57ab1ec fix: clean files warnings for terminal build

Original tip preserved as tag backup/pre-rebase-upstream-20260524.
This commit is contained in:
Leyoda 2026-05-24 21:16:14 +02:00
parent 784200a253
commit f1b1f8d799
17 changed files with 1173 additions and 646 deletions

947
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -73,8 +73,9 @@ version = "0.18"
default-features = false default-features = false
features = ["fs", "io", "macros", "polling", "runtime"] features = ["fs", "io", "macros", "polling", "runtime"]
[dependencies.libcosmic] # Yoda fork — depend on libcosmic-yoda directly by path (no git/no patch).
git = "https://github.com/pop-os/libcosmic.git" [dependencies.libcosmic-yoda]
path = "../libcosmic"
default-features = false default-features = false
#TODO: a11y feature crashes #TODO: a11y feature crashes
features = [ features = [
@ -83,7 +84,7 @@ features = [
"autosize", "autosize",
"multi-window", "multi-window",
"tokio", "tokio",
"winit", "wayland",
"surface-message", "surface-message",
] ]
@ -111,15 +112,15 @@ default = [
"wayland", "wayland",
"wgpu", "wgpu",
] ]
dbus-config = ["libcosmic/dbus-config"] dbus-config = ["libcosmic-yoda/dbus-config"]
desktop = ["libcosmic/desktop", "dep:cosmic-mime-apps", "dep:xdg"] desktop = ["libcosmic-yoda/desktop", "dep:cosmic-mime-apps", "dep:xdg"]
desktop-applet = [] desktop-applet = []
gvfs = ["dep:gio", "dep:glib"] gvfs = ["dep:gio", "dep:glib"]
io-uring = ["compio/io-uring"] io-uring = ["compio/io-uring"]
jemalloc = ["dep:tikv-jemallocator"] jemalloc = ["dep:tikv-jemallocator"]
notify = ["dep:notify-rust"] notify = ["dep:notify-rust"]
wayland = ["libcosmic/wayland", "dep:cctk", "dep:wayland-client"] wayland = ["libcosmic-yoda/wayland", "dep:cctk", "dep:wayland-client"]
wgpu = ["libcosmic/wgpu"] wgpu = ["libcosmic-yoda/wgpu"]
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
@ -145,20 +146,22 @@ fastrand = "2"
test-log = "0.2" test-log = "0.2"
tokio = { version = "1", features = ["rt", "macros"] } tokio = { version = "1", features = ["rt", "macros"] }
# [patch.'https://github.com/pop-os/cosmic-text'] # Yoda fork — libcosmic dep is now a direct path dep (libcosmic-yoda above),
# cosmic-text = { path = "../cosmic-text" } # no [patch] block needed anymore. Keeping the block below would be a no-op
# since nothing in the dep graph still asks for pop-os/libcosmic.git.
# [patch.'https://github.com/pop-os/libcosmic'] # Yoda wayland cut: redirect window_clipboard (x11 gated behind opt-in
# libcosmic = { path = "../libcosmic" } # feature) and cosmic-text (PR#503: EAW monospace width fix) to our public
# cosmic-config = { path = "../libcosmic/cosmic-config" } # Forgejo forks. The window_clipboard patch is needed to consolidate the
# cosmic-theme = { path = "../libcosmic/cosmic-theme" } # upstream pop-os/libcosmic chain (pulled by cosmic-settings-daemon) onto
# libcosmic = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" } # the same fork iced uses, otherwise cargo compiles two versions.
# cosmic-config = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" } [patch.'https://github.com/pop-os/window_clipboard.git']
# cosmic-theme = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" } window_clipboard = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
dnd = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
mime = { git = "https://forge.aditua.com/leyoda/window_clipboard.git", branch = "yoda-x11-optional" }
[patch.'https://github.com/pop-os/cosmic-text.git']
# [patch.'https://github.com/pop-os/smithay-clipboard'] cosmic-text = { git = "https://forge.aditua.com/leyoda/cosmic-text.git", branch = "local/pr-503" }
# smithay-clipboard = { path = "../smithay-clipboard" }
[workspace] [workspace]
members = ["cosmic-files-applet"] members = ["cosmic-files-applet"]

View file

@ -0,0 +1,78 @@
# Local performance and portal notes
Date: 2026-05-05
This repository carries a local patch aimed at improving initial directory
display latency in large folders, plus a system-level workaround for a COSMIC
portal FileChooser crash observed with Firefox and Chromium.
## Directory listing latency
The slow path was the initial construction of `Item` values in `src/tab.rs`.
On a test folder with about 2000 entries, raw filesystem enumeration and stat
calls completed in a few milliseconds, while COSMIC Files took multiple seconds
before showing the directory.
The local patch keeps initial item construction cheap:
- directory child counts are no longer computed synchronously in
`item_from_entry` and `item_from_gvfs_info`;
- initial MIME detection uses extension-based `mime_guess`;
- regular files use a generic file icon during the initial scan instead of
resolving the full MIME icon immediately.
This keeps folders and `.desktop` files special-cased, while avoiding expensive
per-file work for ordinary files during first paint.
Measured locally during investigation:
- before the MIME/icon changes: about 3.1 seconds for `~/Téléchargements`;
- after avoiding full MIME icon resolution during scan: below the temporary
100 ms perf-log threshold for the same folder.
## File chooser portal workaround
Firefox and Chromium were failing to open `Save As` on the first attempt because
`xdg-desktop-portal-cosmic` crashed while handling `FileChooser`.
Logs showed:
```text
Backend call failed: Remote peer disconnected
xdg-desktop-portal-cosmic ... status=11/SEGV
```
The working local system workaround is to remove
`org.freedesktop.impl.portal.FileChooser` from:
```text
/usr/share/xdg-desktop-portal/portals/cosmic.portal
```
so the file chooser falls back to GTK. The resulting local `cosmic.portal`
keeps COSMIC for the other interfaces:
```ini
[portal]
DBusName=org.freedesktop.impl.portal.desktop.cosmic
Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.ScreenCast
UseIn=COSMIC
```
Backups created locally:
```text
/usr/share/xdg-desktop-portal/portals/cosmic.portal.bak-codex-20260505-filechooser
/usr/share/xdg-desktop-portal/cosmic-portals.conf.bak-codex-20260505
/usr/share/xdg-desktop-portal/cosmic-portals.conf.bak-codex-20260505-2
```
After editing portal files, restart:
```sh
systemctl --user restart xdg-desktop-portal.service xdg-desktop-portal-gtk.service
```
Package updates may overwrite `/usr/share/xdg-desktop-portal/portals/cosmic.portal`.
If `Save As` starts needing two attempts again, re-check that `FileChooser` has
not been reintroduced in `cosmic.portal`.

View file

@ -96,6 +96,7 @@ save-file = Save file
## Open With Dialog ## Open With Dialog
open-with-title = How do you want to open "{$name}"? open-with-title = How do you want to open "{$name}"?
open-with-set-default = Always use this app for this file type
browse-store = Browse {$store} browse-store = Browse {$store}
other-apps = Other applications other-apps = Other applications
related-apps = Related applications related-apps = Related applications
@ -139,6 +140,11 @@ open-with = Open with
owner = Owner owner = Owner
group = Group group = Group
other = Other other = Other
toolbar = Toolbar
toolbar-available = Available
toolbar-empty-hint = No buttons. Drag or add from below.
toolbar-reset = Reset to defaults
parent-directory = Parent directory
mixed = Mixed mixed = Mixed
### Mode 0 ### Mode 0
none = None none = None

View file

@ -92,6 +92,7 @@ save-file = Enregistrer fichier
## Open With Dialog ## Open With Dialog
open-with-title = Comment souhaitez-vous ouvrir "{ $name }"? open-with-title = Comment souhaitez-vous ouvrir "{ $name }"?
open-with-set-default = Toujours utiliser cette application pour ce type de fichier
browse-store = Parcourir { $store } browse-store = Parcourir { $store }
## Permanently delete Dialog ## Permanently delete Dialog
@ -130,6 +131,11 @@ open-with = Ouvrir avec
owner = Propriétaire owner = Propriétaire
group = Groupe group = Groupe
other = Autre other = Autre
toolbar = Barre d'outils
toolbar-available = Disponibles
toolbar-empty-hint = Aucun bouton. Glisser-déposer ou ajouter depuis la liste ci-dessous.
toolbar-reset = Rétablir par défaut
parent-directory = Dossier parent
### Mode 0 ### Mode 0

View file

@ -41,15 +41,21 @@ use notify_debouncer_full::notify::{self, RecommendedWatcher};
use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer}; use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
use slotmap::Key as SlotMapKey; use slotmap::Key as SlotMapKey;
use std::any::TypeId; #[cfg(feature = "notify")]
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; use std::sync::Mutex;
use std::future::Future; use std::{
use std::num::NonZeroU16; any::TypeId,
use std::path::{Path, PathBuf}; collections::{BTreeMap, BTreeSet, HashMap, VecDeque},
use std::pin::Pin; env, fmt, fs,
use std::sync::{Arc, LazyLock, Mutex}; future::Future,
use std::time::{self, Duration, Instant}; io,
use std::{env, fmt, fs, io, process}; num::NonZeroU16,
path::{Path, PathBuf},
pin::Pin,
process,
sync::{Arc, LazyLock},
time::{self, Duration, Instant},
};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use trash::TrashItem; use trash::TrashItem;
#[cfg(all(feature = "wayland", feature = "desktop-applet"))] #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
@ -61,7 +67,7 @@ use crate::clipboard::{
}; };
use crate::config::{ use crate::config::{
AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig,
TimeConfig, TypeToSearch, TimeConfig, ToolbarAction, TypeToSearch, default_toolbar,
}; };
use crate::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings}; use crate::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
use crate::key_bind::key_binds; use crate::key_bind::key_binds;
@ -127,6 +133,105 @@ pub struct Flags {
pub uris: Vec<url::Url>, pub uris: Vec<url::Url>,
} }
/// Yoda phase 3: MIME for the DnD payload carried when a user drags a
/// toolbar row in the Settings editor. A single byte = ToolbarAction
/// discriminant (see `ToolbarAction::to_u8`).
const TOOLBAR_MIME: &str = "application/x-cosmic-files-toolbar-action";
/// Yoda phase 3: DnD payload wrapping a ToolbarAction discriminant.
#[derive(Clone, Debug)]
pub struct ToolbarActionPayload(pub u8);
impl cosmic::iced::clipboard::mime::AsMimeTypes for ToolbarActionPayload {
fn available(&self) -> std::borrow::Cow<'static, [String]> {
std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()])
}
fn as_bytes(&self, mime_type: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
if mime_type == TOOLBAR_MIME {
Some(std::borrow::Cow::Owned(vec![self.0]))
} else {
None
}
}
}
impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ToolbarActionPayload {
fn allowed() -> std::borrow::Cow<'static, [String]> {
std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()])
}
}
impl TryFrom<(Vec<u8>, String)> for ToolbarActionPayload {
type Error = ();
fn try_from((data, _mime): (Vec<u8>, String)) -> Result<Self, Self::Error> {
if data.len() == 1 {
Ok(Self(data[0]))
} else {
Err(())
}
}
}
/// Yoda phase 3 helper: map a ToolbarAction to its button UI (icon name,
/// localized label, app Message). Shared by the toolbar renderer in
/// `view()` and by the Settings page row renderer so the two stay in
/// sync.
fn toolbar_action_ui(a: ToolbarAction) -> (&'static str, String, Message) {
match a {
ToolbarAction::LocationUp => (
"go-up-symbolic",
fl!("parent-directory"),
Action::LocationUp.message(None),
),
ToolbarAction::Reload => (
"view-refresh-symbolic",
fl!("reload-folder"),
Action::Reload.message(None),
),
ToolbarAction::NewFolder => (
"folder-new-symbolic",
fl!("new-folder"),
Action::NewFolder.message(None),
),
ToolbarAction::NewFile => (
"document-new-symbolic",
fl!("new-file"),
Action::NewFile.message(None),
),
ToolbarAction::Rename => (
"pencil-symbolic",
fl!("rename"),
Action::Rename.message(None),
),
ToolbarAction::Delete => (
"edit-delete-symbolic",
fl!("delete"),
Action::Delete.message(None),
),
ToolbarAction::Cut => ("edit-cut-symbolic", fl!("cut"), Action::Cut.message(None)),
ToolbarAction::Copy => (
"edit-copy-symbolic",
fl!("copy"),
Action::Copy.message(None),
),
ToolbarAction::Paste => (
"edit-paste-symbolic",
fl!("paste"),
Action::Paste.message(None),
),
ToolbarAction::ToggleShowHidden => (
"view-reveal-symbolic",
fl!("show-hidden-files"),
Action::ToggleShowHidden.message(None),
),
ToolbarAction::OpenTerminal => (
"utilities-terminal-symbolic",
fl!("open-in-terminal"),
Action::OpenTerminal.message(None),
),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action { pub enum Action {
About, About,
@ -389,6 +494,7 @@ pub enum Message {
OpenWithBrowse, OpenWithBrowse,
OpenWithDialog(Option<Entity>), OpenWithDialog(Option<Entity>),
OpenWithSelection(usize), OpenWithSelection(usize),
OpenWithToggleDefault(bool),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))] #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Overlap(window::Id, OverlapNotifyEvent), Overlap(window::Id, OverlapNotifyEvent),
Paste(Option<Entity>), Paste(Option<Entity>),
@ -429,6 +535,22 @@ pub enum Message {
SearchInput(String), SearchInput(String),
SetShowDetails(bool), SetShowDetails(bool),
SetShowRecents(bool), SetShowRecents(bool),
/// Yoda phase 3 — toolbar editing messages.
ToolbarAdd(ToolbarAction),
ToolbarRemove(ToolbarAction),
ToolbarReorder {
src: ToolbarAction,
target: ToolbarAction,
},
/// Move one step up (toward index 0) inside the enabled toolbar list.
ToolbarMoveUp(ToolbarAction),
/// Move one step down (toward the end) inside the enabled toolbar list.
ToolbarMoveDown(ToolbarAction),
ToolbarReset,
/// Click on a toolbar button (via segmented_button activation).
ToolbarTabActivate(segmented_button::Entity),
/// Drag-reorder inside the toolbar (via segmented_button drag).
ToolbarTabReorder(segmented_button::ReorderEvent),
SetTypeToSearch(TypeToSearch), SetTypeToSearch(TypeToSearch),
SystemThemeModeChange, SystemThemeModeChange,
Size(window::Id, Size), Size(window::Id, Size),
@ -559,6 +681,9 @@ pub enum DialogPage {
mime: mime_guess::Mime, mime: mime_guess::Mime,
selected: usize, selected: usize,
store_opt: Option<MimeApp>, store_opt: Option<MimeApp>,
/// When true, the chosen app is written to mimeapps.list as the
/// default handler for `mime` after it spawns.
set_default: bool,
}, },
PermanentlyDelete { PermanentlyDelete {
paths: Box<[PathBuf]>, paths: Box<[PathBuf]>,
@ -716,6 +841,11 @@ pub struct App {
nav_bar_context_id: segmented_button::Entity, nav_bar_context_id: segmented_button::Entity,
nav_model: segmented_button::SingleSelectModel, nav_model: segmented_button::SingleSelectModel,
tab_model: segmented_button::Model<segmented_button::SingleSelect>, tab_model: segmented_button::Model<segmented_button::SingleSelect>,
/// Yoda phase 3: segmented_button model mirroring config.toolbar so the
/// toolbar row gets free drag-reorder + click activation (same widget
/// that powers the tab bar, its reorder is proven to work in this
/// setup unlike the generic dnd_source/dnd_destination wrappers).
toolbar_model: segmented_button::Model<segmented_button::SingleSelect>,
config_handler: Option<cosmic_config::Config>, config_handler: Option<cosmic_config::Config>,
state_handler: Option<cosmic_config::Config>, state_handler: Option<cosmic_config::Config>,
config: Config, config: Config,
@ -1071,7 +1201,7 @@ impl App {
.sort_by(|a, b| (b.1.width * b.1.height).total_cmp(&(a.1.width * b.1.height))); .sort_by(|a, b| (b.1.width * b.1.height).total_cmp(&(a.1.width * b.1.height)));
for (w_id, overlap) in sorted_overlaps { for (w_id, overlap) in sorted_overlaps {
let Some((bl, br, tl, tr, mut size)) = self.layer_sizes.get(w_id).map(|s| { let Some((bl, br, tl, tr, size)) = self.layer_sizes.get(w_id).map(|s| {
( (
Rectangle::new( Rectangle::new(
Point::new(0., s.height / 2.), Point::new(0., s.height / 2.),
@ -1121,30 +1251,18 @@ impl App {
if tl && !(tr || bl) { if tl && !(tr || bl) {
*top += min_dim.1; *top += min_dim.1;
*left += min_dim.0; *left += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
} }
if tr && !(tl || br) { if tr && !(tl || br) {
*top += min_dim.1; *top += min_dim.1;
*right += min_dim.0; *right += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
} }
if bl && !(br || tl) { if bl && !(br || tl) {
*bottom += min_dim.1; *bottom += min_dim.1;
*left += min_dim.0; *left += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
} }
if br && !(bl || tr) { if br && !(bl || tr) {
*bottom += min_dim.1; *bottom += min_dim.1;
*right += min_dim.0; *right += min_dim.0;
size.height -= min_dim.1;
size.width -= min_dim.0;
} }
} }
self.margin = overlaps; self.margin = overlaps;
@ -1503,12 +1621,18 @@ impl App {
) -> Task<Message> { ) -> Task<Message> {
log::info!("rescan_tab {entity:?} {location:?} {selection_paths:?}"); log::info!("rescan_tab {entity:?} {location:?} {selection_paths:?}");
let icon_sizes = self.config.tab.icon_sizes; let icon_sizes = self.config.tab.icon_sizes;
#[cfg(feature = "gvfs")]
let mounter_items = self.mounter_items.clone(); let mounter_items = self.mounter_items.clone();
Task::future(async move { Task::future(async move {
let location2 = location.clone(); let location2 = location.clone();
match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await { match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await {
Ok((parent_item_opt, mut items)) => { Ok((parent_item_opt, items)) => {
#[cfg(feature = "gvfs")]
let mut items = items;
#[cfg(not(feature = "gvfs"))]
let items = items;
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
{ {
let mounter_paths: Box<[_]> = mounter_items let mounter_paths: Box<[_]> = mounter_items
@ -1678,6 +1802,7 @@ impl App {
fn update_config(&mut self) -> Task<Message> { fn update_config(&mut self) -> Task<Message> {
self.update_nav_model(); self.update_nav_model();
self.rebuild_toolbar_model();
// Tabs are collected first to placate the borrowck // Tabs are collected first to placate the borrowck
let tabs: Box<[_]> = self.tab_model.iter().collect(); let tabs: Box<[_]> = self.tab_model.iter().collect();
// Update main conf and each tab with the new config // Update main conf and each tab with the new config
@ -1691,6 +1816,52 @@ impl App {
Task::batch(commands) Task::batch(commands)
} }
/// Yoda phase 3: rebuild `toolbar_model` so it matches `config.toolbar`.
/// Called on init and on every config update. Each entity carries the
/// associated `ToolbarAction` as data so click/reorder handlers can
/// round-trip Entity → ToolbarAction without maintaining a side map.
///
/// We insert ONLY the icon (no `.text()`) so the toolbar renders as a
/// clean icon row — user-visible labels stay in Settings/tooltips on
/// other surfaces.
fn rebuild_toolbar_model(&mut self) {
self.toolbar_model.clear();
for action in self.config.toolbar.iter().copied() {
let (icon_name, _label, _msg) = toolbar_action_ui(action);
self.toolbar_model
.insert()
.icon(widget::icon::from_name(icon_name).size(16).icon())
.data::<ToolbarAction>(action);
}
}
/// Yoda phase 3: after a drag-reorder, sync `config.toolbar` with the
/// new entity order in `toolbar_model`. Inlines what `config_set!`
/// would do (the macro lives inside update()).
fn sync_toolbar_config_from_model(&mut self) -> Task<Message> {
let new_toolbar: Vec<ToolbarAction> = self
.toolbar_model
.iter()
.filter_map(|e| self.toolbar_model.data::<ToolbarAction>(e).copied())
.collect();
if new_toolbar == self.config.toolbar {
return Task::none();
}
match self.config_handler.as_ref() {
Some(h) => {
if let Err(err) = self.config.set_toolbar(h, new_toolbar) {
log::warn!("failed to save toolbar order: {err}");
}
}
None => self.config.toolbar = new_toolbar,
}
// Don't call update_config() — that would rebuild the
// toolbar_model from config and undo the reorder the user just
// made. The model already has the new order; config is just
// catching up for persistence.
Task::none()
}
fn update_desktop(&mut self) -> Task<Message> { fn update_desktop(&mut self) -> Task<Message> {
let needs_reload: Box<[_]> = (self.tab_model.iter()) let needs_reload: Box<[_]> = (self.tab_model.iter())
.filter_map(|entity| { .filter_map(|entity| {
@ -2268,10 +2439,134 @@ impl App {
.toggler(self.config.show_recents, Message::SetShowRecents) .toggler(self.config.show_recents, Message::SetShowRecents)
}) })
.into(), .into(),
// Yoda phase 3: toolbar editor. Two stacked lists:
// - top: enabled buttons in their current order (drag to reorder)
// - bottom: available (not-yet-enabled) buttons
// Each row's toggle adds/removes; enabled rows are also
// drag sources + drop targets.
self.toolbar_settings_section(),
]) ])
.into() .into()
} }
/// Yoda phase 3: build the Toolbar settings section.
fn toolbar_settings_section(&self) -> Element<'_, Message> {
use iced::clipboard::dnd::DndAction;
let enabled = &self.config.toolbar;
let disabled: Vec<ToolbarAction> = ToolbarAction::ALL
.iter()
.copied()
.filter(|a| !enabled.contains(a))
.collect();
let space_xxs = theme::active().cosmic().spacing.space_xxs;
let drag_icon = |size: u16| -> Element<'static, Message> {
widget::icon::from_name("list-drag-handle-symbolic")
.size(size)
.into()
};
let row_enabled =
|action: ToolbarAction, pos: usize, last: usize| -> Element<'_, Message> {
let (icon, label, _msg) = toolbar_action_ui(action);
let up_btn =
widget::button::icon(widget::icon::from_name("go-up-symbolic").size(14));
let up_btn = if pos > 0 {
up_btn.on_press(Message::ToolbarMoveUp(action))
} else {
up_btn
};
let down_btn =
widget::button::icon(widget::icon::from_name("go-down-symbolic").size(14));
let down_btn = if pos < last {
down_btn.on_press(Message::ToolbarMoveDown(action))
} else {
down_btn
};
let row_content: Element<_> = widget::row::with_children(vec![
drag_icon(14),
widget::icon::from_name(icon).size(16).into(),
widget::text::body(label).width(Length::Fill).into(),
up_btn.into(),
down_btn.into(),
widget::button::icon(widget::icon::from_name("list-remove-symbolic").size(14))
.on_press(Message::ToolbarRemove(action))
.into(),
])
.spacing(space_xxs)
.align_y(Alignment::Center)
.into();
let row_container = widget::container(row_content)
.width(Length::Fill)
.padding(space_xxs);
// Wrap as DnD source (drags itself) + DnD destination (accepts
// drops from other enabled rows; on drop, move the src before
// this row).
let source = widget::dnd_source::<Message, ToolbarActionPayload>(row_container)
.drag_content(move || ToolbarActionPayload(action.to_u8()));
widget::dnd_destination(source, vec![std::borrow::Cow::Borrowed(TOOLBAR_MIME)])
.data_received_for::<ToolbarActionPayload>(
move |payload: Option<ToolbarActionPayload>| {
match payload.and_then(|p| ToolbarAction::from_u8(p.0)) {
Some(src) if src != action => Message::ToolbarReorder {
src,
target: action,
},
// No-op if payload missing / malformed / same row.
_ => Message::ToolbarReorder {
src: action,
target: action,
},
}
},
)
.action(DndAction::Move)
.into()
};
let row_disabled = |action: ToolbarAction| -> Element<'_, Message> {
let (icon, label, _msg) = toolbar_action_ui(action);
widget::row::with_children(vec![
widget::icon::from_name(icon).size(16).into(),
widget::text::body(label).width(Length::Fill).into(),
widget::button::icon(widget::icon::from_name("list-add-symbolic").size(14))
.on_press(Message::ToolbarAdd(action))
.into(),
])
.spacing(space_xxs)
.align_y(Alignment::Center)
.padding(space_xxs)
.into()
};
let mut section = widget::settings::section().title(fl!("toolbar"));
if enabled.is_empty() {
section = section.add(widget::text::body(fl!("toolbar-empty-hint")));
} else {
let last = enabled.len() - 1;
for (pos, a) in enabled.iter().copied().enumerate() {
section = section.add(row_enabled(a, pos, last));
}
}
let mut col = widget::column::with_capacity(3).spacing(space_xxs);
col = col.push(section);
if !disabled.is_empty() {
let mut avail = widget::settings::section().title(fl!("toolbar-available"));
for a in disabled {
avail = avail.add(row_disabled(a));
}
col = col.push(avail);
}
col = col
.push(widget::button::standard(fl!("toolbar-reset")).on_press(Message::ToolbarReset));
col.into()
}
fn get_apps_for_mime(&self, mime_type: &Mime) -> Vec<(&MimeApp, MimeAppMatch)> { fn get_apps_for_mime(&self, mime_type: &Mime) -> Vec<(&MimeApp, MimeAppMatch)> {
let mut results = Vec::new(); let mut results = Vec::new();
@ -2443,6 +2738,7 @@ impl Application for App {
nav_bar_context_id: segmented_button::Entity::null(), nav_bar_context_id: segmented_button::Entity::null(),
nav_model: segmented_button::ModelBuilder::default().build(), nav_model: segmented_button::ModelBuilder::default().build(),
tab_model: segmented_button::ModelBuilder::default().build(), tab_model: segmented_button::ModelBuilder::default().build(),
toolbar_model: segmented_button::ModelBuilder::default().build(),
config_handler: flags.config_handler, config_handler: flags.config_handler,
state_handler: flags.state_handler, state_handler: flags.state_handler,
config: flags.config, config: flags.config,
@ -3222,6 +3518,7 @@ impl Application for App {
path, path,
mime, mime,
selected, selected,
set_default,
.. ..
} => { } => {
let available_apps = self.get_apps_for_mime(&mime); let available_apps = self.get_apps_for_mime(&mime);
@ -3240,6 +3537,11 @@ impl Application for App {
None, None,
); );
} }
// Yoda: persist as default if the user asked for it.
if set_default {
self.mime_app_cache
.set_default(mime.clone(), app.id.clone());
}
} }
Err(err) => { Err(err) => {
log::warn!( log::warn!(
@ -3862,6 +4164,7 @@ impl Application for App {
.and_then(|mime| { .and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned() self.mime_app_cache.get(&mime).first().cloned()
}), }),
set_default: false,
}, },
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()), Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
); );
@ -3873,6 +4176,13 @@ impl Application for App {
*selected = index; *selected = index;
} }
} }
Message::OpenWithToggleDefault(enabled) => {
if let Some(DialogPage::OpenWith { set_default, .. }) =
self.dialog_pages.front_mut()
{
*set_default = enabled;
}
}
Message::Paste(entity_opt) => { Message::Paste(entity_opt) => {
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
@ -4349,6 +4659,86 @@ impl Application for App {
config_set!(show_recents, show_recents); config_set!(show_recents, show_recents);
return self.update_config(); return self.update_config();
} }
Message::ToolbarAdd(action) => {
let mut tb = self.config.toolbar.clone();
if !tb.contains(&action) {
tb.push(action);
}
config_set!(toolbar, tb);
return self.update_config();
}
Message::ToolbarRemove(action) => {
let mut tb = self.config.toolbar.clone();
tb.retain(|a| a != &action);
config_set!(toolbar, tb);
return self.update_config();
}
Message::ToolbarReorder { src, target } => {
let mut tb = self.config.toolbar.clone();
if let (Some(src_idx), Some(tgt_idx)) = (
tb.iter().position(|a| a == &src),
tb.iter().position(|a| a == &target),
) && src_idx != tgt_idx
{
// Pull src out, then insert before the target's new position.
let item = tb.remove(src_idx);
let new_tgt = if src_idx < tgt_idx {
tgt_idx - 1
} else {
tgt_idx
};
tb.insert(new_tgt, item);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarMoveUp(action) => {
let mut tb = self.config.toolbar.clone();
if let Some(i) = tb.iter().position(|a| a == &action)
&& i > 0
{
tb.swap(i, i - 1);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarMoveDown(action) => {
let mut tb = self.config.toolbar.clone();
if let Some(i) = tb.iter().position(|a| a == &action)
&& i + 1 < tb.len()
{
tb.swap(i, i + 1);
config_set!(toolbar, tb);
return self.update_config();
}
return Task::none();
}
Message::ToolbarReset => {
config_set!(toolbar, default_toolbar());
return self.update_config();
}
Message::ToolbarTabActivate(entity) => {
// Dispatch the stored ToolbarAction's message, then clear
// the "active" selection so the button doesn't stay
// highlighted after a click (we use segmented_button for
// layout/drag but toolbar buttons are action-firing, not
// a mutual-exclusive choice).
let action = self.toolbar_model.data::<ToolbarAction>(entity).copied();
self.toolbar_model.deactivate();
if let Some(action) = action {
let (_, _, msg) = toolbar_action_ui(action);
return self.update(msg);
}
return Task::none();
}
Message::ToolbarTabReorder(event) => {
let _ = self
.toolbar_model
.reorder(event.dragged, event.target, event.position);
return self.sync_toolbar_config_from_model();
}
Message::SetTypeToSearch(type_to_search) => { Message::SetTypeToSearch(type_to_search) => {
config_set!(type_to_search, type_to_search); config_set!(type_to_search, type_to_search);
return self.update_config(); return self.update_config();
@ -5108,6 +5498,7 @@ impl Application for App {
.and_then(|mime| { .and_then(|mime| {
self.mime_app_cache.get(&mime).first().cloned() self.mime_app_cache.get(&mime).first().cloned()
}), }),
set_default: false,
}, },
None, None,
); );
@ -5951,7 +6342,7 @@ impl Application for App {
mime, mime,
selected, selected,
store_opt, store_opt,
.. set_default,
} => { } => {
let name = match path.file_name() { let name = match path.file_name() {
Some(file_name) => file_name.to_str(), Some(file_name) => file_name.to_str(),
@ -6036,7 +6427,21 @@ impl Application for App {
} else { } else {
Length::Shrink Length::Shrink
} }
})); }))
// Yoda: let the user make this choice stick. A plain row
// instead of settings::item::builder because the latter
// returns a section Item, not an Element usable in .control().
.control(
widget::row::with_children([
widget::text::body(fl!("open-with-set-default")).into(),
widget::space::horizontal().into(),
widget::toggler(*set_default)
.on_toggle(Message::OpenWithToggleDefault)
.into(),
])
.spacing(space_s)
.align_y(Alignment::Center),
);
if let Some(app) = store_opt { if let Some(app) = store_opt {
dialog = dialog.tertiary_action( dialog = dialog.tertiary_action(
@ -6442,7 +6847,10 @@ impl Application for App {
/// Creates a view after each update. /// Creates a view after each update.
fn view(&self) -> Element<'_, Self::Message> { fn view(&self) -> Element<'_, Self::Message> {
let cosmic_theme::Spacing { let cosmic_theme::Spacing {
space_xxs, space_s, .. space_xxs,
space_xs,
space_s,
..
} = theme::spacing(); } = theme::spacing();
let mut tab_column = widget::column::with_capacity(4); let mut tab_column = widget::column::with_capacity(4);
@ -6486,6 +6894,36 @@ impl Application for App {
); );
} }
// Yoda phase 3: Dolphin-style quick actions toolbar via
// segmented_button::horizontal — the same widget that powers the
// tab bar, so its built-in drag reorder works reliably (unlike the
// generic dnd_source+dnd_destination pairing we tried earlier).
// Short click = action (ToolbarTabActivate → dispatch the stored
// ToolbarAction's message). Drag past threshold = reorder
// (ToolbarTabReorder → model.reorder + sync to config).
if !self.config.toolbar.is_empty() {
// Use Control style (no TabBar underline, no bottom border)
// and let each button shrink to its icon. Spacing = space_xs
// keeps the buttons visually separated so it looks like an
// icon toolbar rather than a conjoined segmented control.
let toolbar = widget::segmented_button::horizontal(&self.toolbar_model)
.style(theme::SegmentedButton::Control)
.button_height(32)
.button_spacing(space_xs)
.button_alignment(Alignment::Center)
.minimum_button_width(32)
.maximum_button_width(32)
.enable_tab_drag(String::from("x-cosmic-files/toolbar-dnd"))
.on_reorder(Message::ToolbarTabReorder)
.tab_drag_threshold(8.)
.on_activate(Message::ToolbarTabActivate);
tab_column = tab_column.push(
widget::container(toolbar)
.width(Length::Shrink)
.padding([space_xxs, space_s]),
);
}
let entity = self.tab_model.active(); let entity = self.tab_model.active();
if let Some(tab) = self.tab_model.data::<Tab>(entity) { if let Some(tab) = self.tab_model.data::<Tab>(entity) {
let tab_view = tab let tab_view = tab

View file

@ -130,9 +130,11 @@ impl TryFrom<(Vec<u8>, String)> for ClipboardPaste {
match mime.as_str() { match mime.as_str() {
"text/uri-list" => { "text/uri-list" => {
let text = str::from_utf8(&data)?; let text = str::from_utf8(&data)?;
let _lines = text.lines();
for line in text.lines() { for line in text.lines().filter(|line| {
let line = line.trim();
!line.is_empty() && !line.starts_with('#')
}) {
let url = Url::parse(line)?; let url = Url::parse(line)?;
match url.to_file_path() { match url.to_file_path() {
Ok(path) => paths.push(path), Ok(path) => paths.push(path),

View file

@ -170,6 +170,11 @@ pub struct Config {
pub show_details: bool, pub show_details: bool,
pub show_recents: bool, pub show_recents: bool,
pub tab: TabConfig, pub tab: TabConfig,
/// Yoda phase 3: Dolphin-style quick actions toolbar. An ordered list
/// of enabled buttons — position in the vec drives the toolbar order.
/// Reorder in Settings via drag-drop; items not in the vec are
/// hidden. Default = the minimal-6 set from phase 1.
pub toolbar: Vec<ToolbarAction>,
pub type_to_search: TypeToSearch, pub type_to_search: TypeToSearch,
} }
@ -234,11 +239,97 @@ impl Default for Config {
show_details: false, show_details: false,
show_recents: true, show_recents: true,
tab: TabConfig::default(), tab: TabConfig::default(),
toolbar: default_toolbar(),
type_to_search: TypeToSearch::Recursive, type_to_search: TypeToSearch::Recursive,
} }
} }
} }
/// Yoda phase 3: ordered enum of quick-action toolbar buttons.
/// The Config stores `Vec<ToolbarAction>` so the user can pick BOTH
/// visibility (just include/exclude the variant) AND order (position in
/// the vec). Drag-drop reorder in the Settings page moves items around.
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub enum ToolbarAction {
LocationUp,
Reload,
NewFolder,
NewFile,
Rename,
Delete,
Cut,
Copy,
Paste,
ToggleShowHidden,
OpenTerminal,
}
impl ToolbarAction {
/// Stable list of every supported action. Ordered roughly by logical
/// grouping (location → create/edit → clipboard → view/misc) so that
/// the default enabled set follows a sensible shape and the Settings
/// row for a not-yet-enabled action lands in a predictable spot.
pub const ALL: &'static [Self] = &[
Self::LocationUp,
Self::Reload,
Self::NewFolder,
Self::NewFile,
Self::Rename,
Self::Delete,
Self::Cut,
Self::Copy,
Self::Paste,
Self::ToggleShowHidden,
Self::OpenTerminal,
];
/// u8 discriminant used to carry the action over a DnD mime payload.
pub const fn to_u8(self) -> u8 {
match self {
Self::LocationUp => 0,
Self::Reload => 1,
Self::NewFolder => 2,
Self::NewFile => 3,
Self::Rename => 4,
Self::Delete => 5,
Self::Cut => 6,
Self::Copy => 7,
Self::Paste => 8,
Self::ToggleShowHidden => 9,
Self::OpenTerminal => 10,
}
}
pub const fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::LocationUp),
1 => Some(Self::Reload),
2 => Some(Self::NewFolder),
3 => Some(Self::NewFile),
4 => Some(Self::Rename),
5 => Some(Self::Delete),
6 => Some(Self::Cut),
7 => Some(Self::Copy),
8 => Some(Self::Paste),
9 => Some(Self::ToggleShowHidden),
10 => Some(Self::OpenTerminal),
_ => None,
}
}
}
/// Default set shown on a fresh install — same "minimal 6" as phase 1/2.
pub fn default_toolbar() -> Vec<ToolbarAction> {
vec![
ToolbarAction::NewFolder,
ToolbarAction::Rename,
ToolbarAction::Delete,
ToolbarAction::Cut,
ToolbarAction::Copy,
ToolbarAction::Paste,
]
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct DesktopConfig { pub struct DesktopConfig {

View file

@ -732,11 +732,17 @@ impl App {
fn rescan_tab(&self, selection_paths: Option<Vec<PathBuf>>) -> Task<Message> { fn rescan_tab(&self, selection_paths: Option<Vec<PathBuf>>) -> Task<Message> {
let location = self.tab.location.clone(); let location = self.tab.location.clone();
let icon_sizes = self.tab.config.icon_sizes; let icon_sizes = self.tab.config.icon_sizes;
#[cfg(feature = "gvfs")]
let mounter_items = self.mounter_items.clone(); let mounter_items = self.mounter_items.clone();
Task::future(async move { Task::future(async move {
let location2 = location.clone(); let location2 = location.clone();
match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await { match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await {
Ok((parent_item_opt, mut items)) => { Ok((parent_item_opt, items)) => {
#[cfg(feature = "gvfs")]
let mut items = items;
#[cfg(not(feature = "gvfs"))]
let items = items;
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
{ {
let mounter_paths: Box<[_]> = mounter_items let mounter_paths: Box<[_]> = mounter_items

View file

@ -38,6 +38,7 @@ mod zoom;
pub(crate) type FxOrderMap<K, V> = ordermap::OrderMap<K, V, rustc_hash::FxBuildHasher>; pub(crate) type FxOrderMap<K, V> = ordermap::OrderMap<K, V, rustc_hash::FxBuildHasher>;
#[cfg(feature = "gvfs")]
pub(crate) fn err_str<T: ToString>(err: T) -> String { pub(crate) fn err_str<T: ToString>(err: T) -> String {
err.to_string() err.to_string()
} }

View file

@ -10,6 +10,7 @@ use cosmic::widget::{
self, Row, button, column, container, divider, responsive_menu_bar, space, text, self, Row, button, column, container, divider, responsive_menu_bar, space, text,
}; };
use cosmic::{Element, theme}; use cosmic::{Element, theme};
#[cfg(feature = "desktop")]
use i18n_embed::LanguageLoader; use i18n_embed::LanguageLoader;
use mime_guess::Mime; use mime_guess::Mime;
use std::collections::HashMap; use std::collections::HashMap;
@ -190,11 +191,11 @@ pub fn context_menu<'a>(
if !Trash::is_empty() { if !Trash::is_empty() {
children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into()); children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into());
} }
} else if let Some(entry) = selected_desktop_entry { } else if let Some(_entry) = selected_desktop_entry {
children.push(menu_item(fl!("open"), Action::Open).into()); children.push(menu_item(fl!("open"), Action::Open).into());
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
{ {
children.extend(entry.desktop_actions.into_iter().enumerate().map( children.extend(_entry.desktop_actions.into_iter().enumerate().map(
|(i, action)| menu_item(action.name, Action::ExecEntryAction(i)).into(), |(i, action)| menu_item(action.name, Action::ExecEntryAction(i)).into(),
)); ));
} }

View file

@ -7,12 +7,19 @@ use cosmic::desktop;
use cosmic::widget; use cosmic::widget;
pub use mime_guess::Mime; pub use mime_guess::Mime;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::cmp::Ordering; #[cfg(feature = "desktop")]
use std::ffi::OsStr; use std::{cmp::Ordering, fs, io, time::Instant};
use std::os::unix::ffi::OsStrExt; use std::{
use std::path::{Path, PathBuf}; ffi::OsStr,
use std::time::Instant; os::unix::ffi::OsStrExt,
use std::{fs, io, process}; path::{Path, PathBuf},
process,
};
// 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( pub fn exec_to_command(
exec: &str, exec: &str,
@ -364,9 +371,12 @@ impl MimeAppCache {
// The current approach works but might not adhere to the spec (yet) // The current approach works but might not adhere to the spec (yet)
// Look for and return preferred terminals // Look for and return preferred terminals
//TODO: fallback order beyond cosmic-term? // Yoda: cosmic-yoterm (our fork) wins over upstream cosmic-term if both
// are installed — useful when xdg-mime default is not set.
let mut preference_order = vec!["com.system76.CosmicTerm".to_string()]; let mut preference_order = vec![
"com.aditua.CosmicYoterm".to_string(),
"com.system76.CosmicTerm".to_string(),
];
if let Some(id) = self.get_default_terminal() { if let Some(id) = self.get_default_terminal() {
preference_order.insert(0, id); preference_order.insert(0, id);

View file

@ -75,10 +75,10 @@ impl MounterItem {
} }
} }
pub fn icon(&self, symbolic: bool) -> Option<widget::icon::Handle> { pub fn icon(&self, _symbolic: bool) -> Option<widget::icon::Handle> {
match self { match self {
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
Self::Gvfs(item) => item.icon(symbolic), Self::Gvfs(item) => item.icon(_symbolic),
Self::None => unreachable!(), Self::None => unreachable!(),
} }
} }
@ -103,6 +103,7 @@ impl MounterItem {
pub type MounterItems = Vec<MounterItem>; pub type MounterItems = Vec<MounterItem>;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum MounterMessage { pub enum MounterMessage {
Items(MounterItems), Items(MounterItems),
MountResult(MounterItem, Result<bool, String>), MountResult(MounterItem, Result<bool, String>),

View file

@ -10,6 +10,7 @@ use compio::driver::ToSharedFd;
use compio::driver::op::AsyncifyFd; use compio::driver::op::AsyncifyFd;
use compio::io::{AsyncReadAt, AsyncWriteAt}; use compio::io::{AsyncReadAt, AsyncWriteAt};
use cosmic::iced::futures; use cosmic::iced::futures;
#[cfg(feature = "gvfs")]
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use std::cell::Cell; use std::cell::Cell;
use std::error::Error; use std::error::Error;

View file

@ -18,6 +18,7 @@ use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind; use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::{self, DndDestination, DndSource, Id, RcElementWrapper, Widget, space}; use cosmic::widget::{self, DndDestination, DndSource, Id, RcElementWrapper, Widget, space};
use cosmic::{Apply, Element, cosmic_theme, font, theme}; use cosmic::{Apply, Element, cosmic_theme, font, theme};
#[cfg(feature = "desktop")]
use i18n_embed::LanguageLoader; use i18n_embed::LanguageLoader;
use icu::datetime::input::DateTime; use icu::datetime::input::DateTime;
use icu::datetime::options::TimePrecision; use icu::datetime::options::TimePrecision;
@ -288,6 +289,26 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han
.handle() .handle()
} }
fn generic_file_icons(
sizes: IconSizes,
) -> (
widget::icon::Handle,
widget::icon::Handle,
widget::icon::Handle,
) {
(
widget::icon::from_name("text-x-generic")
.size(sizes.grid())
.handle(),
widget::icon::from_name("text-x-generic")
.size(sizes.list())
.handle(),
widget::icon::from_name("text-x-generic")
.size(sizes.list_condensed())
.handle(),
)
}
//TODO: replace with Path::has_trailing_sep when stable //TODO: replace with Path::has_trailing_sep when stable
fn has_trailing_sep(path: &Path) -> bool { fn has_trailing_sep(path: &Path) -> bool {
path.as_os_str() path.as_os_str()
@ -544,7 +565,7 @@ pub fn fs_kind(_metadata: &Metadata) -> FsKind {
} }
#[cfg(not(feature = "desktop"))] #[cfg(not(feature = "desktop"))]
fn get_desktop_file_display_name(path: &Path) -> Option<String> { fn get_desktop_file_display_name(_path: &Path) -> Option<String> {
None None
} }
@ -563,7 +584,7 @@ fn get_desktop_file_display_name(path: &Path) -> Option<String> {
} }
#[cfg(not(feature = "desktop"))] #[cfg(not(feature = "desktop"))]
fn get_desktop_file_icon(path: &Path) -> Option<String> { fn get_desktop_file_icon(_path: &Path) -> Option<String> {
None None
} }
@ -654,9 +675,9 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
folder_icon(&path, sizes.list_condensed()), folder_icon(&path, sizes.list_condensed()),
) )
} else { } else {
// ALWAYS assume we're remote for mime guessing here, since gvfs reading can be expensive // Keep the initial directory scan cheap. Opening files still
// @todo - expose this as a config option? // recalculates MIME from the real path before launching apps.
let mime = mime_for_path(&path, None, true); let mime = mime_guess::from_path(&path).first_or_octet_stream();
//TODO: clean this up, implement for trash //TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" { let icon_name_opt = if mime == "application/x-desktop" {
@ -673,28 +694,21 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
desktop_icon_handle(&icon_name, sizes.list_condensed()), desktop_icon_handle(&icon_name, sizes.list_condensed()),
) )
} else { } else {
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
generic_file_icons(sizes);
( (
mime.clone(), mime,
mime_icon(mime.clone(), sizes.grid()), icon_handle_grid,
mime_icon(mime.clone(), sizes.list()), icon_handle_list,
mime_icon(mime, sizes.list_condensed()), icon_handle_list_condensed,
) )
} }
}; };
let mut children_opt = None; let children_opt = None;
let mut dir_size = DirSize::NotDirectory; let mut dir_size = DirSize::NotDirectory;
if is_dir && !remote { if is_dir && !remote {
dir_size = DirSize::Calculating(Controller::default()); dir_size = DirSize::Calculating(Controller::default());
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
}
}
} }
let display_name = display_name_for_file(&path, &file_info.display_name(), false, is_desktop); let display_name = display_name_for_file(&path, &file_info.display_name(), false, is_desktop);
@ -748,7 +762,10 @@ pub fn item_from_entry(
sizes: IconSizes, sizes: IconSizes,
) -> Item { ) -> Item {
let mut is_desktop = false; let mut is_desktop = false;
#[cfg(feature = "gvfs")]
let mut is_gvfs = false; let mut is_gvfs = false;
#[cfg(not(feature = "gvfs"))]
let is_gvfs = false;
let hidden = name.starts_with('.') || hidden_attribute(&metadata); let hidden = name.starts_with('.') || hidden_attribute(&metadata);
@ -796,7 +813,9 @@ pub fn item_from_entry(
folder_icon(&path, sizes.list_condensed()), folder_icon(&path, sizes.list_condensed()),
) )
} else { } else {
let mime = mime_for_path(&path, Some(&metadata), remote); // Keep the initial directory scan cheap. Opening files still
// recalculates MIME from the real path before launching apps.
let mime = mime_guess::from_path(&path).first_or_octet_stream();
//TODO: clean this up, implement for trash //TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" { let icon_name_opt = if mime == "application/x-desktop" {
is_desktop = true; is_desktop = true;
@ -812,28 +831,21 @@ pub fn item_from_entry(
desktop_icon_handle(&icon_name, sizes.list_condensed()), desktop_icon_handle(&icon_name, sizes.list_condensed()),
) )
} else { } else {
let (icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
generic_file_icons(sizes);
( (
mime.clone(), mime,
mime_icon(mime.clone(), sizes.grid()), icon_handle_grid,
mime_icon(mime.clone(), sizes.list()), icon_handle_list,
mime_icon(mime, sizes.list_condensed()), icon_handle_list_condensed,
) )
} }
}; };
let mut children_opt = None; let children_opt = None;
let mut dir_size = DirSize::NotDirectory; let mut dir_size = DirSize::NotDirectory;
if metadata.is_dir() && !remote { if metadata.is_dir() && !remote {
dir_size = DirSize::Calculating(Controller::default()); dir_size = DirSize::Calculating(Controller::default());
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
}
}
} }
let display_name = display_name_for_file(&path, &name, is_gvfs, is_desktop); let display_name = display_name_for_file(&path, &name, is_gvfs, is_desktop);
@ -947,7 +959,10 @@ pub fn item_from_path<P: Into<PathBuf>>(path: P, sizes: IconSizes) -> Result<Ite
pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> { pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let mut items = Vec::new(); let mut items = Vec::new();
let mut hidden_files = Box::from([]); let mut hidden_files = Box::from([]);
#[cfg(feature = "gvfs")]
let mut remote_scannable = false; let mut remote_scannable = false;
#[cfg(not(feature = "gvfs"))]
let remote_scannable = false;
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
{ {

View file

@ -5,10 +5,13 @@
use cosmic::desktop::fde::GenericEntry; use cosmic::desktop::fde::GenericEntry;
use mime_guess::Mime; use mime_guess::Mime;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::path::Path; #[cfg(feature = "desktop")]
use std::sync::{LazyLock, Mutex}; use std::{fs, time::Instant};
use std::time::Instant; use std::{
use std::{fs, process}; path::Path,
process,
sync::{LazyLock, Mutex},
};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Thumbnailer { pub struct Thumbnailer {

View file

@ -142,4 +142,12 @@ impl TrashExt for Trash {
not(target_os = "android") not(target_os = "android")
) )
)))] )))]
impl TrashExt for Trash {} impl TrashExt for Trash {
fn scan_search<F: Fn(SearchItem) -> bool + Sync>(callback: F, regex: &Regex) {
log::warn!(
"searching trash not supported on this platform for pattern {:?}",
regex.as_str()
);
drop(callback);
}
}