diff --git a/src/mounter/gvfs.rs b/src/mounter/gvfs.rs index 1b7af76..b675dd0 100644 --- a/src/mounter/gvfs.rs +++ b/src/mounter/gvfs.rs @@ -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, 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, 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, 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) = { diff --git a/src/tab.rs b/src/tab.rs index 0da0db2..6de11e7 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -640,6 +640,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>> = + 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 @@ -647,7 +682,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); @@ -715,6 +750,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) @@ -766,23 +802,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 => { @@ -1009,7 +1029,6 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { // 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(), @@ -1818,6 +1837,8 @@ pub enum Message { HighlightDeactivate(usize), HighlightActivate(usize), DirectorySize(PathBuf, DirSize), + #[cfg(feature = "gvfs")] + DirectoryChildren(PathBuf, usize), Checksums(PathBuf, ChecksumState), CalculateChecksums(PathBuf), CopyChecksum(String), @@ -1886,6 +1907,7 @@ pub enum ItemMetadata { mtime: u64, size_opt: Option, children_opt: Option, + is_dir: bool, }, } @@ -1900,7 +1922,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, } } @@ -4827,6 +4849,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 @@ -4988,11 +5024,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); @@ -6170,17 +6210,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 { @@ -6946,6 +6990,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(&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