Merge branch 'pop-os:master' into master

This commit is contained in:
David Carvalho 2024-09-17 13:48:43 -03:00 • committed by GitHub
commit e10e73be69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 785 additions and 253 deletions

31
examples/gio-list.rs Normal file
View file

@ -0,0 +1,31 @@
use gio::prelude::*;
use std::env;
fn main() {
let uri = env::args().nth(1).expect("no uri provided");
let file = gio::File::for_uri(&uri);
for entry_res in file
.enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE)
.unwrap()
{
let entry = entry_res.unwrap();
println!("{:?}", entry.display_name());
for attribute in entry.list_attributes(None) {
println!(
" {:?}: {:?}",
attribute,
entry.attribute_as_string(&attribute)
);
}
//TODO: what is the best way to resolve shortcuts?
let child = if let Some(target_uri) =
entry.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_TARGET_URI)
{
gio::File::for_uri(&target_uri)
} else {
file.child(entry.name())
};
println!("{:?}", child.uri());
}
}

View file

@ -9,6 +9,7 @@ notification-in-progress = File operations are in progress.
trash = Trash trash = Trash
recents = Recents recents = Recents
undo = Undo undo = Undo
today = Today
# List view # List view
name = Name name = Name
@ -235,4 +236,4 @@ sort-z-a = Z-A
sort-newest-first = Newest first sort-newest-first = Newest first
sort-oldest-first = Oldest first sort-oldest-first = Oldest first
sort-smallest-to-largest = Smallest to largest sort-smallest-to-largest = Smallest to largest
sort-largest-to-smallest = Largest to smallest sort-largest-to-smallest = Largest to smallest

View file

@ -4,9 +4,9 @@ empty-folder-hidden = Dossier vide (contient des éléments cachés)
no-results = Aucun résultat trouvé no-results = Aucun résultat trouvé
filesystem = Système de fichiers filesystem = Système de fichiers
home = Dossier personnel home = Dossier personnel
notification-in-progress = Les opérations sur les fichiers sont en cours. notification-in-progress = Des opérations sur des fichiers sont en cours.
trash = Corbeille trash = Corbeille
recents = Récente recents = Récents
undo = Annuler undo = Annuler
# List view # List view
@ -77,6 +77,31 @@ execute = Exécution
## About ## About
git-description = Git commit {$hash} le {$date} git-description = Git commit {$hash} le {$date}
## Add Network Drive
add-network-drive = Ajouter un lecteur réseau
connect = Connecter
connect-anonymously = Connecter anonymement
connecting = Connection en cours...
domain = Domaine
enter-server-address = Entrez l'adresse du serveur
network-drive-description =
Les adresses de serveur incluent un préfixe de protocole et une adresse.
Exemples: ssh://192.168.0.1, ftp://[2001:db8::1]
### Make sure to keep the comma which separates the columns
network-drive-schemes =
Protocoles disponibles,Préfixe
AppleTalk,afp://
File Transfer Protocol,ftp:// or ftps://
Network File System,nfs://
Server Message Block,smb://
SSH File Transfer Protocol,sftp:// or ssh://
WebDav,dav:// or davs://
network-drive-error = Impossible d'accéder au lecteur réseau
password = Mot de passe
remember-password = Se souvenir du mot de passe
try-again = Essayer à nouveau
username = Nom d'utilisateur
## Operations ## Operations
edit-history = Modifier l'historique edit-history = Modifier l'historique
history = Historique history = Historique
@ -203,3 +228,12 @@ show-hidden-files = Afficher les fichiers cachés
list-directories-first = Lister les répertoires en premier list-directories-first = Lister les répertoires en premier
menu-settings = Paramètres... menu-settings = Paramètres...
menu-about = À propos de Fichiers COSMIC... menu-about = À propos de Fichiers COSMIC...
## Sort
sort = Trier
sort-a-z = A-Z
sort-z-a = Z-A
sort-newest-first = Le plus récent en premier
sort-oldest-first = Le plus ancien en premier
sort-smallest-to-largest = Du plus petit au plus grand
sort-largest-to-smallest = Du plus grand au plus petit

View file

@ -4,8 +4,10 @@ empty-folder-hidden = Pusty katalog (z ukrytymi plikami)
no-results = Brak wyników no-results = Brak wyników
filesystem = System plików filesystem = System plików
home = Katalog Domowy home = Katalog Domowy
networks = sieci
notification-in-progress = Operacje na plikach w toku. notification-in-progress = Operacje na plikach w toku.
trash = Kosz trash = Kosz
recents = Ubiegłe
undo = Cofnij undo = Cofnij
# List view # List view
@ -62,11 +64,44 @@ apply-to-all = Zastosuj do wszystkich
keep-both = Zachowaj oba keep-both = Zachowaj oba
skip = Pomiń skip = Pomiń
## Metadata Dialog
owner = Właściciel
group = Grupa
other = Inni
read = Odczyt
write = Zapis
execute = Wykonywanie
# Context Pages # Context Pages
## About ## About
git-description = Git commit {$hash} z {$date} git-description = Git commit {$hash} z {$date}
## Add Network Drive
add-network-drive = Dodaj dysk sieciowy
connect = Połącz
connect-anonymously = Połącz anonimowo
connecting = Łączenie...
domain = Domena
enter-server-address = Wprowadź adres serwera
network-drive-description =
Adres serwera zawiera prefiks protokołu i adres.
Przykładowo: ssh://192.168.0.1, ftp://[2001:db8::1]
### Make sure to keep the comma which separates the columns
network-drive-schemes =
Dostępne protokoły,Prefiks
AppleTalk,afp://
File Transfer Protocol,ftp:// or ftps://
Network File System,nfs://
Server Message Block,smb://
SSH File Transfer Protocol,sftp:// or ssh://
WebDav,dav:// or davs://
network-drive-error = Brak dostępu do dysku sieciowego
password = Hasło
remember-password = Zapamiętaj hasło
try-again = Spróbuj ponownie
username = Nazwa użytkownika
## Operations ## Operations
edit-history = Edytuj historię edit-history = Edytuj historię
history = Historia history = Historia
@ -200,11 +235,6 @@ show-hidden-files = Pokaż ukryte pliki
list-directories-first = Najpierw wyświetlaj katalogi list-directories-first = Najpierw wyświetlaj katalogi
menu-settings = Ustawienia... menu-settings = Ustawienia...
menu-about = O Plikach COSMIC... menu-about = O Plikach COSMIC...
list-view = Widok listy
show-hidden-files = Pokaż ukryte pliki
list-directories-first = Najpierw wyświetlaj katalogi
menu-settings = Ustawienia...
menu-about = O Plikach COSMIC...
## Sort ## Sort
sort = Uszereguj sort = Uszereguj
@ -212,5 +242,5 @@ sort-a-z = A-Z
sort-z-a = Z-A sort-z-a = Z-A
sort-newest-first = Najpierw najnowsze sort-newest-first = Najpierw najnowsze
sort-oldest-first = Najpierw najstarsze sort-oldest-first = Najpierw najstarsze
sort-smallest-to-largest = Od najmniejszego do największego sort-smallest-to-largest = Najpierw najmniejsze
sort-largest-to-smallest = Od największego do najmniejszego sort-largest-to-smallest = Najpierw największe

View file

@ -2,7 +2,11 @@ empty-folder = Tom katalog
empty-folder-hidden = Tom katalog (har dolda objekt) empty-folder-hidden = Tom katalog (har dolda objekt)
filesystem = Filsystem filesystem = Filsystem
home = Hem home = Hem
networks = Nätverk
trash = Papperskorg trash = Papperskorg
recents = Senaste
undo = Ångra
today = Idag
# Dialog # Dialog
cancel = Avbryt cancel = Avbryt
@ -27,6 +31,15 @@ properties = Egenskaper
## Settings ## Settings
settings = Inställningar settings = Inställningar
settings-tab = Flik
settings-show-hidden = Visa dolda filer
default-view = Standardvy
icon-size-list = Ikonstorlek (lista)
icon-size-grid = Ikonstorlek (rutnät)
sorting-name = Sortera efter
direction = Riktning
ascending = Stigande
descending = Fallande
### Appearance ### Appearance
appearance = Utseende appearance = Utseende
@ -78,3 +91,39 @@ default-app = {$name} (standard)
## Show details ## Show details
show-details = Visa detaljer show-details = Visa detaljer
network-drive-description =
Serveradresser inkluderar ett protokollprefix och en adress.
Exempel: ssh://192.168.0.1, ftp://[2001:db8::1]
### Se till att behålla kommatecken som skiljer kolumnerna åt
network-drive-schemes =
Tillgängliga protokoll, Prefix
AppleTalk,afp://
Filöverföringsprotokoll,ftp:// eller ftps://
Nätverksfilsystem,nfs://
Servermeddelandeblock,smb://
SSH-filöverföringsprotokoll,sftp:// eller ssh://
WebDav,dav:// eller davs://
network-drive-error = Kan inte komma åt nätverksenheten
password = Lösenord
remember-password = Kom ihåg lösenord
## Lägg till en Nätverksenhet
add-network-drive = Lägg till en Nätverksenhet
connect = Anslut
connect-anonymously = Anslut anonymt
connecting = Ansluter...
domain = Domän
enter-server-address = Ange server address
try-again = Försök igen
username = Användarnamn
## Sort
sort = Sortera
sort-a-z = A-Z
sort-z-a = Z-A
sort-newest-first = Nyaste först
sort-oldest-first = Äldst först
sort-smallest-to-largest = Minsta till största
sort-largest-to-smallest = Största till minsta

View file

@ -521,11 +521,14 @@ impl App {
location: Location, location: Location,
selection_path: Option<PathBuf>, selection_path: Option<PathBuf>,
) -> Command<Message> { ) -> Command<Message> {
let mounters = self.mounters.clone();
let icon_sizes = self.config.tab.icon_sizes; let icon_sizes = self.config.tab.icon_sizes;
Command::perform( Command::perform(
async move { 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(mounters, icon_sizes))
.await
{
Ok(items) => { Ok(items) => {
message::app(Message::TabRescan(entity, location, items, selection_path)) message::app(Message::TabRescan(entity, location, items, selection_path))
} }
@ -591,7 +594,7 @@ impl App {
if let Some(ref items) = tab.items_opt() { if let Some(ref items) = tab.items_opt() {
for item in items.iter() { for item in items.iter() {
if item.selected { if item.selected {
if let Some(path) = &item.path_opt { if let Some(Location::Path(path)) = &item.location_opt {
paths.push(path.clone()); paths.push(path.clone());
} }
} }
@ -672,7 +675,10 @@ impl App {
.size(16) .size(16)
.handle(), .handle(),
)) ))
.data(Location::Networks) .data(Location::Network(
"network:///".to_string(),
fl!("networks"),
))
.divider_above() .divider_above()
}); });
@ -941,49 +947,35 @@ impl App {
fn properties(&self, entity: Option<ContextItem>) -> Element<Message> { fn properties(&self, entity: Option<ContextItem>) -> Element<Message> {
match entity { match entity {
None => self.tab_properties(self.tab_model.active()), None => self.tab_properties(self.tab_model.active()),
Some(ContextItem::TabBar(entity)) => self.tab_properties(entity), Some(ContextItem::TabBar(entity)) => self.tab_properties(entity),
Some(ContextItem::NavBar(item)) => { Some(ContextItem::NavBar(item)) => {
let mut children = Vec::new(); let mut children = Vec::with_capacity(1);
if let Some(location) = self.nav_model.data::<Location>(item) { if let Some(location) = self.nav_model.data::<Location>(item) {
if let Location::Path(path) = location { if let Location::Path(path) = location {
let parent = path.parent().unwrap_or(path); //TODO: this should be done once, not when generating the view!
if let Ok(item) = tab::item_from_path(path, self.config.tab.icon_sizes) {
for item in Location::Path(parent.to_owned()).scan(IconSizes::default()) { children.push(item.property_view(IconSizes::default()));
if item.path_opt.as_deref() == Some(path) {
children.push(item.property_view(IconSizes::default()));
}
} }
}; }
} }
widget::settings::view_column(children).into() widget::settings::view_column(children).into()
} }
Some(ContextItem::BreadCrumbs(index)) => { Some(ContextItem::BreadCrumbs(index)) => {
let mut children = Vec::new(); let mut children = Vec::with_capacity(1);
if let Some(tab) = self.tab_model.active_data::<Tab>() { if let Some(tab) = self.tab_model.active_data::<Tab>() {
let path = match tab.location { let path_opt = tab
Location::Path(ref path) => Some(path), .location
Location::Search(ref path, _) => Some(path), .path_opt()
_ => None, .and_then(|path| path.ancestors().nth(index))
} .map(|path| path.to_path_buf());
.and_then(|path| path.ancestors().nth(index)) if let Some(ref path) = path_opt {
.map(|path| path.to_path_buf()); //TODO: this should be done once, not when generating the view!
if let Some(ref path) = path { if let Ok(item) = tab::item_from_path(path, self.config.tab.icon_sizes) {
let parent = path.parent().unwrap_or(path); children.push(item.property_view(IconSizes::default()));
for item in Location::Path(parent.to_owned()).scan(IconSizes::default()) {
if item.path_opt.as_deref() == Some(path) {
children.push(item.property_view(IconSizes::default()));
}
} }
}; };
} }
widget::settings::view_column(children).into() widget::settings::view_column(children).into()
} }
} }
@ -1772,9 +1764,7 @@ impl Application for App {
//TODO: this could be further optimized by looking at what exactly changed //TODO: this could be further optimized by looking at what exactly changed
if let Some(items) = &mut tab.items_opt { if let Some(items) = &mut tab.items_opt {
for item in items.iter_mut() { for item in items.iter_mut() {
if item.path_opt.as_ref() if item.path_opt() == Some(event_path) {
== Some(event_path)
{
//TODO: reload more, like mime types? //TODO: reload more, like mime types?
match fs::metadata(&event_path) { match fs::metadata(&event_path) {
Ok(new_metadata) => match &mut item Ok(new_metadata) => match &mut item
@ -1836,7 +1826,7 @@ impl Application for App {
if let Some(items) = tab.items_opt() { if let Some(items) = tab.items_opt() {
for item in items.iter() { for item in items.iter() {
if item.selected { if item.selected {
if let Some(path) = &item.path_opt { if let Some(Location::Path(path)) = &item.location_opt {
paths.push(path.clone()); paths.push(path.clone());
} }
} }
@ -2036,7 +2026,7 @@ impl Application for App {
let mut selected = Vec::new(); let mut selected = Vec::new();
for item in items.iter() { for item in items.iter() {
if item.selected { if item.selected {
if let Some(path) = &item.path_opt { if let Some(Location::Path(path)) = &item.location_opt {
selected.push(path.clone()); selected.push(path.clone());
} }
} }
@ -2398,11 +2388,14 @@ impl Application for App {
self.toasts.remove(id); self.toasts.remove(id);
let mut paths = Vec::with_capacity(recently_trashed.len()); let mut paths = Vec::with_capacity(recently_trashed.len());
let mounters = self.mounters.clone();
let icon_sizes = self.config.tab.icon_sizes; let icon_sizes = self.config.tab.icon_sizes;
return cosmic::command::future(async move { return cosmic::command::future(async move {
match tokio::task::spawn_blocking(move || Location::Trash.scan(icon_sizes)) match tokio::task::spawn_blocking(move || {
.await Location::Trash.scan(mounters, icon_sizes)
})
.await
{ {
Ok(items) => { Ok(items) => {
for path in &*recently_trashed { for path in &*recently_trashed {
@ -3551,6 +3544,7 @@ pub(crate) mod test_utils {
use crate::{ use crate::{
config::{IconSizes, TabConfig}, config::{IconSizes, TabConfig},
mounter::MounterMap,
tab::Item, tab::Item,
}; };
@ -3713,7 +3707,7 @@ pub(crate) mod test_utils {
// New tab with items // New tab with items
let location = Location::Path(path.to_owned()); let location = Location::Path(path.to_owned());
let items = location.scan(IconSizes::default()); let items = location.scan(Mounters::new(MounterMap::new()), IconSizes::default());
let mut tab = Tab::new(location, TabConfig::default()); let mut tab = Tab::new(location, TabConfig::default());
tab.set_items(items); tab.set_items(items);
@ -3748,7 +3742,7 @@ pub(crate) mod test_utils {
name == item.name name == item.name
&& is_dir == item.metadata.is_dir() && is_dir == item.metadata.is_dir()
&& path == item.path_opt.as_ref().expect("item should have path") && path == item.path_opt().expect("item should have path")
&& is_hidden == item.hidden && is_hidden == item.hidden
} }
@ -3768,6 +3762,19 @@ pub(crate) mod test_utils {
); );
} }
pub fn assert_zoom_affects_item_size(tab: &mut Tab, message: tab::Message, should_zoom: bool) {
let grid_icon_size = tab.config.icon_sizes.grid;
let list_icon_size = tab.config.icon_sizes.list;
debug!("Emitting {:?}", message);
tab.update(message, Modifiers::empty());
let grid_size_changed = grid_icon_size != tab.config.icon_sizes.grid;
let list_size_changed = list_icon_size != tab.config.icon_sizes.list;
assert_eq!(grid_size_changed || list_size_changed, should_zoom);
}
/// Assert that tab's items are equal to a path's entries. /// Assert that tab's items are equal to a path's entries.
pub fn assert_eq_tab_path_contents(tab: &Tab, path: &Path) { pub fn assert_eq_tab_path_contents(tab: &Tab, path: &Path) {
let Location::Path(ref tab_path) = tab.location else { let Location::Path(ref tab_path) = tab.location else {

View file

@ -376,10 +376,12 @@ struct App {
impl App { impl App {
fn rescan_tab(&self) -> Command<Message> { fn rescan_tab(&self) -> Command<Message> {
let location = self.tab.location.clone(); let location = self.tab.location.clone();
let mounters = self.mounters.clone();
let icon_sizes = self.tab.config.icon_sizes; let icon_sizes = self.tab.config.icon_sizes;
Command::perform( Command::perform(
async move { async move {
match tokio::task::spawn_blocking(move || location.scan(icon_sizes)).await { match tokio::task::spawn_blocking(move || location.scan(mounters, icon_sizes)).await
{
Ok(items) => message::app(Message::TabRescan(items)), Ok(items) => message::app(Message::TabRescan(items)),
Err(err) => { Err(err) => {
log::warn!("failed to rescan: {}", err); log::warn!("failed to rescan: {}", err);
@ -895,7 +897,7 @@ impl Application for App {
} }
} }
} }
DialogPage::Replace { filename } => { DialogPage::Replace { .. } => {
return self.update(Message::Save(true)); return self.update(Message::Save(true));
} }
} }
@ -999,7 +1001,7 @@ impl Application for App {
//TODO: this could be further optimized by looking at what exactly changed //TODO: this could be further optimized by looking at what exactly changed
if let Some(items) = &mut self.tab.items_opt { if let Some(items) = &mut self.tab.items_opt {
for item in items.iter_mut() { for item in items.iter_mut() {
if item.path_opt.as_ref() == Some(event_path) { if item.path_opt() == Some(event_path) {
//TODO: reload more, like mime types? //TODO: reload more, like mime types?
match fs::metadata(&event_path) { match fs::metadata(&event_path) {
Ok(new_metadata) => { Ok(new_metadata) => {
@ -1049,7 +1051,7 @@ impl Application for App {
if let Some(items) = self.tab.items_opt() { if let Some(items) = self.tab.items_opt() {
for item in items.iter() { for item in items.iter() {
if item.selected { if item.selected {
if let Some(path) = &item.path_opt { if let Some(Location::Path(path)) = &item.location_opt {
paths.push(path.clone()); paths.push(path.clone());
let _ = update_recently_used( let _ = update_recently_used(
&path.clone(), &path.clone(),

View file

@ -25,6 +25,10 @@ mod spawn_detached;
use tab::Location; use tab::Location;
pub mod tab; pub mod tab;
pub(crate) fn err_str<T: ToString>(err: T) -> String {
err.to_string()
}
pub fn home_dir() -> PathBuf { pub fn home_dir() -> PathBuf {
match dirs::home_dir() { match dirs::home_dir() {
Some(home) => home, Some(home) => home,

View file

@ -197,8 +197,22 @@ pub fn context_menu<'a>(
children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
} }
} }
(_, Location::Networks) => { (_, Location::Network(_, _)) => {
//TODO: networks context menu? if selected > 0 {
if selected_dir == 1 && selected == 1 || selected_dir == 0 {
children.push(menu_item(fl!("open"), Action::Open).into());
}
} else {
if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
}
if !children.is_empty() {
children.push(divider::horizontal::light().into());
}
children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
}
} }
(_, Location::Trash) => { (_, Location::Trash) => {
if tab.mode.multiple() { if tab.mode.multiple() {

View file

@ -3,10 +3,15 @@ use cosmic::{
widget, Command, widget, Command,
}; };
use gio::{glib, prelude::*}; use gio::{glib, prelude::*};
use std::{any::TypeId, future::pending, path::PathBuf, sync::Arc}; use std::{any::TypeId, cell::Cell, future::pending, path::PathBuf, sync::Arc};
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage}; use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage};
use crate::{
config::IconSizes,
err_str,
tab::{self, ItemMetadata, ItemThumbnail, Location},
};
fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> { fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> {
if let Some(themed_icon) = icon.downcast_ref::<gio::ThemedIcon>() { if let Some(themed_icon) = icon.downcast_ref::<gio::ThemedIcon>() {
@ -21,10 +26,154 @@ fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> {
None None
} }
fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
let file = gio::File::for_uri(uri);
let mut items = Vec::new();
for info_res in file
.enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE)
.map_err(err_str)?
{
let info = info_res.map_err(err_str)?;
println!("{:?}", info.display_name());
for attribute in info.list_attributes(None) {
println!(
" {:?}: {:?}: {:?}",
attribute,
info.attribute_type(&attribute),
info.attribute_as_string(&attribute)
);
}
let name = info.name().to_string_lossy().to_string();
let display_name = info.display_name().to_string();
//TODO: what is the best way to resolve shortcuts?
let location = Location::Network(
if let Some(target_uri) = info.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_TARGET_URI)
{
target_uri.to_string()
} else {
file.child(info.name()).uri().to_string()
},
display_name.clone(),
);
//TODO: support dir or file
let metadata = ItemMetadata::SimpleDir { entries: 0 };
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
let file_icon = |size| {
info.icon()
.as_ref()
.and_then(|icon| gio_icon_to_path(icon, size))
.map(|path| widget::icon::from_path(path))
.unwrap_or(
widget::icon::from_name(if metadata.is_dir() {
"folder"
} else {
"text-x-generic"
})
.size(size)
.handle(),
)
};
(
//TODO: get mime from content_type?
"inode/directory".parse().unwrap(),
file_icon(sizes.grid()),
file_icon(sizes.list()),
file_icon(sizes.list_condensed()),
)
};
items.push(tab::Item {
name,
display_name,
metadata,
hidden: false,
location_opt: Some(location),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
open_with: Vec::new(),
thumbnail_opt: Some(ItemThumbnail::NotImage),
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
overlaps_drag_rect: false,
});
}
Ok(items)
}
fn mount_op(uri: String, event_tx: mpsc::UnboundedSender<Event>) -> gio::MountOperation {
let mount_op = gio::MountOperation::new();
mount_op.connect_ask_password(
move |mount_op, message, default_user, default_domain, flags| {
let auth = MounterAuth {
message: message.to_string(),
username_opt: if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) {
Some(default_user.to_string())
} else {
None
},
domain_opt: if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) {
Some(default_domain.to_string())
} else {
None
},
password_opt: if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) {
Some(String::new())
} else {
None
},
remember_opt: if flags.contains(gio::AskPasswordFlags::SAVING_SUPPORTED) {
Some(false)
} else {
None
},
anonymous_opt: if flags.contains(gio::AskPasswordFlags::ANONYMOUS_SUPPORTED) {
Some(false)
} else {
None
},
};
let (auth_tx, mut auth_rx) = mpsc::channel(1);
event_tx
.send(Event::NetworkAuth(uri.clone(), auth, auth_tx))
.unwrap();
//TODO: async recv?
if let Some(auth) = auth_rx.blocking_recv() {
if auth.anonymous_opt == Some(true) {
mount_op.set_anonymous(true);
} else {
mount_op.set_username(auth.username_opt.as_deref());
mount_op.set_domain(auth.domain_opt.as_deref());
mount_op.set_password(auth.password_opt.as_deref());
if auth.remember_opt == Some(true) {
mount_op.set_password_save(gio::PasswordSave::Permanently);
}
}
mount_op.reply(gio::MountOperationResult::Handled);
} else {
mount_op.reply(gio::MountOperationResult::Aborted);
}
},
);
mount_op
}
enum Cmd { enum Cmd {
Rescan, Rescan,
Mount(MounterItem), Mount(MounterItem),
NetworkDrive(String), NetworkDrive(String),
NetworkScan(
String,
IconSizes,
mpsc::Sender<Result<Vec<tab::Item>, String>>,
),
Unmount(MounterItem), Unmount(MounterItem),
} }
@ -194,62 +343,8 @@ impl Gvfs {
} }
} }
Cmd::NetworkDrive(uri) => { Cmd::NetworkDrive(uri) => {
let mount_op = gio::MountOperation::new();
{
let event_tx = event_tx.clone();
let uri = uri.clone();
mount_op.connect_ask_password(move |mount_op, message, default_user, default_domain, flags| {
let auth = MounterAuth {
message: message.to_string(),
username_opt: if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) {
Some(default_user.to_string())
} else {
None
},
domain_opt: if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) {
Some(default_domain.to_string())
} else {
None
},
password_opt: if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) {
Some(String::new())
} else {
None
},
remember_opt: if flags.contains(gio::AskPasswordFlags::SAVING_SUPPORTED) {
Some(false)
} else {
None
},
anonymous_opt: if flags.contains(gio::AskPasswordFlags::ANONYMOUS_SUPPORTED) {
Some(false)
} else {
None
}
};
let (auth_tx, mut auth_rx) = mpsc::channel(1);
event_tx.send(Event::NetworkAuth(uri.clone(), auth, auth_tx)).unwrap();
//TODO: async recv?
if let Some(auth) = auth_rx.blocking_recv() {
if auth.anonymous_opt == Some(true) {
mount_op.set_anonymous(true);
} else {
mount_op.set_username(auth.username_opt.as_deref());
mount_op.set_domain(auth.domain_opt.as_deref());
mount_op.set_password(auth.password_opt.as_deref());
if auth.remember_opt == Some(true) {
mount_op.set_password_save(gio::PasswordSave::Permanently);
}
}
mount_op.reply(gio::MountOperationResult::Handled);
} else {
mount_op.reply(gio::MountOperationResult::Aborted);
}
});
}
let file = gio::File::for_uri(&uri); let file = gio::File::for_uri(&uri);
let mount_op = mount_op(uri.clone(), event_tx.clone());
let event_tx = event_tx.clone(); let event_tx = event_tx.clone();
file.mount_enclosing_volume( file.mount_enclosing_volume(
gio::MountMountFlags::empty(), gio::MountMountFlags::empty(),
@ -267,6 +362,40 @@ impl Gvfs {
} }
); );
} }
Cmd::NetworkScan(uri, sizes, items_tx) => {
let file = gio::File::for_uri(&uri);
let needs_mount = match file.find_enclosing_mount(gio::Cancellable::NONE) {
Ok(_) => false,
Err(err) => match err.kind::<gio::IOErrorEnum>() {
Some(gio::IOErrorEnum::NotMounted) => true,
_ => false
}
};
if needs_mount {
let mount_op = mount_op(uri.clone(), event_tx.clone());
let event_tx = event_tx.clone();
file.mount_enclosing_volume(
gio::MountMountFlags::empty(),
Some(&mount_op),
gio::Cancellable::NONE,
move |res| {
log::info!("network scan mounted {}: result {:?}", uri, res);
items_tx.blocking_send(network_scan(&uri, sizes)).unwrap();
event_tx.send(Event::NetworkResult(uri, match res {
Ok(()) => {
Ok(true)
},
Err(err) => match err.kind::<gio::IOErrorEnum>() {
Some(gio::IOErrorEnum::FailedHandled) => Ok(false),
_ => Err(format!("{}", err))
}
})).unwrap();
}
);
} else {
items_tx.send(network_scan(&uri, sizes)).await.unwrap();
}
}
Cmd::Unmount(mounter_item) => { Cmd::Unmount(mounter_item) => {
let MounterItem::Gvfs(item) = mounter_item else { continue }; let MounterItem::Gvfs(item) = mounter_item else { continue };
let ItemKind::Mount = item.kind else { continue }; let ItemKind::Mount = item.kind else { continue };
@ -330,6 +459,14 @@ impl Mounter for Gvfs {
) )
} }
fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option<Result<Vec<tab::Item>, String>> {
let (items_tx, mut items_rx) = mpsc::channel(1);
self.command_tx
.send(Cmd::NetworkScan(uri.to_string(), sizes, items_tx))
.unwrap();
items_rx.blocking_recv()
}
fn unmount(&self, item: MounterItem) -> Command<()> { fn unmount(&self, item: MounterItem) -> Command<()> {
let command_tx = self.command_tx.clone(); let command_tx = self.command_tx.clone();
Command::perform( Command::perform(

View file

@ -2,6 +2,8 @@ use cosmic::{iced::subscription, widget, Command};
use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc}; use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{config::IconSizes, tab};
#[cfg(feature = "gvfs")] #[cfg(feature = "gvfs")]
mod gvfs; mod gvfs;
@ -86,10 +88,11 @@ pub enum MounterMessage {
NetworkResult(String, Result<bool, String>), NetworkResult(String, Result<bool, String>),
} }
pub trait Mounter { pub trait Mounter: Send + Sync {
//TODO: send result //TODO: send result
fn mount(&self, item: MounterItem) -> Command<()>; fn mount(&self, item: MounterItem) -> Command<()>;
fn network_drive(&self, uri: String) -> Command<()>; fn network_drive(&self, uri: String) -> Command<()>;
fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option<Result<Vec<tab::Item>, String>>;
fn unmount(&self, item: MounterItem) -> Command<()>; fn unmount(&self, item: MounterItem) -> Command<()>;
fn subscription(&self) -> subscription::Subscription<MounterMessage>; fn subscription(&self) -> subscription::Subscription<MounterMessage>;
} }

View file

@ -6,8 +6,14 @@ use cosmic::{
iced_core::{ iced_core::{
border::Border, border::Border,
event::{self, Event}, event::{self, Event},
keyboard::{
self,
key::{self, Key},
Event::{KeyPressed, KeyReleased},
Modifiers,
},
layout, layout,
mouse::{self, click}, mouse::{self, click, Event as MouseEvent},
overlay, overlay,
renderer::{self, Quad, Renderer as _}, renderer::{self, Quad, Renderer as _},
touch, touch,
@ -39,6 +45,7 @@ pub struct MouseArea<'a, Message> {
on_back_release: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>, on_back_release: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_forward_press: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>, on_forward_press: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_forward_release: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>, on_forward_release: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_scroll: Option<Box<dyn Fn(mouse::ScrollDelta, Modifiers) -> Option<Message> + 'a>>,
show_drag_rect: bool, show_drag_rect: bool,
} }
@ -144,6 +151,16 @@ impl<'a, Message> MouseArea<'a, Message> {
self self
} }
/// The message to emit on a scroll.
#[must_use]
pub fn on_scroll(
mut self,
message: impl Fn(mouse::ScrollDelta, Modifiers) -> Option<Message> + 'a,
) -> Self {
self.on_scroll = Some(Box::new(message));
self
}
#[must_use] #[must_use]
pub fn show_drag_rect(mut self, show_drag_rect: bool) -> Self { pub fn show_drag_rect(mut self, show_drag_rect: bool) -> Self {
self.show_drag_rect = show_drag_rect; self.show_drag_rect = show_drag_rect;
@ -163,7 +180,7 @@ impl<'a, Message> MouseArea<'a, Message> {
struct State { struct State {
// TODO: Support on_mouse_enter and on_mouse_exit // TODO: Support on_mouse_enter and on_mouse_exit
drag_initiated: Option<Point>, drag_initiated: Option<Point>,
modifiers: Modifiers,
prev_click: Option<(mouse::Click, Instant)>, prev_click: Option<(mouse::Click, Instant)>,
} }
@ -227,6 +244,7 @@ impl<'a, Message> MouseArea<'a, Message> {
on_back_release: None, on_back_release: None,
on_forward_press: None, on_forward_press: None,
on_forward_release: None, on_forward_release: None,
on_scroll: None,
show_drag_rect: false, show_drag_rect: false,
} }
} }
@ -578,6 +596,21 @@ fn update<Message: Clone>(
} }
} }
if let Some(message) = widget.on_scroll.as_ref() {
if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event {
if let Some(on_scroll) = widget.on_scroll.as_ref() {
if let Some(message) = on_scroll(delta.clone(), state.modifiers) {
shell.publish(message);
return event::Status::Captured;
}
}
}
}
if let Event::Keyboard(key_event) = event {
handle_key_event(key_event, state)
};
if let Some((message, drag_rect)) = widget.on_drag.as_ref().zip(state.drag_rect(cursor)) { if let Some((message, drag_rect)) = widget.on_drag.as_ref().zip(state.drag_rect(cursor)) {
shell.publish(message(drag_rect.intersection(&layout_bounds).map( shell.publish(message(drag_rect.intersection(&layout_bounds).map(
|mut rect| { |mut rect| {
@ -590,3 +623,53 @@ fn update<Message: Clone>(
event::Status::Ignored event::Status::Ignored
} }
fn handle_key_event(key_event: &keyboard::Event, state: &mut State) {
if let KeyPressed {
key: Key::Named(key::Named::Control),
..
} = key_event
{
state.modifiers.insert(Modifiers::CTRL);
}
if let KeyReleased {
key: Key::Named(key::Named::Control),
..
} = key_event
{
state.modifiers.remove(Modifiers::CTRL);
}
if let KeyPressed {
key: Key::Named(key::Named::Shift),
..
} = key_event
{
state.modifiers.insert(Modifiers::SHIFT);
}
if let KeyReleased {
key: Key::Named(key::Named::Shift),
..
} = key_event
{
state.modifiers.remove(Modifiers::SHIFT);
}
if let KeyPressed {
key: Key::Named(key::Named::Alt),
..
} = key_event
{
state.modifiers.insert(Modifiers::ALT);
}
if let KeyReleased {
key: Key::Named(key::Named::Alt),
..
} = key_event
{
state.modifiers.remove(Modifiers::ALT);
}
}

View file

@ -15,15 +15,11 @@ use walkdir::WalkDir;
use crate::{ use crate::{
app::{ArchiveType, DialogPage, Message}, app::{ArchiveType, DialogPage, Message},
config::IconSizes, config::IconSizes,
fl, err_str, fl,
mime_icon::mime_for_path, mime_icon::mime_for_path,
tab, tab,
}; };
fn err_str<T: ToString>(err: T) -> String {
err.to_string()
}
fn handle_replace( fn handle_replace(
msg_tx: &Arc<Mutex<Sender<Message>>>, msg_tx: &Arc<Mutex<Sender<Message>>>,
file_from: PathBuf, file_from: PathBuf,

View file

@ -7,8 +7,9 @@ use cosmic::{
}, },
alignment::{Horizontal, Vertical}, alignment::{Horizontal, Vertical},
clipboard::dnd::DndAction, clipboard::dnd::DndAction,
event,
futures::SinkExt, futures::SinkExt,
keyboard::Modifiers, keyboard::{self, Modifiers},
subscription::{self, Subscription}, subscription::{self, Subscription},
//TODO: export in cosmic::widget //TODO: export in cosmic::widget
widget::{ widget::{
@ -24,7 +25,7 @@ use cosmic::{
Rectangle, Rectangle,
Size, Size,
}, },
iced_core::widget::tree, iced_core::{mouse::ScrollDelta, widget::tree},
iced_style::rule, iced_style::rule,
theme, theme,
widget::{ widget::{
@ -44,7 +45,7 @@ use std::{
cell::{Cell, RefCell}, cell::{Cell, RefCell},
cmp::Ordering, cmp::Ordering,
collections::HashMap, collections::HashMap,
fmt, fmt::{self, Display},
fs::{self, Metadata}, fs::{self, Metadata},
num::NonZeroU16, num::NonZeroU16,
os::unix::fs::MetadataExt, os::unix::fs::MetadataExt,
@ -63,6 +64,7 @@ use crate::{
menu, menu,
mime_app::{mime_apps, MimeApp}, mime_app::{mime_apps, MimeApp},
mime_icon::{mime_for_path, mime_icon}, mime_icon::{mime_for_path, mime_icon},
mounter::Mounters,
mouse_area, mouse_area,
}; };
use unix_permissions_ext::UNIXPermissionsExt; use unix_permissions_ext::UNIXPermissionsExt;
@ -72,7 +74,8 @@ pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500);
pub const HOVER_DURATION: Duration = Duration::from_millis(1600); pub const HOVER_DURATION: Duration = Duration::from_millis(1600);
//TODO: adjust for locales? //TODO: adjust for locales?
const TIME_FORMAT: &'static str = "%a %-d %b %-Y %r"; const DATE_TIME_FORMAT: &'static str = "%b %-d, %-Y, %-I:%M %p";
const TIME_FORMAT: &'static str = "%-I:%M %p";
static SPECIAL_DIRS: Lazy<HashMap<PathBuf, &'static str>> = Lazy::new(|| { static SPECIAL_DIRS: Lazy<HashMap<PathBuf, &'static str>> = Lazy::new(|| {
let mut special_dirs = HashMap::new(); let mut special_dirs = HashMap::new();
if let Some(dir) = dirs::document_dir() { if let Some(dir) = dirs::document_dir() {
@ -260,6 +263,31 @@ fn format_permissions(metadata: &Metadata, owner: PermissionOwner) -> String {
perms.join(" ") perms.join(" ")
} }
struct FormatTime(std::time::SystemTime);
impl Display for FormatTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let date_time = chrono::DateTime::<chrono::Local>::from(self.0);
let now = chrono::Local::now();
if date_time.date() == now.date() {
write!(
f,
"{}, {}",
fl!("today"),
date_time.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
)
} else {
date_time
.format_localized(DATE_TIME_FORMAT, *LANGUAGE_CHRONO)
.fmt(f)
}
}
}
fn format_time(time: std::time::SystemTime) -> FormatTime {
FormatTime(time)
}
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn hidden_attribute(_metadata: &Metadata) -> bool { fn hidden_attribute(_metadata: &Metadata) -> bool {
false false
@ -377,7 +405,7 @@ pub fn item_from_entry(
display_name, display_name,
metadata: ItemMetadata::Path { metadata, children }, metadata: ItemMetadata::Path { metadata, children },
hidden, hidden,
path_opt: Some(path), location_opt: Some(Location::Path(path)),
mime, mime,
icon_handle_grid, icon_handle_grid,
icon_handle_list, icon_handle_list,
@ -535,7 +563,7 @@ pub fn scan_search(tab_path: &PathBuf, term: &str, sizes: IconSizes) -> Vec<Item
items.par_sort_unstable_by(|a, b| { items.par_sort_unstable_by(|a, b| {
let get_modified = |x: &Item| match &x.metadata { let get_modified = |x: &Item| match &x.metadata {
ItemMetadata::Path { metadata, .. } => metadata.modified().ok(), ItemMetadata::Path { metadata, .. } => metadata.modified().ok(),
ItemMetadata::Trash { .. } => None, _ => None,
}; };
// Sort with latest modified first // Sort with latest modified first
@ -621,7 +649,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
display_name, display_name,
metadata: ItemMetadata::Trash { metadata, entry }, metadata: ItemMetadata::Trash { metadata, entry },
hidden: false, hidden: false,
path_opt: None, location_opt: None,
mime, mime,
icon_handle_grid, icon_handle_grid,
icon_handle_list, icon_handle_list,
@ -724,9 +752,17 @@ pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
recents.into_iter().take(50).map(|(item, _)| item).collect() recents.into_iter().take(50).map(|(item, _)| item).collect()
} }
pub fn scan_networks(sizes: IconSizes) -> Vec<Item> { pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec<Item> {
//TODO: network folder items for (key, mounter) in mounters.iter() {
vec![] match mounter.network_scan(uri, sizes) {
Some(Ok(items)) => return items,
Some(Err(err)) => {
log::warn!("failed to scan {:?}: {}", uri, err);
}
None => {}
}
}
Vec::new()
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@ -735,7 +771,7 @@ pub enum Location {
Search(PathBuf, String), Search(PathBuf, String),
Trash, Trash,
Recents, Recents,
Networks, Network(String, String),
} }
impl std::fmt::Display for Location { impl std::fmt::Display for Location {
@ -745,19 +781,27 @@ impl std::fmt::Display for Location {
Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term), Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term),
Self::Trash => write!(f, "trash"), Self::Trash => write!(f, "trash"),
Self::Recents => write!(f, "recents"), Self::Recents => write!(f, "recents"),
Self::Networks => write!(f, "networks"), Self::Network(uri, _) => write!(f, "{}", uri),
} }
} }
} }
impl Location { impl Location {
pub fn scan(&self, sizes: IconSizes) -> Vec<Item> { pub fn path_opt(&self) -> Option<&PathBuf> {
match self {
Self::Path(path) => Some(&path),
Self::Search(path, _) => Some(&path),
_ => None,
}
}
pub fn scan(&self, mounters: Mounters, sizes: IconSizes) -> Vec<Item> {
match self { match self {
Self::Path(path) => scan_path(path, sizes), Self::Path(path) => scan_path(path, sizes),
Self::Search(path, term) => scan_search(path, term, sizes), Self::Search(path, term) => scan_search(path, term, sizes),
Self::Trash => scan_trash(sizes), Self::Trash => scan_trash(sizes),
Self::Recents => scan_recents(sizes), Self::Recents => scan_recents(sizes),
Self::Networks => scan_networks(sizes), Self::Network(uri, _) => scan_network(uri, mounters, sizes),
} }
} }
} }
@ -847,6 +891,12 @@ pub enum ItemMetadata {
metadata: trash::TrashItemMetadata, metadata: trash::TrashItemMetadata,
entry: trash::TrashItem, entry: trash::TrashItem,
}, },
SimpleDir {
entries: u64,
},
SimpleFile {
size: u64,
},
} }
impl ItemMetadata { impl ItemMetadata {
@ -857,6 +907,8 @@ impl ItemMetadata {
trash::TrashItemSize::Entries(_) => true, trash::TrashItemSize::Entries(_) => true,
trash::TrashItemSize::Bytes(_) => false, trash::TrashItemSize::Bytes(_) => false,
}, },
Self::SimpleDir { .. } => true,
Self::SimpleFile { .. } => false,
} }
} }
} }
@ -874,7 +926,7 @@ pub struct Item {
pub display_name: String, pub display_name: String,
pub metadata: ItemMetadata, pub metadata: ItemMetadata,
pub hidden: bool, pub hidden: bool,
pub path_opt: Option<PathBuf>, pub location_opt: Option<Location>,
pub mime: Mime, pub mime: Mime,
pub icon_handle_grid: widget::icon::Handle, pub icon_handle_grid: widget::icon::Handle,
pub icon_handle_list: widget::icon::Handle, pub icon_handle_list: widget::icon::Handle,
@ -894,6 +946,10 @@ impl Item {
name.replace(".", ".\u{200B}").replace("_", "_\u{200B}") name.replace(".", ".\u{200B}").replace("_", "_\u{200B}")
} }
pub fn path_opt(&self) -> Option<&PathBuf> {
self.location_opt.as_ref()?.path_opt()
}
fn preview(&self, sizes: IconSizes) -> Element<'static, app::Message> { fn preview(&self, sizes: IconSizes) -> Element<'static, app::Message> {
// This loads the image only if thumbnailing worked // This loads the image only if thumbnailing worked
let icon = widget::icon::icon(self.icon_handle_grid.clone()) let icon = widget::icon::icon(self.icon_handle_grid.clone())
@ -907,7 +963,7 @@ impl Item {
{ {
ItemThumbnail::NotImage => icon, ItemThumbnail::NotImage => icon,
ItemThumbnail::Rgba(_) => { ItemThumbnail::Rgba(_) => {
if let Some(path) = &self.path_opt { if let Some(Location::Path(path)) = &self.location_opt {
widget::image::viewer(widget::image::Handle::from_path(path)) widget::image::viewer(widget::image::Handle::from_path(path))
.min_scale(1.0) .min_scale(1.0)
.into() .into()
@ -916,7 +972,7 @@ impl Item {
} }
} }
ItemThumbnail::Svg => { ItemThumbnail::Svg => {
if let Some(path) = &self.path_opt { if let Some(Location::Path(path)) = &self.location_opt {
widget::Svg::from_path(path).into() widget::Svg::from_path(path).into()
} else { } else {
icon icon
@ -944,7 +1000,7 @@ impl Item {
column = column.push(widget::text(format!("Type: {}", self.mime))); column = column.push(widget::text(format!("Type: {}", self.mime)));
if let Some(path) = &self.path_opt { if let Some(Location::Path(path)) = &self.location_opt {
for app in self.open_with.iter() { for app in self.open_with.iter() {
column = column.push( column = column.push(
widget::button( widget::button(
@ -998,27 +1054,15 @@ impl Item {
} }
if let Ok(time) = metadata.created() { if let Ok(time) = metadata.created() {
column = column.push(widget::text(format!( column = column.push(widget::text(format!("Created: {}", format_time(time))));
"Created: {}",
chrono::DateTime::<chrono::Local>::from(time)
.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
)));
} }
if let Ok(time) = metadata.modified() { if let Ok(time) = metadata.modified() {
column = column.push(widget::text(format!( column = column.push(widget::text(format!("Modified: {}", format_time(time))));
"Modified: {}",
chrono::DateTime::<chrono::Local>::from(time)
.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
)));
} }
if let Ok(time) = metadata.accessed() { if let Ok(time) = metadata.accessed() {
column = column.push(widget::text(format!( column = column.push(widget::text(format!("Accessed: {}", format_time(time))));
"Accessed: {}",
chrono::DateTime::<chrono::Local>::from(time)
.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
)));
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
@ -1061,8 +1105,8 @@ impl Item {
); );
} }
} }
ItemMetadata::Trash { .. } => { _ => {
//TODO: trash metadata //TODO: other metadata types
} }
} }
@ -1097,13 +1141,12 @@ impl Item {
if let Ok(time) = metadata.modified() { if let Ok(time) = metadata.modified() {
column = column.push(widget::text(format!( column = column.push(widget::text(format!(
"Last modified: {}", "Last modified: {}",
chrono::DateTime::<chrono::Local>::from(time) format_time(time)
.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
))); )));
} }
} }
ItemMetadata::Trash { .. } => { _ => {
//TODO: trash metadata //TODO: other metadata
} }
} }
@ -1253,9 +1296,7 @@ impl Tab {
Location::Recents => { Location::Recents => {
fl!("recents") fl!("recents")
} }
Location::Networks => { Location::Network(_uri, display_name) => display_name.clone(),
fl!("networks")
}
} }
} }
@ -1309,10 +1350,11 @@ impl Tab {
} }
pub fn select_path(&mut self, path: PathBuf) { pub fn select_path(&mut self, path: PathBuf) {
let location = Location::Path(path);
*self.cached_selected.borrow_mut() = None; *self.cached_selected.borrow_mut() = None;
if let Some(ref mut items) = self.items_opt { if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() { for item in items.iter_mut() {
item.selected = item.path_opt.as_ref() == Some(&path); item.selected = item.location_opt.as_ref() == Some(&location);
} }
} }
} }
@ -1566,14 +1608,18 @@ impl Tab {
.as_ref() .as_ref()
.and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i))) .and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i)))
{ {
if let Some(path) = &clicked_item.path_opt { if let Some(location) = &clicked_item.location_opt {
if clicked_item.metadata.is_dir() { if clicked_item.metadata.is_dir() {
cd = Some(Location::Path(path.clone())); cd = Some(location.clone());
} else { } else {
commands.push(Command::OpenFile(path.clone())); if let Location::Path(path) = location {
commands.push(Command::OpenFile(path.clone()));
} else {
log::warn!("no path for item {:?}", clicked_item);
}
} }
} else { } else {
log::warn!("no path for item {:?}", clicked_item); log::warn!("no location for item {:?}", clicked_item);
} }
} else { } else {
log::warn!("no item for click index {:?}", click_i_opt); log::warn!("no item for click index {:?}", click_i_opt);
@ -1964,12 +2010,14 @@ impl Tab {
if let Some(ref mut items) = self.items_opt { if let Some(ref mut items) = self.items_opt {
for item in items.iter() { for item in items.iter() {
if item.selected { if item.selected {
if let Some(path) = &item.path_opt { if let Some(location) = &item.location_opt {
if path.is_dir() { if item.metadata.is_dir() {
//TODO: allow opening multiple tabs? //TODO: allow opening multiple tabs?
cd = Some(Location::Path(path.clone())); cd = Some(location.clone());
} else { } else {
commands.push(Command::OpenFile(path.clone())); if let Location::Path(path) = location {
commands.push(Command::OpenFile(path.clone()));
}
} }
} else { } else {
//TODO: open properties? //TODO: open properties?
@ -2004,7 +2052,7 @@ impl Tab {
if let Some(clicked_item) = if let Some(clicked_item) =
self.items_opt.as_ref().and_then(|items| items.get(click_i)) self.items_opt.as_ref().and_then(|items| items.get(click_i))
{ {
if let Some(path) = &clicked_item.path_opt { if let Some(Location::Path(path)) = &clicked_item.location_opt {
if clicked_item.metadata.is_dir() { if clicked_item.metadata.is_dir() {
//cd = Some(Location::Path(path.clone())); //cd = Some(Location::Path(path.clone()));
commands.push(Command::OpenInNewTab(path.clone())) commands.push(Command::OpenInNewTab(path.clone()))
@ -2035,8 +2083,9 @@ impl Tab {
} }
Message::Thumbnail(path, thumbnail) => { Message::Thumbnail(path, thumbnail) => {
if let Some(ref mut items) = self.items_opt { if let Some(ref mut items) = self.items_opt {
let location = Location::Path(path);
for item in items.iter_mut() { for item in items.iter_mut() {
if item.path_opt.as_ref() == Some(&path) { if item.location_opt.as_ref() == Some(&location) {
if let ItemThumbnail::Rgba(rgba) = &thumbnail { if let ItemThumbnail::Rgba(rgba) = &thumbnail {
//TODO: pass handles already generated to avoid blocking main thread //TODO: pass handles already generated to avoid blocking main thread
let handle = widget::icon::from_raster_pixels( let handle = widget::icon::from_raster_pixels(
@ -2085,20 +2134,11 @@ impl Tab {
} }
commands.push(Command::DropFiles(to, from)) commands.push(Command::DropFiles(to, from))
} }
Location::Search(_, _) => {
log::warn!(" Copy/cut to search not supported.");
}
Location::Trash if matches!(from.kind, ClipboardKind::Cut) => { Location::Trash if matches!(from.kind, ClipboardKind::Cut) => {
commands.push(Command::MoveToTrash(from.paths)) commands.push(Command::MoveToTrash(from.paths))
} }
Location::Trash => { _ => {
log::warn!("Copy to trash is not supported."); log::warn!("{:?} to {:?} is not supported.", from.kind, to);
}
Location::Recents => {
log::warn!("Copy to recents is not supported.");
}
Location::Networks => {
log::warn!("Copy to networks is not supported.");
} }
}; };
} }
@ -2227,6 +2267,8 @@ impl Tab {
trash::TrashItemSize::Entries(entries) => (true, entries as u64), trash::TrashItemSize::Entries(entries) => (true, entries as u64),
trash::TrashItemSize::Bytes(bytes) => (false, bytes), trash::TrashItemSize::Bytes(bytes) => (false, bytes),
}, },
ItemMetadata::SimpleDir { entries } => (true, *entries),
ItemMetadata::SimpleFile { size } => (false, *size),
}; };
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);
@ -2260,7 +2302,7 @@ impl Tab {
items.sort_by(|a, b| { items.sort_by(|a, b| {
let get_modified = |x: &Item| match &x.metadata { let get_modified = |x: &Item| match &x.metadata {
ItemMetadata::Path { metadata, .. } => metadata.modified().ok(), ItemMetadata::Path { metadata, .. } => metadata.modified().ok(),
ItemMetadata::Trash { .. } => None, _ => None,
}; };
let a_modified = get_modified(a.1); let a_modified = get_modified(a.1);
@ -2561,11 +2603,14 @@ impl Tab {
.into(), .into(),
); );
} }
Location::Networks => { Location::Network(uri, display_name) => {
children.push( children.push(
widget::button(widget::text::heading(fl!("networks"))) widget::button(widget::text::heading(display_name))
.padding(space_xxxs) .padding(space_xxxs)
.on_press(Message::Location(Location::Networks)) .on_press(Message::Location(Location::Network(
uri.clone(),
display_name.clone(),
)))
.style(theme::Button::Text) .style(theme::Button::Text)
.into(), .into(),
); );
@ -2765,9 +2810,10 @@ impl Tab {
} }
} }
let column: Element<Message> = if item.metadata.is_dir() && item.path_opt.is_some() let column: Element<Message> = if item.metadata.is_dir()
&& item.location_opt.is_some()
{ {
let tab_location = Location::Path(item.path_opt.clone().unwrap()); let tab_location = item.location_opt.clone().unwrap();
let tab_location_enter = tab_location.clone(); let tab_location_enter = tab_location.clone();
let tab_location_leave = tab_location.clone(); let tab_location_leave = tab_location.clone();
let is_dnd_hovered = let is_dnd_hovered =
@ -3036,18 +3082,21 @@ impl Tab {
let modified_text = match &item.metadata { let modified_text = match &item.metadata {
ItemMetadata::Path { metadata, .. } => match metadata.modified() { ItemMetadata::Path { metadata, .. } => match metadata.modified() {
Ok(time) => chrono::DateTime::<chrono::Local>::from(time) Ok(time) => format_time(time).to_string(),
.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO)
.to_string(),
Err(_) => String::new(), Err(_) => String::new(),
}, },
ItemMetadata::Trash { .. } => String::new(), _ => String::new(),
}; };
let size_text = match &item.metadata { let size_text = match &item.metadata {
ItemMetadata::Path { metadata, children } => { ItemMetadata::Path { metadata, children } => {
if metadata.is_dir() { if metadata.is_dir() {
format!("{} items", children) //TODO: translate
if *children == 1 {
format!("{} item", children)
} else {
format!("{} items", children)
}
} else { } else {
format_size(metadata.len()) format_size(metadata.len())
} }
@ -3063,6 +3112,15 @@ impl Tab {
} }
trash::TrashItemSize::Bytes(bytes) => format_size(bytes), trash::TrashItemSize::Bytes(bytes) => format_size(bytes),
}, },
ItemMetadata::SimpleDir { entries } => {
//TODO: translate
if *entries == 1 {
format!("{} item", entries)
} else {
format!("{} items", entries)
}
}
ItemMetadata::SimpleFile { size } => format_size(*size),
}; };
let row = if condensed { let row = if condensed {
@ -3126,58 +3184,59 @@ impl Tab {
}; };
let button_row = button(row.into()); let button_row = button(row.into());
let button_row: Element<_> = if item.metadata.is_dir() && item.path_opt.is_some() { let button_row: Element<_> =
let tab_location = Location::Path(item.path_opt.clone().unwrap()); if item.metadata.is_dir() && item.location_opt.is_some() {
let tab_location_enter = tab_location.clone(); let tab_location = item.location_opt.clone().unwrap();
let tab_location_leave = tab_location.clone(); let tab_location_enter = tab_location.clone();
let is_dnd_hovered = let tab_location_leave = tab_location.clone();
self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location); let is_dnd_hovered =
cosmic::widget::container( self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location);
DndDestination::for_data(button_row, move |data, action| { cosmic::widget::container(
if let Some(mut data) = data { DndDestination::for_data(button_row, move |data, action| {
if action == DndAction::Copy { if let Some(mut data) = data {
Message::Drop(Some((tab_location.clone(), data))) if action == DndAction::Copy {
} else if action == DndAction::Move { Message::Drop(Some((tab_location.clone(), data)))
data.kind = ClipboardKind::Cut; } else if action == DndAction::Move {
Message::Drop(Some((tab_location.clone(), data))) data.kind = ClipboardKind::Cut;
Message::Drop(Some((tab_location.clone(), data)))
} else {
log::warn!("unsupported action: {:?}", action);
Message::Drop(None)
}
} else { } else {
log::warn!("unsupported action: {:?}", action); log::warn!("No data for drop.");
Message::Drop(None) Message::Drop(None)
} }
} else { })
log::warn!("No data for drop."); .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone()))
Message::Drop(None) .on_leave(move || Message::DndLeave(tab_location_leave.clone())),
} )
}) // todo refactor into the dnd destination wrapper
.on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) .style(if is_dnd_hovered {
.on_leave(move || Message::DndLeave(tab_location_leave.clone())), theme::Container::custom(|t| {
) let mut a = cosmic::iced_style::container::StyleSheet::appearance(
// todo refactor into the dnd destination wrapper t,
.style(if is_dnd_hovered { &theme::Container::default(),
theme::Container::custom(|t| { );
let mut a = cosmic::iced_style::container::StyleSheet::appearance( let t = t.cosmic();
t, // todo use theme drop target color
&theme::Container::default(), let mut bg = t.accent_color();
); bg.alpha = 0.2;
let t = t.cosmic(); a.background = Some(Color::from(bg).into());
// todo use theme drop target color a.border = Border {
let mut bg = t.accent_color(); color: t.accent_color().into(),
bg.alpha = 0.2; width: 1.0,
a.background = Some(Color::from(bg).into()); radius: t.radius_s().into(),
a.border = Border { };
color: t.accent_color().into(), a
width: 1.0, })
radius: t.radius_s().into(), } else {
}; theme::Container::default()
a
}) })
.into()
} else { } else {
theme::Container::default() button_row.into()
}) };
.into()
} else {
button_row.into()
};
if item.selected || !drag_items.is_empty() { if item.selected || !drag_items.is_empty() {
let dnd_row = if !item.selected { let dnd_row = if !item.selected {
@ -3306,8 +3365,8 @@ impl Tab {
items items
.iter() .iter()
.filter(|item| item.selected) .filter(|item| item.selected)
.filter_map(|item| item.path_opt.clone()) .filter_map(|item| item.path_opt().map(|x| x.clone()))
.collect::<Vec<_>>() .collect::<Vec<PathBuf>>()
}) })
.unwrap_or_default(); .unwrap_or_default();
let item_view = DndSource::<_, cosmic::app::Message<app::Message>, ClipboardCopy>::with_id( let item_view = DndSource::<_, cosmic::app::Message<app::Message>, ClipboardCopy>::with_id(
@ -3336,7 +3395,8 @@ impl Tab {
.on_press(move |_point_opt| Message::Click(None)) .on_press(move |_point_opt| Message::Click(None))
.on_release(|_| Message::ClickRelease(None)) .on_release(|_| Message::ClickRelease(None))
.on_back_press(move |_point_opt| Message::GoPrevious) .on_back_press(move |_point_opt| Message::GoPrevious)
.on_forward_press(move |_point_opt| Message::GoNext); .on_forward_press(move |_point_opt| Message::GoNext)
.on_scroll(respond_to_scroll_direction);
if self.context_menu.is_some() { if self.context_menu.is_some() {
mouse_area = mouse_area.on_right_press(move |_point_opt| Message::ContextMenu(None)); mouse_area = mouse_area.on_right_press(move |_point_opt| Message::ContextMenu(None));
@ -3344,6 +3404,7 @@ impl Tab {
mouse_area = mouse_area.on_right_press(Message::ContextMenu); mouse_area = mouse_area.on_right_press(Message::ContextMenu);
} }
let should_propogate_events = true;
let mut popover = widget::popover(mouse_area); let mut popover = widget::popover(mouse_area);
if let Some(point) = self.context_menu { if let Some(point) = self.context_menu {
@ -3383,7 +3444,7 @@ impl Tab {
} }
} }
} }
Location::Networks => { Location::Network(uri, display_name) if uri == "network:///" => {
tab_column = tab_column.push( tab_column = tab_column.push(
widget::layer_container(widget::row::with_children(vec![ widget::layer_container(widget::row::with_children(vec![
widget::horizontal_space(Length::Fill).into(), widget::horizontal_space(Length::Fill).into(),
@ -3485,7 +3546,7 @@ impl Tab {
} }
} }
if let Some(path) = item.path_opt.clone() { if let Some(Location::Path(path)) = item.location_opt.clone() {
subscriptions.push(subscription::channel( subscriptions.push(subscription::channel(
path.clone(), path.clone(),
1, 1,
@ -3549,20 +3610,42 @@ impl Tab {
} }
} }
pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: Modifiers) -> Option<Message> {
if !modifiers.control() {
return None;
}
let delta_y = match delta {
ScrollDelta::Lines { y, .. } => y,
ScrollDelta::Pixels { y, .. } => y,
};
if delta_y > 0.0 {
return Some(Message::ZoomIn);
}
if delta_y < 0.0 {
return Some(Message::ZoomOut);
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{fs, io, path::PathBuf}; use std::{fs, io, path::PathBuf};
use cosmic::iced_runtime::keyboard::Modifiers; use cosmic::{iced::mouse::ScrollDelta, iced_runtime::keyboard::Modifiers};
use log::{debug, trace}; use log::{debug, trace};
use tempfile::TempDir; use tempfile::TempDir;
use test_log::test; use test_log::test;
use super::{scan_path, Location, Message, Tab}; use super::{respond_to_scroll_direction, scan_path, Location, Message, Tab};
use crate::{ use crate::{
app::test_utils::{ app::test_utils::{
assert_eq_tab_path, empty_fs, eq_path_item, filter_dirs, read_dir_sorted, simple_fs, assert_eq_tab_path, assert_zoom_affects_item_size, empty_fs, eq_path_item, filter_dirs,
tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, read_dir_sorted, simple_fs, tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN,
NUM_NESTED,
}, },
config::{IconSizes, TabConfig}, config::{IconSizes, TabConfig},
}; };
@ -3795,6 +3878,64 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn tab_zoom_in_increases_item_view_size() -> io::Result<()> {
let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
let path = fs.path();
let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default());
let should_affect_size = true;
assert_zoom_affects_item_size(&mut tab, Message::ZoomIn, should_affect_size);
Ok(())
}
fn tab_zoom_out_decreases_item_view_size() -> io::Result<()> {
let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
let path = fs.path();
let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default());
let should_affect_size = true;
assert_zoom_affects_item_size(&mut tab, Message::ZoomOut, should_affect_size);
Ok(())
}
#[test]
fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> {
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::CTRL);
assert!(!message_maybe.is_none());
assert!(matches!(message_maybe.unwrap(), Message::ZoomIn));
Ok(())
}
#[test]
fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::empty());
assert!(message_maybe.is_none());
Ok(())
}
#[test]
fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> {
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, Modifiers::CTRL);
assert!(!message_maybe.is_none());
assert!(matches!(message_maybe.unwrap(), Message::ZoomOut));
Ok(())
}
#[test]
fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
let message_maybe = respond_to_scroll_direction(
ScrollDelta::Pixels { x: 0.0, y: -1.0 },
Modifiers::empty(),
);
assert!(message_maybe.is_none());
Ok(())
}
#[test] #[test]
fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> { fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> {
let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
@ -3942,7 +4083,7 @@ impl<M> Widget<M, cosmic::Theme, cosmic::Renderer> for ArcElementWrapper<M> {
_clipboard: &mut dyn cosmic::iced_core::Clipboard, _clipboard: &mut dyn cosmic::iced_core::Clipboard,
_shell: &mut cosmic::iced_core::Shell<'_, M>, _shell: &mut cosmic::iced_core::Shell<'_, M>,
_viewport: &Rectangle, _viewport: &Rectangle,
) -> cosmic::iced_core::event::Status { ) -> event::Status {
self.0.lock().unwrap().as_widget_mut().on_event( self.0.lock().unwrap().as_widget_mut().on_event(
_state, _event, _layout, _cursor, _renderer, _clipboard, _shell, _viewport, _state, _event, _layout, _cursor, _renderer, _clipboard, _shell, _viewport,
) )