// Copyright (c) 2022-2025 Sophie Herold // Copyright (c) 2022 Christopher Davis // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later use std::cell::{OnceCell, RefCell}; use std::collections::{HashMap, VecDeque}; use std::sync::LazyLock; use std::time::Instant; use anyhow::Context; use gio::prelude::*; use gio::subclass::prelude::*; use glib::GString; use glib::subclass::Signal; use indexmap::IndexMap; use crate::deps::*; use crate::util; use crate::util::gettext::*; #[derive(Debug, Clone)] struct Entry { /// Determines the sort order sort: VecDeque, file: gio::File, } impl Entry { async fn new( file: gio::File, info: Option, collation_key_cache: &mut HashMap, ) -> Self { let mut sort = VecDeque::new(); let mut frac_file = file.clone(); // Use already available info for the filename as performance optimization let mut available_file_info = info.map(Ok); // Determine sort order for all path/uri segments one by one loop { let uri = frac_file.uri(); let key = if let Some(cached_key) = collation_key_cache.get(&uri) { cached_key.clone() } else { let file_info = if let Some(file_info) = available_file_info { file_info } else { frac_file .query_info_future( gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, gio::FileQueryInfoFlags::NONE, glib::Priority::LOW, ) .await }; match file_info { Ok(parent) => { let key = glib::FilenameCollationKey::from(parent.display_name()); // Using this cache since each file info query can take noticable time (1 ms // on local SMB). Only cache keys resulting from queries in this function // since these will be path segments that can be (and are likely to) be // repeated. collation_key_cache.insert(uri, key.clone()); key } Err(err) => { tracing::error!("Failed to obtain information for sorting '{uri}': {err}",); break; } } }; sort.push_front(key); if let Some(parent) = frac_file.parent() { frac_file = parent; available_file_info = None; } else { break; } } Self { sort, file } } } mod imp { use super::*; #[derive(Debug, Default)] pub struct LpFileModel { /// Use and IndexMap such that we can put the elements into an arbitrary /// order while still having fast hash access via file URI. pub(super) files: RefCell>, pub(super) directory: OnceCell, /// Track file changes. pub(super) monitor: OnceCell, } #[glib::object_subclass] impl ObjectSubclass for LpFileModel { const NAME: &'static str = "LpFileModel"; type Type = super::LpFileModel; } impl ObjectImpl for LpFileModel { fn signals() -> &'static [Signal] { static SIGNALS: LazyLock> = LazyLock::new(|| { vec![ Signal::builder("changed") .param_types([glib::Type::VARIANT]) .build(), ] }); SIGNALS.as_ref() } } } glib::wrapper! { pub struct LpFileModel(ObjectSubclass); } impl Default for LpFileModel { fn default() -> Self { glib::Object::new() } } impl LpFileModel { /// Create with a single element pub async fn from_file(file: gio::File) -> Self { Self::from_files(vec![file]).await } pub async fn from_files(files: Vec) -> Self { let model = Self::default(); let mut collation_key_cache = Default::default(); let mut stored_files: IndexMap = IndexMap::new(); for file in files { stored_files.insert( file.uri(), Entry::new(file, None, &mut collation_key_cache).await, ); } *model.imp().files.borrow_mut() = stored_files; model } /// Load all file from the given directory /// /// This can be used if there are already files present pub async fn load_directory(&self, directory: gio::File) -> anyhow::Result<()> { let instant = Instant::now(); self.imp().directory.set(directory.clone()).unwrap(); let monitor = directory.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE); match monitor { Ok(monitor) => { monitor.connect_changed(glib::clone!( #[weak(rename_to = obj)] self, move |_monitor, file_a, file_b, event| { obj.file_monitor_cb(event, file_a, file_b); } )); self.imp().monitor.set(monitor).unwrap(); } Err(err) => { tracing::info!("Cannot monitor directory: {err}"); } } let mut new_files = IndexMap::new(); let enumerator = directory .enumerate_children_future( &format!( "{},{},{},{}", gio::FILE_ATTRIBUTE_STANDARD_NAME, gio::FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE, gio::FILE_ATTRIBUTE_STANDARD_IS_HIDDEN, gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, ), gio::FileQueryInfoFlags::NONE, glib::Priority::LOW, ) .await .context(gettext("Could not list other files in directory."))?; let mut collation_key_cache = Default::default(); loop { let info = enumerator.next_files_future(1, glib::Priority::LOW).await; match info { Err(err) => { tracing::warn!("Unreadable entry in directory: {err}"); break; } Ok(mut info) => { if let Some(info) = info.pop() { // GVfs smb does not provide a CONTENT_TYPE if the content type is // ambiguous. This happens for png/apng. Since we // only need to know if something is probably an // image, we can use the FAST_CONTENT_TYPE in these cases. let content_type = info.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE); if let Some(content_type) = content_type.and_then(|x| gio::content_type_get_mime_type(&x)) { let hidden = if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_IS_HIDDEN) { info.is_hidden() } else { false }; // Filter out non-images types. The final decision if images are // supported/kept will later be done by glycin when starting to load the // images. Usually by inspecting the magic bytes. if content_type.starts_with("image/") && !hidden { let file = directory.child(info.name()); let entry = Entry::new(file, Some(info), &mut collation_key_cache).await; new_files.insert(entry.file.uri(), entry); } } } else { break; } } } } { // Here we use a nested scope so that the mutable borrow only lasts as long as // we need it let mut files = self.imp().files.borrow_mut(); for (uri, file) in files.iter() { new_files.insert(uri.clone(), file.clone()); } *files = new_files; // Then sort by name. Self::sort(&mut files); } tracing::debug!( "Completed loading list of files of '{}' after {:#?}", directory.uri(), instant.elapsed(), ); Ok(()) } pub fn directory(&self) -> Option { self.imp().directory.get().cloned() } pub fn index_of(&self, file: &gio::File) -> Option { self.imp().files.borrow().get_index_of(&file.uri()) } pub fn n_files(&self) -> usize { self.imp().files.borrow().len() } pub fn contains_file(&self, file: &gio::File) -> bool { self.imp().files.borrow().contains_key(&file.uri()) } pub fn before(&self, file: &gio::File) -> Option { let index = self.index_of(file)?; self.imp() .files .borrow() .get_index(index.checked_sub(1)?) .map(|x| &x.1.file) .cloned() } pub fn after(&self, file: &gio::File) -> Option { let index = self.index_of(file)?; self.imp() .files .borrow() .get_index(index.checked_add(1)?) .map(|x| &x.1.file) .cloned() } /// Returns `n` elements before and after given file pub fn files_around(&self, file: &gio::File, n: usize) -> IndexMap { let Some(index) = self.index_of(file) else { tracing::error!("URI not in model: {}", file.uri()); return IndexMap::new(); }; let reduce = n.saturating_sub(index); self.imp() .files .borrow() .iter() .skip(index.saturating_sub(n)) .take(2 * n + 1 - reduce) .map(|(k, v)| (k.clone(), v.file.clone())) .collect() } /// Return first file pub fn first(&self) -> Option { self.imp() .files .borrow() .first() .map(|x| &x.1.file) .cloned() } /// Returns last file pub fn last(&self) -> Option { self.imp().files.borrow().last().map(|x| &x.1.file).cloned() } pub fn remove(&self, file: &gio::File) -> Option { self.imp() .files .borrow_mut() .shift_remove(&file.uri()) .map(|x| x.file) } /// Currently sorts by name fn sort(files: &mut IndexMap) { files.sort_by(|_, x, _, y| x.sort.cmp(&y.sort)); } /// Determines if a file is added fn is_image(info: &gio::FileInfo) -> bool { info.content_type() .map(|t| t.to_string()) .filter(|t| t.starts_with("image/")) .is_some() } fn is_image_file(file: &gio::File) -> bool { let Ok(info) = util::query_attributes(file, vec![gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE]) else { tracing::warn!("Could not query file info: {}", file.uri()); return false; }; Self::is_image(&info) } /// Signal that notifies about added/removed files pub fn connect_changed(&self, f: impl Fn(&FileEvent) + 'static) { self.connect_local("changed", false, move |args| { if let Some(file_event) = args .get(1) .and_then(|x| x.get().ok()) .and_then(|x| FileEvent::from_variant(&x)) { f(&file_event); } else { tracing::error!("Failed to read arguments from 'changed' signal."); } None }); } pub fn emmit_changed(&self, file_event: &FileEvent) { self.emit_by_name::<()>("changed", &[&file_event.to_variant()]); } /// Insert a file async fn insert(&self, file: gio::File) -> bool { let obj = self.clone(); let entry = Entry::new(file.clone(), None, &mut Default::default()).await; let mut files = obj.imp().files.borrow_mut(); let changed = files.insert(file.uri(), entry).is_none(); if changed { Self::sort(&mut files); drop(files); } changed } fn file_monitor_cb( &self, event: gio::FileMonitorEvent, file_a: &gio::File, file_b: Option<&gio::File>, ) { let uri_a = file_a.uri(); match event { gio::FileMonitorEvent::Created | gio::FileMonitorEvent::MovedIn | gio::FileMonitorEvent::ChangesDoneHint if Self::is_image_file(file_a) => { // ^^^^ // Changing file content could theoretically make it an image // by adding a magic byte tracing::debug!("File added: {uri_a}"); glib::spawn_future_local(glib::clone!( #[strong(rename_to=obj)] self, #[strong] file_a, async move { let changed = obj.insert(file_a.clone()).await; if changed { obj.emmit_changed(&FileEvent::New(uri_a.to_string())); } } )); } gio::FileMonitorEvent::Deleted | gio::FileMonitorEvent::MovedOut | gio::FileMonitorEvent::Unmounted => { tracing::debug!("File removed: {}", file_a.uri()); let removed = self .imp() .files .borrow_mut() .shift_remove(&file_a.uri()) .is_some(); if removed { self.emmit_changed(&FileEvent::Removed(file_a.uri().to_string())); } } gio::FileMonitorEvent::Renamed => { if let Some(file_b) = file_b { { let uri_b = file_b.uri(); tracing::debug!("File moved from '{uri_a}' to '{uri_b}'"); let mut changed = self.imp().files.borrow_mut().shift_remove(&uri_a).is_some(); if Self::is_image_file(file_b) { glib::spawn_future_local(glib::clone!( #[strong(rename_to=obj)] self, #[strong] file_b, async move { changed |= obj.insert(file_b.clone()).await; if changed { obj.emmit_changed(&FileEvent::Moved( uri_a.to_string(), uri_b.to_string(), )); } } )); } } } } _ => {} } } } #[derive(Debug, glib::Variant)] pub enum FileEvent { New(String), Removed(String), Moved(String, String), }