Merge tag 'epoch-1.7.0' into local/libcosmic-path-patch

Epoch 1.7.0

# Conflicts:
#	Cargo.lock
#	src/app.rs
This commit is contained in:
Lionel DARNIS 2026-08-27 07:52:59 +02:00
commit deb6000c10
13 changed files with 773 additions and 218 deletions

411
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-files" name = "cosmic-files"
version = "1.6.0" version = "1.7.0"
authors = ["Jeremy Soller <jeremy@system76.com>"] authors = ["Jeremy Soller <jeremy@system76.com>"]
edition = "2024" edition = "2024"
license = "GPL-3.0-only" license = "GPL-3.0-only"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "cosmic-files-applet" name = "cosmic-files-applet"
version = "1.6.0" version = "1.7.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
cosmic-files (1.7.0) noble; urgency=medium
* Epoch 1.7.0 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 25 Aug 2026 09:20:22 -0600
cosmic-files (1.6.0) noble; urgency=medium cosmic-files (1.6.0) noble; urgency=medium
* Epoch 1.6.0 version update * Epoch 1.6.0 version update

View file

@ -5,7 +5,7 @@ no-results = Fandt ingen resultater
filesystem = Filsystem filesystem = Filsystem
home = Hjem home = Hjem
networks = Netværk networks = Netværk
notification-in-progress = Fil-operationer er igang. notification-in-progress = Fil-operationer er igang
trash = Papirkurv trash = Papirkurv
recents = Seneste recents = Seneste
undo = Fortryd undo = Fortryd
@ -69,7 +69,7 @@ name-no-slashes = Navn må ikke indeholde skråstreger
## Open/Save Dialog ## Open/Save Dialog
cancel = Annuller cancel = Afbryd
create = Opret create = Opret
open = Åbn open = Åbn
open-file = Åbn fil open-file = Åbn fil
@ -95,7 +95,7 @@ rename-folder = Omdøb mappe
## Replace Dialog ## Replace Dialog
replace = Erstat replace = Erstat
replace-title = "{ $filename }" eksisterer allerede i denne placering. replace-title = "{ $filename }" eksisterer allerede i denne placering
replace-warning = Ønsker du at erstatte den med den, du er ved at gemme? Erstatning af den vil overskrive dens indhold. replace-warning = Ønsker du at erstatte den med den, du er ved at gemme? Erstatning af den vil overskrive dens indhold.
replace-warning-operation = Ønsker du at erstatte den? Erstatning af den vil overskrive dens indhold. replace-warning-operation = Ønsker du at erstatte den? Erstatning af den vil overskrive dens indhold.
original-file = Original fil original-file = Original fil
@ -253,7 +253,7 @@ light = Lyst
# Context menu # Context menu
add-to-sidebar = Tilføj til sidebjælke add-to-sidebar = Tilføj til sidebjælke
compress = Komprimer… compress = Komprimer…
extract-here = Extract extract-here = Uddrag
new-file = Ny fil... new-file = Ny fil...
new-folder = Ny mappe... new-folder = Ny mappe...
open-in-terminal = Åbn i terminal open-in-terminal = Åbn i terminal
@ -332,7 +332,7 @@ deleted =
Slettede { $items } { $items -> Slettede { $items } { $items ->
[one] element [one] element
*[other] elementer *[other] elementer
} fra papirkurven } fra { trash }
removing-from-recents = removing-from-recents =
Fjerner { $items } { $items -> Fjerner { $items } { $items ->
[one] element [one] element
@ -360,7 +360,7 @@ remove = Fjern
context-action = Konteksthandling context-action = Konteksthandling
context-action-confirm-title = Kør “{ $name }”? context-action-confirm-title = Kør “{ $name }”?
context-action-confirm-warning = context-action-confirm-warning =
This will run on { $items } { $items -> Dette vil køre på { $items } { $items ->
[one] item [one] item
*[other] items *[other] items
}. }.

View file

@ -119,6 +119,9 @@ rename-file = Rename file
rename-folder = Rename folder rename-folder = Rename folder
rename-confirm = Rename rename-confirm = Rename
## Change Sidebar Label Dialog
sidebar-label = Sidebar label
## Replace Dialog ## Replace Dialog
replace = Replace replace = Replace
replace-title = "{$filename}" already exists in this location replace-title = "{$filename}" already exists in this location
@ -299,6 +302,7 @@ unknown-folder = unknown folder
## Open with ## Open with
menu-open-with = Open with... menu-open-with = Open with...
default-app = {$name} (default) default-app = {$name} (default)
search-application = Search by app name
## Show details ## Show details
show-details = Show details show-details = Show details
@ -347,6 +351,7 @@ move-to = Move to...
move-to-trash = Move to trash move-to-trash = Move to trash
restore-from-trash = Restore from trash restore-from-trash = Restore from trash
remove-from-sidebar = Remove from sidebar remove-from-sidebar = Remove from sidebar
change-sidebar-label = Change sidebar label
sort-by-name = Sort by name sort-by-name = Sort by name
sort-by-modified = Sort by modified sort-by-modified = Sort by modified
sort-by-size = Sort by size sort-by-size = Sort by size

View file

@ -109,6 +109,10 @@ permanently-delete-warning = Deseja realmente excluir permanentemente { $target
rename-file = Renomear arquivo rename-file = Renomear arquivo
rename-folder = Renomear pasta rename-folder = Renomear pasta
## Change Sidebar Label Dialog
sidebar-label = Rótulo da barra lateral
## Replace Dialog ## Replace Dialog
replace = Substituir replace = Substituir
@ -350,7 +354,7 @@ light = Estilo claro
### Type to Search ### Type to Search
type-to-search = Digite para pesquisar type-to-search = Digitar para pesquisar
type-to-search-recursive = Pesquisa na pasta atual e em todas as subpastas type-to-search-recursive = Pesquisa na pasta atual e em todas as subpastas
type-to-search-enter-path = Insere o caminho do diretório ou arquivo type-to-search-enter-path = Insere o caminho do diretório ou arquivo
# Context menu # Context menu
@ -365,6 +369,7 @@ open-in-terminal = Abrir no terminal
move-to-trash = Mover para a lixeira move-to-trash = Mover para a lixeira
restore-from-trash = Restaurar da lixeira restore-from-trash = Restaurar da lixeira
remove-from-sidebar = Remover da barra lateral remove-from-sidebar = Remover da barra lateral
change-sidebar-label = Alterar rótulo na barra lateral
sort-by-name = Ordenar por nome sort-by-name = Ordenar por nome
sort-by-modified = Ordenar por data de modificação sort-by-modified = Ordenar por data de modificação
sort-by-size = Ordenar por tamanho sort-by-size = Ordenar por tamanho

View file

@ -174,6 +174,7 @@ unknown-folder = неизвестная папка
menu-open-with = Открыть с помощью… menu-open-with = Открыть с помощью…
default-app = { $name } (по умолчанию) default-app = { $name } (по умолчанию)
search-application = Поиск по названию приложения
## Show details ## Show details

View file

@ -37,7 +37,7 @@ use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::menu::key_bind::KeyBind; use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::segmented_button::{self, Entity, ReorderEvent}; use cosmic::widget::segmented_button::{self, Entity, ReorderEvent};
use cosmic::widget::{self, icon, settings, space}; use cosmic::widget::{self, icon, settings, space};
use cosmic::{Application, ApplicationExt, Element, cosmic_theme, executor, style, surface, theme}; use cosmic::{Application, ApplicationExt, Element, cosmic_theme, executor, surface, theme};
use mime_guess::Mime; use mime_guess::Mime;
use notify_debouncer_full::notify::{self, RecommendedWatcher}; 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};
@ -428,6 +428,7 @@ pub enum NavMenuAction {
Preview(segmented_button::Entity), Preview(segmented_button::Entity),
RunContextAction(segmented_button::Entity, usize), RunContextAction(segmented_button::Entity, usize),
RemoveFromSidebar(segmented_button::Entity), RemoveFromSidebar(segmented_button::Entity),
ChangeSidebarLabel(segmented_button::Entity),
} }
impl MenuAction for NavMenuAction { impl MenuAction for NavMenuAction {
@ -503,6 +504,7 @@ pub enum Message {
OpenWithBrowse, OpenWithBrowse,
OpenWithDialog(Option<Entity>), OpenWithDialog(Option<Entity>),
OpenWithSelection(usize), OpenWithSelection(usize),
OpenWithSearchClear,
OpenWithToggleDefault(bool), OpenWithToggleDefault(bool),
#[cfg(all(feature = "wayland", feature = "desktop-applet"))] #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Overlap(window::Id, OverlapNotifyEvent), Overlap(window::Id, OverlapNotifyEvent),
@ -691,6 +693,7 @@ pub enum DialogPage {
mime: mime_guess::Mime, mime: mime_guess::Mime,
selected: usize, selected: usize,
store_opt: Option<Arc<MimeApp>>, store_opt: Option<Arc<MimeApp>>,
search_app_name: String,
/// When true, the chosen app is written to mimeapps.list as the /// When true, the chosen app is written to mimeapps.list as the
/// default handler for `mime` after it spawns. /// default handler for `mime` after it spawns.
set_default: bool, set_default: bool,
@ -701,6 +704,10 @@ pub enum DialogPage {
DeleteTrash { DeleteTrash {
items: Vec<TrashItem>, items: Vec<TrashItem>,
}, },
ChangeSidebarLabel {
entity: Entity,
label: String,
},
RenameItem { RenameItem {
from: PathBuf, from: PathBuf,
parent: PathBuf, parent: PathBuf,
@ -1956,15 +1963,9 @@ impl App {
for (favorite_i, favorite) in self.config.favorites.iter().enumerate() { for (favorite_i, favorite) in self.config.favorites.iter().enumerate() {
if let Some(path) = favorite.path_opt() { if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) { let name = favorite
fl!("home") .display_name()
} else if let Favorite::Network { name, .. } = favorite { .unwrap_or_else(|| fl!("filesystem"));
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
fl!("filesystem")
};
nav_model = nav_model.insert(move |b| { nav_model = nav_model.insert(move |b| {
b.text(name.clone()) b.text(name.clone())
.icon( .icon(
@ -2601,8 +2602,10 @@ impl App {
.favorites .favorites
.iter() .iter()
.map(|favorite| { .map(|favorite| {
if let Favorite::Path(path) = favorite { match favorite {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref())) { Favorite::Path(path) => {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
{
if path.starts_with(from) if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from) && let Ok(relative) = path.strip_prefix(from)
{ {
@ -2611,6 +2614,22 @@ impl App {
} }
} }
} }
Favorite::Named { path, name } => {
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
{
if path.starts_with(from)
&& let Ok(relative) = path.strip_prefix(from)
{
favorites_changed = true;
return Favorite::Named {
path: to.join(relative),
name: name.clone(),
};
}
}
}
_ => {}
}
favorite.clone() favorite.clone()
}) })
.collect(); .collect();
@ -2925,6 +2944,11 @@ impl Application for App {
} }
items.push(cosmic::widget::menu::Item::Divider); items.push(cosmic::widget::menu::Item::Divider);
if favorite_index_opt.is_some() { if favorite_index_opt.is_some() {
items.push(cosmic::widget::menu::Item::Button(
fl!("change-sidebar-label"),
None,
NavMenuAction::ChangeSidebarLabel(entity),
));
items.push(cosmic::widget::menu::Item::Button( items.push(cosmic::widget::menu::Item::Button(
fl!("remove-from-sidebar"), fl!("remove-from-sidebar"),
None, None,
@ -3216,7 +3240,8 @@ impl Application for App {
} else { } else {
Favorite::from_path(path) Favorite::from_path(path)
}; };
if !favorites.contains(&favorite) { let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite); favorites.push(favorite);
} }
} }
@ -3523,10 +3548,18 @@ impl Application for App {
path, path,
mime, mime,
selected, selected,
search_app_name,
set_default, set_default,
.. ..
} => { } => {
let available_apps = self.mime_app_cache.get_apps_for_mime(&mime, true); let mut available_apps =
self.mime_app_cache.get_apps_for_mime(&mime, true);
available_apps.retain(|(app, _)| {
app.name
.to_lowercase()
.trim()
.contains(search_app_name.to_lowercase().as_str().trim())
});
if let Some((app, _)) = available_apps.get(selected) { if let Some((app, _)) = available_apps.get(selected) {
if let Some(mut command) = if let Some(mut command) =
@ -3572,6 +3605,18 @@ impl Application for App {
DialogPage::DeleteTrash { items } => { DialogPage::DeleteTrash { items } => {
tasks.push(self.operation(Operation::DeleteTrash { items })); tasks.push(self.operation(Operation::DeleteTrash { items }));
} }
DialogPage::ChangeSidebarLabel { entity, label } => {
if let Some(FavoriteIndex(favorite_i)) =
self.nav_model.data::<FavoriteIndex>(entity)
{
let mut favorites = self.config.favorites.clone();
if let Some(favorite) = favorites.get_mut(*favorite_i) {
*favorite = favorite.with_label(label.trim());
config_set!(favorites, favorites);
tasks.push(self.update_config());
}
}
}
DialogPage::RenameItem { DialogPage::RenameItem {
from, parent, name, .. from, parent, name, ..
} => { } => {
@ -4173,7 +4218,8 @@ impl Application for App {
let Some(path) = item.path_opt() else { let Some(path) = item.path_opt() else {
continue; continue;
}; };
return self.push_dialog( return Task::batch([
self.push_dialog(
DialogPage::OpenWith { DialogPage::OpenWith {
path: path.clone(), path: path.clone(),
mime: item.mime.clone(), mime: item.mime.clone(),
@ -4184,10 +4230,13 @@ 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()
}), }),
search_app_name: String::new(),
set_default: false, set_default: false,
}, },
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()), Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
); ),
widget::text_input::focus(self.dialog_text_input.clone()),
]);
} }
} }
} }
@ -4203,6 +4252,14 @@ impl Application for App {
*set_default = enabled; *set_default = enabled;
} }
} }
Message::OpenWithSearchClear => {
if let Some(DialogPage::OpenWith {
search_app_name, ..
}) = self.dialog_pages.front_mut()
{
*search_app_name = String::new();
}
}
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)
@ -4888,7 +4945,8 @@ impl Application for App {
tab::Command::AddToSidebar(path) => { tab::Command::AddToSidebar(path) => {
let mut favorites = self.config.favorites.clone(); let mut favorites = self.config.favorites.clone();
let favorite = Favorite::from_path(path); let favorite = Favorite::from_path(path);
if !favorites.contains(&favorite) { let favorite_path = favorite.path_opt();
if !favorites.iter().any(|f| f.path_opt() == favorite_path) {
favorites.push(favorite); favorites.push(favorite);
} }
config_set!(favorites, favorites); config_set!(favorites, favorites);
@ -5537,6 +5595,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()
}), }),
search_app_name: String::new(),
set_default: false, set_default: false,
}, },
None, None,
@ -5672,6 +5731,26 @@ impl Application for App {
return self.update_config(); return self.update_config();
} }
} }
NavMenuAction::ChangeSidebarLabel(entity) => {
if let Some(favorite) = self
.nav_model
.data::<FavoriteIndex>(entity)
.and_then(|FavoriteIndex(favorite_i)| {
self.config.favorites.get(*favorite_i)
})
{
let label = favorite
.display_name()
.unwrap_or_else(|| fl!("filesystem"));
return Task::batch([
self.dialog_pages
.push_back(DialogPage::ChangeSidebarLabel { entity, label }),
widget::text_input::focus(self.dialog_text_input.clone()),
widget::text_input::select_all(self.dialog_text_input.clone()),
]);
}
}
}, },
Message::Recents => { Message::Recents => {
if self.config.show_recents { if self.config.show_recents {
@ -6394,7 +6473,9 @@ impl Application for App {
mime, mime,
selected, selected,
store_opt, store_opt,
search_app_name,
set_default, 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(),
@ -6402,7 +6483,13 @@ impl Application for App {
}; };
let mut column = widget::list_column(); let mut column = widget::list_column();
let available_apps = self.mime_app_cache.get_apps_for_mime(mime, true); let mut available_apps = self.mime_app_cache.get_apps_for_mime(mime, true);
available_apps.retain(|(app, _)| {
app.name
.to_lowercase()
.trim()
.contains(search_app_name.to_lowercase().as_str().trim())
});
let item_height = 32.0; let item_height = 32.0;
let mut displayed_default = false; let mut displayed_default = false;
let mut last_kind = MimeAppMatch::Exact; let mut last_kind = MimeAppMatch::Exact;
@ -6466,6 +6553,25 @@ impl Application for App {
.secondary_action( .secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
) )
.control(
widget::text_input::search_input(
fl!("search-application"),
search_app_name,
)
.id(self.dialog_text_input.clone())
.on_clear(Message::OpenWithSearchClear)
.on_input(move |search_app_name| {
Message::DialogUpdate(DialogPage::OpenWith {
path: path.clone(),
mime: mime.clone(),
selected: *selected,
store_opt: store_opt.clone(),
search_app_name,
set_default: *set_default,
})
})
.on_submit(|_| Message::DialogComplete),
)
.control(widget::scrollable(column).height({ .control(widget::scrollable(column).height({
let max_size = self let max_size = self
.size .size
@ -6554,6 +6660,40 @@ impl Application for App {
target = target target = target
))) )))
} }
DialogPage::ChangeSidebarLabel { entity, label } => {
let entity = *entity;
let complete_maybe = if label.trim().is_empty() {
None
} else {
Some(Message::DialogComplete)
};
widget::dialog()
.title(fl!("change-sidebar-label"))
.primary_action(
widget::button::suggested(fl!("save"))
.on_press_maybe(complete_maybe.clone()),
)
.secondary_action(
widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel),
)
.control(
widget::column::with_children([
widget::text::body(fl!("sidebar-label")).into(),
widget::text_input("", label.as_str())
.id(self.dialog_text_input.clone())
.on_input(move |label| {
Message::DialogUpdate(DialogPage::ChangeSidebarLabel {
entity,
label,
})
})
.on_submit_maybe(complete_maybe.map(|maybe| move |_| maybe.clone()))
.into(),
])
.spacing(space_xxs),
)
}
DialogPage::RenameItem { DialogPage::RenameItem {
from, from,
parent, parent,

View file

@ -70,6 +70,11 @@ pub enum Favorite {
name: String, name: String,
path: PathBuf, path: PathBuf,
}, },
/// A path with a custom name chosen by the user
Named {
path: PathBuf,
name: String,
},
} }
impl Favorite { impl Favorite {
@ -98,6 +103,38 @@ impl Favorite {
Self::Videos => dirs::video_dir(), Self::Videos => dirs::video_dir(),
Self::Path(path) => Some(path.clone()), Self::Path(path) => Some(path.clone()),
Self::Network { path, .. } => Some(path.clone()), Self::Network { path, .. } => Some(path.clone()),
Self::Named { path, .. } => Some(path.clone()),
}
}
/// Name shown in the sidebar, or `None` if the path has no usable file name
pub fn display_name(&self) -> Option<String> {
match self {
Self::Home => Some(crate::fl!("home")),
Self::Named { name, .. } | Self::Network { name, .. } => Some(name.clone()),
_ => self
.path_opt()?
.file_name()
.and_then(|x| x.to_str())
.map(ToString::to_string),
}
}
/// Return this favorite with a custom sidebar label chosen by the user
pub fn with_label(&self, name: &str) -> Self {
match self {
Self::Network { uri, path, .. } => Self::Network {
uri: uri.clone(),
name: name.to_string(),
path: path.clone(),
},
other => match other.path_opt() {
Some(path) => Self::Named {
path,
name: name.to_string(),
},
None => other.clone(),
},
} }
} }
} }
@ -476,3 +513,66 @@ pub const TIME_CONFIG_ID: &str = "com.system76.CosmicAppletTime";
pub struct TimeConfig { pub struct TimeConfig {
pub military_time: bool, pub military_time: bool,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn favorite_with_label_converts_path_to_named() {
let favorite = Favorite::Path(PathBuf::from("/some/dir"));
assert_eq!(
favorite.with_label("Custom"),
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_with_label_updates_network_in_place() {
let favorite = Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "example.com".to_string(),
path: PathBuf::from("/run/mount/example"),
};
assert_eq!(
favorite.with_label("Custom"),
Favorite::Network {
uri: "sftp://example.com/".to_string(),
name: "Custom".to_string(),
path: PathBuf::from("/run/mount/example"),
}
);
}
#[test]
fn favorite_with_label_converts_special_folder_to_named() {
let home = dirs::home_dir().unwrap();
assert_eq!(
Favorite::Home.with_label("Custom"),
Favorite::Named {
path: home,
name: "Custom".to_string(),
}
);
}
#[test]
fn favorite_display_name() {
assert_eq!(
Favorite::Path(PathBuf::from("/some/dir")).display_name(),
Some("dir".to_string())
);
assert_eq!(Favorite::Path(PathBuf::from("/")).display_name(), None);
assert_eq!(
Favorite::Named {
path: PathBuf::from("/some/dir"),
name: "Custom".to_string(),
}
.display_name(),
Some("Custom".to_string())
);
}
}

View file

@ -32,9 +32,7 @@ use std::{env, fmt, fs};
use crate::app::{ use crate::app::{
Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID, Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID,
}; };
use crate::config::{ use crate::config::{Config, DialogConfig, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch};
Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch,
};
use crate::key_bind::key_binds; use crate::key_bind::key_binds;
use crate::localize::LANGUAGE_SORTER; use crate::localize::LANGUAGE_SORTER;
use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage}; use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage};
@ -895,13 +893,7 @@ impl App {
for favorite in &self.flags.config.favorites { for favorite in &self.flags.config.favorites {
if let Some(path) = favorite.path_opt() { if let Some(path) = favorite.path_opt() {
let name = if matches!(favorite, Favorite::Home) { let Some(name) = favorite.display_name() else {
fl!("home")
} else if let Favorite::Network { name, .. } = favorite {
name.clone()
} else if let Some(file_name) = path.file_name().and_then(|x| x.to_str()) {
file_name.to_string()
} else {
continue; continue;
}; };
nav_model = nav_model.insert(move |b| { nav_model = nav_model.insert(move |b| {

View file

@ -18,6 +18,15 @@ use crate::tab::{self, ChecksumState, DirSize, ItemMetadata, ItemThumbnail, Loca
const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri"; const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri";
// Attributes requested when listing a network directory.
const SCAN_ATTRIBUTES: &str = "standard::name,\
standard::display-name,\
standard::type,\
standard::size,\
standard::icon,\
standard::is-hidden,\
time::modified";
fn resolve_uri(uri: &str) -> (String, gio::File) { fn resolve_uri(uri: &str) -> (String, gio::File) {
let file = gio::File::for_uri(uri); let file = gio::File::for_uri(uri);
// Resolve the target-uri if it exists // Resolve the target-uri if it exists
@ -119,9 +128,27 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
Box::from([]) Box::from([])
}; };
// `filesystem::remote` belongs to the filesystem namespace, which `enumerate_children`
// never fills in, so it has to be queried once for the directory being listed.
let remote = file
.query_filesystem_info(
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
)
.map(|info| info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE))
.unwrap_or_else(|err| {
log::warn!("failed to get GIO filesystem info for {uri}: {err}");
// Assume remote, so per-entry work is skipped rather than retried
true
});
let mut items = Vec::new(); let mut items = Vec::new();
for info_res in file for info_res in file
.enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE) .enumerate_children(
SCAN_ATTRIBUTES,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
)
.map_err(err_str)? .map_err(err_str)?
{ {
let info = info_res.map_err(err_str)?; let info = info_res.map_err(err_str)?;
@ -133,13 +160,17 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
//TODO: what is the best way to resolve shortcuts? //TODO: what is the best way to resolve shortcuts?
let location = Location::Network(uri, display_name.clone(), file.child(&name).path()); let location = Location::Network(uri, display_name.clone(), file.child(&name).path());
let metadata = if !force_dir && !info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE) { let metadata = if force_dir {
ItemMetadata::SimpleDir { entries: 0 }
} else {
let mtime = info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED); let mtime = info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
let is_dir = matches!(info.file_type(), gio::FileType::Directory); let is_dir = matches!(info.file_type(), gio::FileType::Directory);
let size_opt = (!is_dir).then_some(info.size() as u64); let size_opt = (!is_dir).then_some(info.size() as u64);
let mut children_opt = None; let mut children_opt = None;
if is_dir { // Counting children costs a directory listing per entry, which is far too
// expensive on a remote filesystem
if is_dir && !remote {
if let Some(path) = file.child(&name).path() { if let Some(path) = file.child(&name).path() {
//TODO: calculate children in the background (and make it cancellable?) //TODO: calculate children in the background (and make it cancellable?)
match std::fs::read_dir(&path) { match std::fs::read_dir(&path) {
@ -159,9 +190,8 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
mtime, mtime,
size_opt, size_opt,
children_opt, children_opt,
is_dir,
} }
} else {
ItemMetadata::SimpleDir { entries: 0 }
}; };
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = { let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {

View file

@ -458,7 +458,9 @@ impl<'a> FormatTime<'a> {
impl Display for FormatTime<'_> { impl Display for FormatTime<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let zoned = jiff::Zoned::try_from(self.time).unwrap(); let Ok(zoned) = jiff::Zoned::try_from(self.time) else {
return Ok(());
};
let now = jiff::Zoned::now(); let now = jiff::Zoned::now();
let icu_datetime = DateTime::convert_from(zoned.datetime()); let icu_datetime = DateTime::convert_from(zoned.datetime());
if zoned.date() == now.date() { if zoned.date() == now.date() {
@ -661,6 +663,41 @@ fn display_name_for_file(path: &Path, name: &str, get_from_gvfs: bool, is_deskto
Item::display_name(name) Item::display_name(name)
} }
// Whether a dir lives on a remote filesystem, according to GIO.
#[cfg(feature = "gvfs")]
fn gvfs_dir_is_remote(dir: &Path) -> bool {
static REMOTE_CACHE: LazyLock<RwLock<FxHashMap<PathBuf, bool>>> =
LazyLock::new(|| RwLock::new(FxHashMap::default()));
if let Some(remote) = REMOTE_CACHE.read().unwrap().get(dir) {
return *remote;
}
let remote = match gio::prelude::FileExt::query_filesystem_info(
&gio::File::for_path(dir),
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
) {
Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
Err(err) => {
log::warn!(
"failed to get GIO filesystem info for {}: {}",
dir.display(),
err
);
//Assume remote so that the "expensive tasks" are rather skipped then actually executed.
true
}
};
REMOTE_CACHE
.write()
.unwrap()
.insert(dir.to_path_buf(), remote);
remote
}
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item { pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
let file_name = file_info let file_name = file_info
@ -668,7 +705,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
.unwrap_or_default(); .unwrap_or_default();
let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED); let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
let mut is_desktop = false; let mut is_desktop = false;
let remote = file_info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE); let remote = path.parent().is_none_or(gvfs_dir_is_remote);
let is_dir = matches!(file_info.file_type(), gio::FileType::Directory); let is_dir = matches!(file_info.file_type(), gio::FileType::Directory);
let size_opt = (!is_dir).then_some(file_info.size() as u64); let size_opt = (!is_dir).then_some(file_info.size() as u64);
@ -729,6 +766,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
mtime, mtime,
size_opt, size_opt,
children_opt, children_opt,
is_dir,
}, },
hidden, hidden,
image_dimensions: (!remote && mime.type_() == mime::IMAGE) image_dimensions: (!remote && mime.type_() == mime::IMAGE)
@ -783,23 +821,7 @@ pub fn item_from_entry(
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
FsKind::Gvfs => { FsKind::Gvfs => {
is_gvfs = true; is_gvfs = true;
let file = gio::File::for_path(&path); path.parent().is_none_or(gvfs_dir_is_remote)
match gio::prelude::FileExt::query_filesystem_info(
&file,
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
) {
Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
Err(err) => {
log::warn!(
"failed to get GIO filesystem info for {}: {}",
path.display(),
err
);
true
}
}
} }
#[cfg(not(feature = "gvfs"))] #[cfg(not(feature = "gvfs"))]
FsKind::Gvfs => { FsKind::Gvfs => {
@ -1024,7 +1046,6 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
// gio crate expects a comma delimited string // gio crate expects a comma delimited string
let attr_string = [ let attr_string = [
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(), gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(),
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE.as_str(),
gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(), gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(), gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(), gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
@ -1833,6 +1854,8 @@ pub enum Message {
HighlightDeactivate(usize), HighlightDeactivate(usize),
HighlightActivate(usize), HighlightActivate(usize),
DirectorySize(PathBuf, DirSize), DirectorySize(PathBuf, DirSize),
#[cfg(feature = "gvfs")]
DirectoryChildren(PathBuf, usize),
Checksums(PathBuf, ChecksumState), Checksums(PathBuf, ChecksumState),
CalculateChecksums(PathBuf), CalculateChecksums(PathBuf),
CopyChecksum(String), CopyChecksum(String),
@ -1901,6 +1924,7 @@ pub enum ItemMetadata {
mtime: u64, mtime: u64,
size_opt: Option<u64>, size_opt: Option<u64>,
children_opt: Option<usize>, children_opt: Option<usize>,
is_dir: bool,
}, },
} }
@ -1915,7 +1939,7 @@ impl ItemMetadata {
Self::SimpleDir { .. } => true, Self::SimpleDir { .. } => true,
Self::SimpleFile { .. } => false, Self::SimpleFile { .. } => false,
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
Self::GvfsPath { children_opt, .. } => children_opt.is_some(), Self::GvfsPath { is_dir, .. } => *is_dir,
} }
} }
@ -1924,7 +1948,7 @@ impl ItemMetadata {
Self::Path { metadata, .. } => metadata.modified().ok(), Self::Path { metadata, .. } => metadata.modified().ok(),
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
Self::GvfsPath { mtime, .. } => { Self::GvfsPath { mtime, .. } => {
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime)) SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(*mtime))
} }
_ => None, _ => None,
} }
@ -2346,6 +2370,7 @@ impl Item {
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
widget::text::body(name) widget::text::body(name)
.wrapping(text::Wrapping::WordOrGlyph) .wrapping(text::Wrapping::WordOrGlyph)
.align_x(text::Alignment::Center)
.ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines( .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3, 3,
))) )))
@ -4842,6 +4867,20 @@ impl Tab {
} }
} }
} }
#[cfg(feature = "gvfs")]
Message::DirectoryChildren(path, children) => {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.path_opt() == Some(&path) {
if let ItemMetadata::GvfsPath { children_opt, .. } = &mut item.metadata
{
*children_opt = Some(children);
}
break;
}
}
}
}
Message::Checksums(path, checksum_state) => { Message::Checksums(path, checksum_state) => {
let location = Location::Path(path); let location = Location::Path(path);
if let Some(ref mut item) = self.parent_item_opt if let Some(ref mut item) = self.parent_item_opt
@ -5003,11 +5042,15 @@ impl Tab {
ItemMetadata::GvfsPath { ItemMetadata::GvfsPath {
size_opt, size_opt,
children_opt, children_opt,
is_dir,
.. ..
} => match children_opt { } => {
Some(child_count) => (true, *child_count as u64), if *is_dir {
None => (false, size_opt.unwrap_or_default()), (true, children_opt.unwrap_or_default() as u64)
}, } else {
(false, size_opt.unwrap_or_default())
}
}
}; };
let (a_is_entry, a_size) = get_size(a.1); let (a_is_entry, a_size) = get_size(a.1);
let (b_is_entry, b_size) = get_size(b.1); let (b_is_entry, b_size) = get_size(b.1);
@ -6185,17 +6228,21 @@ impl Tab {
ItemMetadata::GvfsPath { ItemMetadata::GvfsPath {
size_opt, size_opt,
children_opt, children_opt,
is_dir,
.. ..
} => match children_opt { } => {
Some(child_count) => { if *is_dir {
if *child_count == 1 { // Children are not counted on remote filesystems
format!("{child_count} item") match children_opt {
//TODO: translate
Some(1) => "1 item".to_string(),
Some(child_count) => format!("{child_count} items"),
None => String::new(),
}
} else { } else {
format!("{child_count} items") format_size(size_opt.unwrap_or_default())
} }
} }
None => format_size(size_opt.unwrap_or_default()),
},
}; };
let row = if condensed { let row = if condensed {
@ -6961,6 +7008,74 @@ impl Tab {
Rectangle::new(point, size) Rectangle::new(point, size)
}; };
// Count the children of visible directories in the background. Doing it while
// scanning costs one directory listing per entry, which stalls remote filesystems.
#[cfg(feature = "gvfs")]
for item in items {
let ItemMetadata::GvfsPath {
children_opt: None,
is_dir: true,
..
} = &item.metadata
else {
continue;
};
// Skip items that are not visible, or have no determined rect
match item.rect_opt.get() {
Some(rect) if rect.intersects(&visible_rect) => {}
_ => continue,
}
let Some(path) = item.path_opt().cloned() else {
continue;
};
struct ChildrenWrapper(PathBuf);
impl Hash for ChildrenWrapper {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
subscriptions.push(Subscription::run_with(
ChildrenWrapper(path),
|ChildrenWrapper(path)| {
let path = path.clone();
stream::channel(
1,
move |mut output: futures::channel::mpsc::Sender<_>| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let children = match fs::read_dir(&path) {
Ok(entries) => entries.count(),
Err(err) => {
log::warn!(
"failed to read directory {}: {}",
path.display(),
err
);
0
}
};
Message::DirectoryChildren(path, children)
})
.await
.unwrap()
};
if let Err(err) = output.send(message).await {
log::warn!("failed to send directory children: {err}");
}
std::future::pending().await
},
)
},
));
}
for item in items { for item in items {
if item.thumbnail_opt.is_some() { if item.thumbnail_opt.is_some() {
// Skip items that already have a mime type and thumbnail // Skip items that already have a mime type and thumbnail