Before, if the target file was gone, files_around just returned an empty list of files, leading Loupe to think there are no images and showing the empty state view. This was frequently triggered by the "Replace original" function in editing, since the tmp file was already gone, when the view moved to it. Now, the correct error is shown, and scrolling to the other files is possible. Issue #585
511 lines
17 KiB
Rust
511 lines
17 KiB
Rust
// 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 <http://www.gnu.org/licenses/>.
|
|
//
|
|
// 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<glib::FilenameCollationKey>,
|
|
file: gio::File,
|
|
}
|
|
|
|
impl Entry {
|
|
async fn new(
|
|
file: gio::File,
|
|
info: Option<gio::FileInfo>,
|
|
collation_key_cache: &mut HashMap<glib::GString, glib::FilenameCollationKey>,
|
|
) -> 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<IndexMap<GString, Entry>>,
|
|
pub(super) directory: OnceCell<gio::File>,
|
|
/// Track file changes.
|
|
pub(super) monitor: OnceCell<gio::FileMonitor>,
|
|
}
|
|
|
|
#[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<Vec<Signal>> = LazyLock::new(|| {
|
|
vec![
|
|
Signal::builder("changed")
|
|
.param_types([glib::Type::VARIANT])
|
|
.build(),
|
|
]
|
|
});
|
|
SIGNALS.as_ref()
|
|
}
|
|
}
|
|
}
|
|
|
|
glib::wrapper! {
|
|
pub struct LpFileModel(ObjectSubclass<imp::LpFileModel>);
|
|
}
|
|
|
|
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<gio::File>) -> Self {
|
|
let model = Self::default();
|
|
let mut collation_key_cache = Default::default();
|
|
|
|
let mut stored_files: IndexMap<GString, Entry> = 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<gio::File> {
|
|
self.imp().directory.get().cloned()
|
|
}
|
|
|
|
pub fn index_of(&self, file: &gio::File) -> Option<usize> {
|
|
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<gio::File> {
|
|
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<gio::File> {
|
|
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,
|
|
) -> Result<IndexMap<GString, gio::File>, ()> {
|
|
let Some(index) = self.index_of(file) else {
|
|
// This can happen when the file is deleted the moment the view moves to it.
|
|
// This frequently happens when the "Replace original" function is used in
|
|
// editing.
|
|
tracing::info!("URI not in model: {}", file.uri());
|
|
return Err(());
|
|
};
|
|
|
|
let reduce = n.saturating_sub(index);
|
|
|
|
Ok(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<gio::File> {
|
|
self.imp()
|
|
.files
|
|
.borrow()
|
|
.first()
|
|
.map(|x| &x.1.file)
|
|
.cloned()
|
|
}
|
|
|
|
/// Returns last file
|
|
pub fn last(&self) -> Option<gio::File> {
|
|
self.imp().files.borrow().last().map(|x| &x.1.file).cloned()
|
|
}
|
|
|
|
pub fn remove(&self, file: &gio::File) -> Option<gio::File> {
|
|
self.imp()
|
|
.files
|
|
.borrow_mut()
|
|
.shift_remove(&file.uri())
|
|
.map(|x| x.file)
|
|
}
|
|
|
|
/// Currently sorts by name
|
|
fn sort(files: &mut IndexMap<GString, Entry>) {
|
|
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),
|
|
}
|