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:
commit
deb6000c10
13 changed files with 773 additions and 218 deletions
411
Cargo.lock
generated
411
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "cosmic-files"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
authors = ["Jeremy Soller <jeremy@system76.com>"]
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "cosmic-files-applet"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
6
debian/changelog
vendored
6
debian/changelog
vendored
|
|
@ -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
|
||||
|
||||
* Epoch 1.6.0 version update
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ no-results = Fandt ingen resultater
|
|||
filesystem = Filsystem
|
||||
home = Hjem
|
||||
networks = Netværk
|
||||
notification-in-progress = Fil-operationer er igang.
|
||||
notification-in-progress = Fil-operationer er igang
|
||||
trash = Papirkurv
|
||||
recents = Seneste
|
||||
undo = Fortryd
|
||||
|
|
@ -69,7 +69,7 @@ name-no-slashes = Navn må ikke indeholde skråstreger
|
|||
|
||||
## Open/Save Dialog
|
||||
|
||||
cancel = Annuller
|
||||
cancel = Afbryd
|
||||
create = Opret
|
||||
open = Åbn
|
||||
open-file = Åbn fil
|
||||
|
|
@ -95,7 +95,7 @@ rename-folder = Omdøb mappe
|
|||
## Replace Dialog
|
||||
|
||||
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-operation = Ønsker du at erstatte den? Erstatning af den vil overskrive dens indhold.
|
||||
original-file = Original fil
|
||||
|
|
@ -253,7 +253,7 @@ light = Lyst
|
|||
# Context menu
|
||||
add-to-sidebar = Tilføj til sidebjælke
|
||||
compress = Komprimer…
|
||||
extract-here = Extract
|
||||
extract-here = Uddrag
|
||||
new-file = Ny fil...
|
||||
new-folder = Ny mappe...
|
||||
open-in-terminal = Åbn i terminal
|
||||
|
|
@ -332,7 +332,7 @@ deleted =
|
|||
Slettede { $items } { $items ->
|
||||
[one] element
|
||||
*[other] elementer
|
||||
} fra papirkurven
|
||||
} fra { trash }
|
||||
removing-from-recents =
|
||||
Fjerner { $items } { $items ->
|
||||
[one] element
|
||||
|
|
@ -360,7 +360,7 @@ remove = Fjern
|
|||
context-action = Konteksthandling
|
||||
context-action-confirm-title = Kør “{ $name }”?
|
||||
context-action-confirm-warning =
|
||||
This will run on { $items } { $items ->
|
||||
Dette vil køre på { $items } { $items ->
|
||||
[one] item
|
||||
*[other] items
|
||||
}.
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ rename-file = Rename file
|
|||
rename-folder = Rename folder
|
||||
rename-confirm = Rename
|
||||
|
||||
## Change Sidebar Label Dialog
|
||||
sidebar-label = Sidebar label
|
||||
|
||||
## Replace Dialog
|
||||
replace = Replace
|
||||
replace-title = "{$filename}" already exists in this location
|
||||
|
|
@ -299,6 +302,7 @@ unknown-folder = unknown folder
|
|||
## Open with
|
||||
menu-open-with = Open with...
|
||||
default-app = {$name} (default)
|
||||
search-application = Search by app name
|
||||
|
||||
## Show details
|
||||
show-details = Show details
|
||||
|
|
@ -347,6 +351,7 @@ move-to = Move to...
|
|||
move-to-trash = Move to trash
|
||||
restore-from-trash = Restore from trash
|
||||
remove-from-sidebar = Remove from sidebar
|
||||
change-sidebar-label = Change sidebar label
|
||||
sort-by-name = Sort by name
|
||||
sort-by-modified = Sort by modified
|
||||
sort-by-size = Sort by size
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ permanently-delete-warning = Deseja realmente excluir permanentemente { $target
|
|||
rename-file = Renomear arquivo
|
||||
rename-folder = Renomear pasta
|
||||
|
||||
## Change Sidebar Label Dialog
|
||||
|
||||
sidebar-label = Rótulo da barra lateral
|
||||
|
||||
## Replace Dialog
|
||||
|
||||
replace = Substituir
|
||||
|
|
@ -350,7 +354,7 @@ light = Estilo claro
|
|||
|
||||
### 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-enter-path = Insere o caminho do diretório ou arquivo
|
||||
# Context menu
|
||||
|
|
@ -365,6 +369,7 @@ open-in-terminal = Abrir no terminal
|
|||
move-to-trash = Mover para a lixeira
|
||||
restore-from-trash = Restaurar da lixeira
|
||||
remove-from-sidebar = Remover da barra lateral
|
||||
change-sidebar-label = Alterar rótulo na barra lateral
|
||||
sort-by-name = Ordenar por nome
|
||||
sort-by-modified = Ordenar por data de modificação
|
||||
sort-by-size = Ordenar por tamanho
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ unknown-folder = неизвестная папка
|
|||
|
||||
menu-open-with = Открыть с помощью…
|
||||
default-app = { $name } (по умолчанию)
|
||||
search-application = Поиск по названию приложения
|
||||
|
||||
## Show details
|
||||
|
||||
|
|
|
|||
210
src/app.rs
210
src/app.rs
|
|
@ -37,7 +37,7 @@ use cosmic::widget::menu::action::MenuAction;
|
|||
use cosmic::widget::menu::key_bind::KeyBind;
|
||||
use cosmic::widget::segmented_button::{self, Entity, ReorderEvent};
|
||||
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 notify_debouncer_full::notify::{self, RecommendedWatcher};
|
||||
use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
|
||||
|
|
@ -428,6 +428,7 @@ pub enum NavMenuAction {
|
|||
Preview(segmented_button::Entity),
|
||||
RunContextAction(segmented_button::Entity, usize),
|
||||
RemoveFromSidebar(segmented_button::Entity),
|
||||
ChangeSidebarLabel(segmented_button::Entity),
|
||||
}
|
||||
|
||||
impl MenuAction for NavMenuAction {
|
||||
|
|
@ -503,6 +504,7 @@ pub enum Message {
|
|||
OpenWithBrowse,
|
||||
OpenWithDialog(Option<Entity>),
|
||||
OpenWithSelection(usize),
|
||||
OpenWithSearchClear,
|
||||
OpenWithToggleDefault(bool),
|
||||
#[cfg(all(feature = "wayland", feature = "desktop-applet"))]
|
||||
Overlap(window::Id, OverlapNotifyEvent),
|
||||
|
|
@ -691,6 +693,7 @@ pub enum DialogPage {
|
|||
mime: mime_guess::Mime,
|
||||
selected: usize,
|
||||
store_opt: Option<Arc<MimeApp>>,
|
||||
search_app_name: String,
|
||||
/// When true, the chosen app is written to mimeapps.list as the
|
||||
/// default handler for `mime` after it spawns.
|
||||
set_default: bool,
|
||||
|
|
@ -701,6 +704,10 @@ pub enum DialogPage {
|
|||
DeleteTrash {
|
||||
items: Vec<TrashItem>,
|
||||
},
|
||||
ChangeSidebarLabel {
|
||||
entity: Entity,
|
||||
label: String,
|
||||
},
|
||||
RenameItem {
|
||||
from: PathBuf,
|
||||
parent: PathBuf,
|
||||
|
|
@ -1956,15 +1963,9 @@ impl App {
|
|||
|
||||
for (favorite_i, favorite) in self.config.favorites.iter().enumerate() {
|
||||
if let Some(path) = favorite.path_opt() {
|
||||
let name = if matches!(favorite, Favorite::Home) {
|
||||
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 {
|
||||
fl!("filesystem")
|
||||
};
|
||||
let name = favorite
|
||||
.display_name()
|
||||
.unwrap_or_else(|| fl!("filesystem"));
|
||||
nav_model = nav_model.insert(move |b| {
|
||||
b.text(name.clone())
|
||||
.icon(
|
||||
|
|
@ -2601,15 +2602,33 @@ impl App {
|
|||
.favorites
|
||||
.iter()
|
||||
.map(|favorite| {
|
||||
if let Favorite::Path(path) = favorite {
|
||||
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)
|
||||
match favorite {
|
||||
Favorite::Path(path) => {
|
||||
for (from, to) in path_changes.iter().map(|(f, t)| (f.as_ref(), t.as_ref()))
|
||||
{
|
||||
favorites_changed = true;
|
||||
return Favorite::from_path(to.join(relative));
|
||||
if path.starts_with(from)
|
||||
&& let Ok(relative) = path.strip_prefix(from)
|
||||
{
|
||||
favorites_changed = true;
|
||||
return Favorite::from_path(to.join(relative));
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
|
@ -2925,6 +2944,11 @@ impl Application for App {
|
|||
}
|
||||
items.push(cosmic::widget::menu::Item::Divider);
|
||||
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(
|
||||
fl!("remove-from-sidebar"),
|
||||
None,
|
||||
|
|
@ -3216,7 +3240,8 @@ impl Application for App {
|
|||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3523,10 +3548,18 @@ impl Application for App {
|
|||
path,
|
||||
mime,
|
||||
selected,
|
||||
search_app_name,
|
||||
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(mut command) =
|
||||
|
|
@ -3572,6 +3605,18 @@ impl Application for App {
|
|||
DialogPage::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 {
|
||||
from, parent, name, ..
|
||||
} => {
|
||||
|
|
@ -4173,21 +4218,25 @@ impl Application for App {
|
|||
let Some(path) = item.path_opt() else {
|
||||
continue;
|
||||
};
|
||||
return self.push_dialog(
|
||||
DialogPage::OpenWith {
|
||||
path: path.clone(),
|
||||
mime: item.mime.clone(),
|
||||
selected: 0,
|
||||
store_opt: "x-scheme-handler/mime"
|
||||
.parse::<mime_guess::Mime>()
|
||||
.ok()
|
||||
.and_then(|mime| {
|
||||
self.mime_app_cache.get(&mime).first().cloned()
|
||||
}),
|
||||
set_default: false,
|
||||
},
|
||||
Some(CONFIRM_OPEN_WITH_BUTTON_ID.clone()),
|
||||
);
|
||||
return Task::batch([
|
||||
self.push_dialog(
|
||||
DialogPage::OpenWith {
|
||||
path: path.clone(),
|
||||
mime: item.mime.clone(),
|
||||
selected: 0,
|
||||
store_opt: "x-scheme-handler/mime"
|
||||
.parse::<mime_guess::Mime>()
|
||||
.ok()
|
||||
.and_then(|mime| {
|
||||
self.mime_app_cache.get(&mime).first().cloned()
|
||||
}),
|
||||
search_app_name: String::new(),
|
||||
set_default: false,
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
Message::OpenWithSearchClear => {
|
||||
if let Some(DialogPage::OpenWith {
|
||||
search_app_name, ..
|
||||
}) = self.dialog_pages.front_mut()
|
||||
{
|
||||
*search_app_name = String::new();
|
||||
}
|
||||
}
|
||||
Message::Paste(entity_opt) => {
|
||||
let entity = entity_opt.unwrap_or_else(|| self.tab_model.active());
|
||||
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity)
|
||||
|
|
@ -4888,7 +4945,8 @@ impl Application for App {
|
|||
tab::Command::AddToSidebar(path) => {
|
||||
let mut favorites = self.config.favorites.clone();
|
||||
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);
|
||||
}
|
||||
config_set!(favorites, favorites);
|
||||
|
|
@ -5537,6 +5595,7 @@ impl Application for App {
|
|||
.and_then(|mime| {
|
||||
self.mime_app_cache.get(&mime).first().cloned()
|
||||
}),
|
||||
search_app_name: String::new(),
|
||||
set_default: false,
|
||||
},
|
||||
None,
|
||||
|
|
@ -5672,6 +5731,26 @@ impl Application for App {
|
|||
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 => {
|
||||
if self.config.show_recents {
|
||||
|
|
@ -6394,7 +6473,9 @@ impl Application for App {
|
|||
mime,
|
||||
selected,
|
||||
store_opt,
|
||||
search_app_name,
|
||||
set_default,
|
||||
..
|
||||
} => {
|
||||
let name = match path.file_name() {
|
||||
Some(file_name) => file_name.to_str(),
|
||||
|
|
@ -6402,7 +6483,13 @@ impl Application for App {
|
|||
};
|
||||
|
||||
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 mut displayed_default = false;
|
||||
let mut last_kind = MimeAppMatch::Exact;
|
||||
|
|
@ -6466,6 +6553,25 @@ impl Application for App {
|
|||
.secondary_action(
|
||||
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({
|
||||
let max_size = self
|
||||
.size
|
||||
|
|
@ -6554,6 +6660,40 @@ impl Application for App {
|
|||
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 {
|
||||
from,
|
||||
parent,
|
||||
|
|
|
|||
100
src/config.rs
100
src/config.rs
|
|
@ -70,6 +70,11 @@ pub enum Favorite {
|
|||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
/// A path with a custom name chosen by the user
|
||||
Named {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Favorite {
|
||||
|
|
@ -98,6 +103,38 @@ impl Favorite {
|
|||
Self::Videos => dirs::video_dir(),
|
||||
Self::Path(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 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ use std::{env, fmt, fs};
|
|||
use crate::app::{
|
||||
Action, ContextPage, Message as AppMessage, PreviewItem, PreviewKind, REPLACE_BUTTON_ID,
|
||||
};
|
||||
use crate::config::{
|
||||
Config, DialogConfig, Favorite, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch,
|
||||
};
|
||||
use crate::config::{Config, DialogConfig, TIME_CONFIG_ID, ThumbCfg, TimeConfig, TypeToSearch};
|
||||
use crate::key_bind::key_binds;
|
||||
use crate::localize::LANGUAGE_SORTER;
|
||||
use crate::mounter::{MOUNTERS, MounterItem, MounterItems, MounterKey, MounterMessage};
|
||||
|
|
@ -895,13 +893,7 @@ impl App {
|
|||
|
||||
for favorite in &self.flags.config.favorites {
|
||||
if let Some(path) = favorite.path_opt() {
|
||||
let name = if matches!(favorite, Favorite::Home) {
|
||||
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 {
|
||||
let Some(name) = favorite.display_name() else {
|
||||
continue;
|
||||
};
|
||||
nav_model = nav_model.insert(move |b| {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ use crate::tab::{self, ChecksumState, DirSize, ItemMetadata, ItemThumbnail, Loca
|
|||
|
||||
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) {
|
||||
let file = gio::File::for_uri(uri);
|
||||
// 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([])
|
||||
};
|
||||
|
||||
// `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();
|
||||
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)?
|
||||
{
|
||||
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?
|
||||
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 is_dir = matches!(info.file_type(), gio::FileType::Directory);
|
||||
let size_opt = (!is_dir).then_some(info.size() as u64);
|
||||
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() {
|
||||
//TODO: calculate children in the background (and make it cancellable?)
|
||||
match std::fs::read_dir(&path) {
|
||||
|
|
@ -159,9 +190,8 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
|
|||
mtime,
|
||||
size_opt,
|
||||
children_opt,
|
||||
is_dir,
|
||||
}
|
||||
} else {
|
||||
ItemMetadata::SimpleDir { entries: 0 }
|
||||
};
|
||||
|
||||
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
|
||||
|
|
|
|||
183
src/tab.rs
183
src/tab.rs
|
|
@ -458,7 +458,9 @@ impl<'a> FormatTime<'a> {
|
|||
|
||||
impl Display for FormatTime<'_> {
|
||||
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 icu_datetime = DateTime::convert_from(zoned.datetime());
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")]
|
||||
pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
|
||||
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();
|
||||
let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
|
||||
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 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,
|
||||
size_opt,
|
||||
children_opt,
|
||||
is_dir,
|
||||
},
|
||||
hidden,
|
||||
image_dimensions: (!remote && mime.type_() == mime::IMAGE)
|
||||
|
|
@ -783,23 +821,7 @@ pub fn item_from_entry(
|
|||
#[cfg(feature = "gvfs")]
|
||||
FsKind::Gvfs => {
|
||||
is_gvfs = true;
|
||||
let file = gio::File::for_path(&path);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
path.parent().is_none_or(gvfs_dir_is_remote)
|
||||
}
|
||||
#[cfg(not(feature = "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
|
||||
let attr_string = [
|
||||
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_STANDARD_SIZE.as_str(),
|
||||
gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
|
||||
|
|
@ -1833,6 +1854,8 @@ pub enum Message {
|
|||
HighlightDeactivate(usize),
|
||||
HighlightActivate(usize),
|
||||
DirectorySize(PathBuf, DirSize),
|
||||
#[cfg(feature = "gvfs")]
|
||||
DirectoryChildren(PathBuf, usize),
|
||||
Checksums(PathBuf, ChecksumState),
|
||||
CalculateChecksums(PathBuf),
|
||||
CopyChecksum(String),
|
||||
|
|
@ -1901,6 +1924,7 @@ pub enum ItemMetadata {
|
|||
mtime: u64,
|
||||
size_opt: Option<u64>,
|
||||
children_opt: Option<usize>,
|
||||
is_dir: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1915,7 +1939,7 @@ impl ItemMetadata {
|
|||
Self::SimpleDir { .. } => true,
|
||||
Self::SimpleFile { .. } => false,
|
||||
#[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(),
|
||||
#[cfg(feature = "gvfs")]
|
||||
Self::GvfsPath { mtime, .. } => {
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
|
||||
SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(*mtime))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -2346,6 +2370,7 @@ impl Item {
|
|||
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||
widget::text::body(name)
|
||||
.wrapping(text::Wrapping::WordOrGlyph)
|
||||
.align_x(text::Alignment::Center)
|
||||
.ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
|
||||
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) => {
|
||||
let location = Location::Path(path);
|
||||
if let Some(ref mut item) = self.parent_item_opt
|
||||
|
|
@ -5003,11 +5042,15 @@ impl Tab {
|
|||
ItemMetadata::GvfsPath {
|
||||
size_opt,
|
||||
children_opt,
|
||||
is_dir,
|
||||
..
|
||||
} => match children_opt {
|
||||
Some(child_count) => (true, *child_count as u64),
|
||||
None => (false, size_opt.unwrap_or_default()),
|
||||
},
|
||||
} => {
|
||||
if *is_dir {
|
||||
(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 (b_is_entry, b_size) = get_size(b.1);
|
||||
|
|
@ -6185,17 +6228,21 @@ impl Tab {
|
|||
ItemMetadata::GvfsPath {
|
||||
size_opt,
|
||||
children_opt,
|
||||
is_dir,
|
||||
..
|
||||
} => match children_opt {
|
||||
Some(child_count) => {
|
||||
if *child_count == 1 {
|
||||
format!("{child_count} item")
|
||||
} else {
|
||||
format!("{child_count} items")
|
||||
} => {
|
||||
if *is_dir {
|
||||
// Children are not counted on remote filesystems
|
||||
match children_opt {
|
||||
//TODO: translate
|
||||
Some(1) => "1 item".to_string(),
|
||||
Some(child_count) => format!("{child_count} items"),
|
||||
None => String::new(),
|
||||
}
|
||||
} else {
|
||||
format_size(size_opt.unwrap_or_default())
|
||||
}
|
||||
None => format_size(size_opt.unwrap_or_default()),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let row = if condensed {
|
||||
|
|
@ -6961,6 +7008,74 @@ impl Tab {
|
|||
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 {
|
||||
if item.thumbnail_opt.is_some() {
|
||||
// Skip items that already have a mime type and thumbnail
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue