From 91b0864fce476ef1d9abeb342b48b7e5c0c21d46 Mon Sep 17 00:00:00 2001 From: emptyname Date: Tue, 18 Aug 2026 13:06:27 +0100 Subject: [PATCH 1/4] feat(thumbnails): generate cached previews for Drive files --- Cargo.lock | 1 + Cargo.toml | 1 + crates/pdfs-core/src/control.rs | 200 ++++++++- crates/pdfs-fuse/Cargo.toml | 1 + crates/pdfs-fuse/src/control.rs | 56 ++- crates/pdfs-fuse/src/lib.rs | 12 +- crates/pdfs-fuse/src/mount.rs | 6 + crates/pdfs-fuse/src/photos.rs | 728 ++++++++++++++++++++++++++++++-- crates/pdfs-fuse/src/sharing.rs | 1 + 9 files changed, 973 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ef147e..d399051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3578,6 +3578,7 @@ dependencies = [ name = "pdfs-fuse" version = "1.9.0" dependencies = [ + "base64", "fuser", "futures", "image", diff --git a/Cargo.toml b/Cargo.toml index 6da9058..61e5f22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22" thiserror = "2" anyhow = "1" # Non-poisoning mutexes. A handler that panics while holding a lock must not diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 5f1dd3a..f81601a 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -93,6 +93,26 @@ pub enum Request { /// already cached (one batched round-trip) and replying with their on-disk /// paths. Keep the batch small — it is served on demand, as tiles scroll in. PhotoThumbs { uids: Vec }, + /// Fetch thumbnails for ordinary Drive image files shown outside the Photos + /// timeline. The modification time is the cache validity tag, so replacing + /// an image can never reuse its previous revision's thumbnail. + FileThumbs { + items: Vec, + /// Identifies the front-end's current listing. Moving to another page or + /// folder advances it, allowing the daemon to stop work for rows that + /// are no longer visible. + #[serde(default)] + generation: u64, + }, + /// Stop ordinary-file thumbnail work belonging to older listings. Photos + /// timeline work and an explicit recursive build are deliberately separate. + CancelFileThumbs { generation: u64 }, + /// Start building local thumbnails for every supported image below `path`. + /// The request returns immediately; progress is read with + /// [`Request::ThumbnailBuildStatus`]. + StartThumbnailBuild { path: String }, + /// Read progress for the recursive ordinary-file thumbnail build. + ThumbnailBuildStatus, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, /// Add or remove Proton's `Favorite` tag on a photo. Replies with @@ -679,6 +699,10 @@ pub struct SharedItem { /// The shared node's decrypted name. pub name: String, pub is_dir: bool, + /// Modification time used to validate an image thumbnail. Older daemons + /// omit it, in which case clients use `0` as a conservative fallback tag. + #[serde(default)] + pub modified: i64, /// Mountpoint-relative path, when the daemon can resolve it (the node lives in /// my own tree). Empty when the path is unknown. #[serde(default)] @@ -1187,6 +1211,110 @@ pub struct PhotoThumb { pub pending: bool, } +/// One ordinary Drive file whose thumbnail a front-end wants to paint. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileThumbRequest { + /// Node uid in `volume~link` form. + pub uid: String, + /// File modification time, used as the thumbnail cache validity tag. + pub modified: i64, + /// Original Drive name. Local RAW-preview extraction uses the extension as a + /// format hint; defaulted for wire compatibility with older front-ends. + #[serde(default)] + pub name: String, +} + +/// Whether a Drive name is a raster image that the built-in decoder can read, +/// or a concrete camera RAW format whose embedded preview exiftool can extract. +/// Keep this list shared by the daemon and every GUI surface so a visible tile +/// is never advertised without a matching generation path. +pub fn is_thumbnail_image_name(name: &str) -> bool { + let Some((_, extension)) = name.rsplit_once('.') else { + return false; + }; + matches!( + extension.to_ascii_lowercase().as_str(), + "bmp" + | "gif" + | "jpeg" + | "jpg" + | "png" + | "tif" + | "tiff" + | "webp" + | "arw" + | "cr2" + | "cr3" + | "crw" + | "dng" + | "k25" + | "kdc" + | "mrw" + | "nef" + | "nrw" + | "orf" + | "pef" + | "raf" + | "rw" + | "rw2" + | "sr2" + | "srf" + | "x3f" + ) +} + +/// Whether `name` is one of the concrete RAW formats handled by the exiftool +/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose +/// preview availability is not dependable. +pub fn is_raw_image_name(name: &str) -> bool { + let Some((_, extension)) = name.rsplit_once('.') else { + return false; + }; + matches!( + extension.to_ascii_lowercase().as_str(), + "arw" + | "cr2" + | "cr3" + | "crw" + | "dng" + | "k25" + | "kdc" + | "mrw" + | "nef" + | "nrw" + | "orf" + | "pef" + | "raf" + | "rw" + | "rw2" + | "sr2" + | "srf" + | "x3f" + ) +} + +/// Progress of the one recursive ordinary-file thumbnail build the daemon may +/// run at a time. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ThumbnailBuildStatus { + pub running: bool, + /// True while folders are still being discovered, before the final image + /// count is known and the progress bar can become determinate. + pub scanning: bool, + /// Mountpoint-relative root selected when the build started. + pub path: String, + pub folders_scanned: u64, + pub images_found: u64, + /// Images already cached or processed during this build. Includes failures, + /// so `completed == images_found` still means the job has finished. + pub completed: u64, + /// Images that could not be downloaded or decoded. + pub failed: u64, + /// A traversal-level problem, such as a folder that could not be listed. + #[serde(default)] + pub message: Option, +} + /// A daemon too old to report connectivity is assumed online — it could not /// have mounted at all otherwise. fn default_online() -> bool { @@ -1339,6 +1467,8 @@ pub enum Response { PhotoMonths { months: Vec }, /// Thumbnails for a [`Request::PhotoThumbs`] batch. Thumbs { items: Vec }, + /// Current recursive thumbnail-build progress. + ThumbnailBuild { status: ThumbnailBuildStatus }, /// An on-disk path the front-end can open (e.g. a downloaded photo). FilePath { path: String }, /// Full-text search results (reply to [`Request::Search`]). @@ -1473,7 +1603,7 @@ impl ErrorKind { } /// Send one [`Request`] to the daemon listening on `socket` and read its -/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening. +/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening. /// /// Shared by the CLI and GUI so both speak the wire format identically. pub fn send(socket: &Path, req: &Request) -> Result { @@ -1632,6 +1762,74 @@ mod tests { } } + #[test] + fn file_thumbnail_requests_keep_their_revision_tags() { + let request = Request::FileThumbs { + items: vec![ + FileThumbRequest { + uid: "volume~first".into(), + modified: 1_700_000_001, + name: "first.jpg".into(), + }, + FileThumbRequest { + uid: "volume~second".into(), + modified: 1_700_000_002, + name: "second.nef".into(), + }, + ], + generation: 42, + }; + let line = serde_json::to_string(&request).unwrap(); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + + #[test] + fn legacy_file_thumbnail_requests_default_the_name() { + let decoded: Request = serde_json::from_str( + r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#, + ) + .unwrap(); + let Request::FileThumbs { items, generation } = decoded else { + panic!("decoded the wrong request variant"); + }; + assert_eq!(generation, 42); + assert_eq!(items.len(), 1); + assert!(items[0].name.is_empty()); + } + + #[test] + fn thumbnail_build_requests_and_progress_roundtrip() { + let requests = [ + Request::CancelFileThumbs { generation: 43 }, + Request::StartThumbnailBuild { + path: "pictures/events".into(), + }, + Request::ThumbnailBuildStatus, + ]; + for request in requests { + let line = serde_json::to_string(&request).unwrap(); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + + let response = Response::ThumbnailBuild { + status: ThumbnailBuildStatus { + running: true, + scanning: false, + path: "pictures/events".into(), + folders_scanned: 12, + images_found: 80, + completed: 25, + failed: 1, + message: None, + }, + }; + let line = serde_json::to_string(&response).unwrap(); + let decoded: Response = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + #[test] fn unified_search_request_and_response_roundtrip() { let request = Request::SearchV2 { diff --git a/crates/pdfs-fuse/Cargo.toml b/crates/pdfs-fuse/Cargo.toml index f4564d7..21a0e27 100644 --- a/crates/pdfs-fuse/Cargo.toml +++ b/crates/pdfs-fuse/Cargo.toml @@ -18,6 +18,7 @@ tracing.workspace = true fuser.workspace = true libc.workspace = true serde_json.workspace = true +base64.workspace = true image.workspace = true notify = "8" parking_lot.workspace = true diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 3425a69..445384e 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -11,7 +11,7 @@ use std::io::{BufRead, BufReader, Write as _}; use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor /// the mount root. fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult { let p = Path::new(path); - if p.is_absolute() { + let relative = if p.is_absolute() { p.strip_prefix(mountpoint) .map(Path::to_path_buf) .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) } else { Ok(p.to_path_buf()) + }?; + + if relative + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) + { + Ok(relative) + } else { + Err(CoreError::invalid(format!("{path} escapes the mountpoint"))) } } @@ -320,6 +329,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U items: core.photo_thumbs(&parsed), } } + Ok(CtlRequest::FileThumbs { items, generation }) => CtlResponse::Thumbs { + items: core.file_thumbs(&items, generation), + }, + Ok(CtlRequest::CancelFileThumbs { generation }) => { + core.cancel_file_thumbs(generation); + CtlResponse::Ok { + message: "thumbnail requests cancelled".to_string(), + } + } + Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => CtlResponse::ThumbnailBuild { + status: core.start_thumbnail_build(rel), + }, + Err(error) => CtlResponse::error(error), + }, + Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild { + status: core.thumbnail_build_status(), + }, Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) { Some(u) => match core.open_photo(&u) { Ok(p) => CtlResponse::FilePath { @@ -1221,4 +1248,29 @@ mod request_limit_tests { std::io::ErrorKind::InvalidData ); } + + #[test] + fn accepts_paths_inside_the_mountpoint() { + let mountpoint = Path::new("/mnt/proton"); + assert_eq!( + rel_to_mount(mountpoint, "Photos/2026").unwrap(), + Path::new("Photos/2026") + ); + assert_eq!( + rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(), + Path::new("Photos/2026") + ); + assert_eq!( + rel_to_mount(mountpoint, "/mnt/proton").unwrap(), + Path::new("") + ); + } + + #[test] + fn rejects_paths_that_escape_the_mountpoint() { + let mountpoint = Path::new("/mnt/proton"); + assert!(rel_to_mount(mountpoint, "../escape").is_err()); + assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err()); + assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err()); + } } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 8d6c580..f22b32e 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode}; use pdfs_core::control::{ ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, - TransferDirection, + ThumbnailBuildStatus, TransferDirection, }; use pdfs_core::db::{ Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, @@ -511,6 +511,16 @@ struct Core { /// those downloads is a full-size photo — so an in-flight uid is never started /// twice. thumb_gen: Arc>>, + /// Global permit pool for locally generated thumbnails. The old per-request + /// limit multiplied when the GUI sent several batches, allowing dozens of + /// full-size images to download at once. + thumb_gen_budget: Arc, + /// Current ordinary-file listing generation and a wake-up for tasks waiting + /// on a permit or a download when that listing is abandoned. + file_thumb_generation: Arc, + file_thumb_cancel: Arc, + /// Progress of the explicit recursive build started from the Files toolbar. + thumbnail_build: Arc>, /// Nodes the remote has told us have *no* thumbnail of a given type, keyed by /// `(uid, thumbnail type)` and holding the mtime the answer was learned at. /// diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index 0063216..c9f392e 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -389,6 +389,12 @@ pub fn mount( own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), self_changes: Arc::new(Mutex::new(HashMap::new())), thumb_gen: Arc::new(Mutex::new(HashSet::new())), + thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new( + super::photos::THUMB_GEN_CONCURRENCY, + )), + file_thumb_generation: Arc::new(AtomicU64::new(0)), + file_thumb_cancel: Arc::new(tokio::sync::Notify::new()), + thumbnail_build: Arc::new(Mutex::new(Default::default())), no_thumbnail: Arc::new(Mutex::new(HashMap::new())), quota: Arc::new(Mutex::new(None)), size_upgrades: Arc::new(Mutex::new(HashMap::new())), diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 2c320d2..7185be5 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -6,12 +6,22 @@ //! a phone) are generated locally and stored as if the server had served them. use std::collections::{HashMap, HashSet}; +use std::fs::OpenOptions; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::OpenOptionsExt as _; use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Once; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb}; +use base64::Engine as _; +use pdfs_core::control::{ + FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name, + is_thumbnail_image_name, +}; use pdfs_core::db::{self, StoredPhoto}; use pdfs_core::{CoreError, CoreResult}; -use std::sync::atomic::Ordering; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; @@ -31,7 +41,14 @@ const THUMB_QUALITY: u8 = 82; /// How many photos may be downloaded at once to generate their missing /// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise /// saturate the link and starve the rest of the daemon. -const THUMB_GEN_CONCURRENCY: usize = 4; +pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4; +/// Images processed between recursive-build progress updates. +const THUMB_BUILD_CHUNK: usize = 16; +/// A broken metadata helper must not hold one of the four thumbnail permits +/// indefinitely. Matches the deliberately generous KDE RAW thumbnail budget. +const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); +static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); +static EXIFTOOL_MISSING_WARNING: Once = Once::new(); /// The aspect ratio (w/h) of an encoded image, read from its header alone — no /// pixels are decoded. `None` when the format is unknown or the header is torn. @@ -60,6 +77,36 @@ enum ThumbAttempt { Undecodable, /// The photo couldn't be downloaded. Transient: try again next time. Unavailable, + /// Its ordinary-file listing was left while this work was queued or running. + Cancelled, +} + +/// Why a local thumbnail is being generated. Only opportunistic thumbnails for +/// ordinary file listings are cancellable on navigation; Photos and an explicit +/// recursive build are intentional work with their own lifecycle. +#[derive(Clone, Copy)] +enum ThumbJob { + Photos, + Files(u64), + Build, +} + +#[derive(Default)] +struct ThumbBatchSummary { + made: u64, + undecodable: u64, + unavailable: u64, + cancelled: u64, +} + +impl ThumbBatchSummary { + fn processed(&self) -> u64 { + self.made + self.undecodable + self.unavailable + self.cancelled + } + + fn failed(&self) -> u64 { + self.undecodable + self.unavailable + } } /// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its @@ -68,12 +115,27 @@ enum ThumbAttempt { /// un-thumbnailable. /// /// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. -fn scale_thumbnail(bytes: &[u8]) -> Option { - let image = image::ImageReader::new(std::io::Cursor::new(bytes)) +fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { + let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() - .ok()? - .decode() - .ok()?; + .ok() + .and_then(|reader| reader.decode().ok()); + let image = match direct { + Some(image) => image, + None if is_raw_image_name(name) => { + let (preview, orientation) = extract_raw_preview(bytes, name)?; + let mut image = image::ImageReader::new(std::io::Cursor::new(preview)) + .with_guessed_format() + .ok()? + .decode() + .ok()?; + if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) { + image.apply_orientation(orientation); + } + image + } + None => return None, + }; let (width, height) = (image.width(), image.height()); if width == 0 || height == 0 { return None; @@ -90,6 +152,168 @@ fn scale_thumbnail(bytes: &[u8]) -> Option { Some(GeneratedThumb { bytes, ratio }) } +/// Temporary file passed to exiftool. The helper identifies most RAWs from their +/// magic bytes, while preserving a safe extension also covers formats whose +/// detector uses the original name as a hint. +struct RawTempFile(PathBuf); + +impl RawTempFile { + fn create(bytes: &[u8], name: &str) -> Option { + let extension = std::path::Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| { + !value.is_empty() + && value.len() <= 10 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }) + .unwrap_or("raw"); + let started = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for _ in 0..16 { + let nonce = RAW_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "pdfs-raw-{}-{started}-{nonce}.{extension}", + std::process::id(), + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return Some(Self(path)); + } + let _ = std::fs::remove_file(path); + return None; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return None, + } + } + None + } +} + +impl Drop for RawTempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// Run one bounded exiftool JSON query while draining stdout concurrently. The +/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a +/// pipe buffer long before the child exits. +fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option { + let mut command = Command::new("exiftool"); + command.args(["-j", "-b", "-n"]); + command.args(tags); + command + .arg(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + if error.kind() == std::io::ErrorKind::NotFound { + EXIFTOOL_MISSING_WARNING.call_once(|| { + warn!( + "exiftool is not installed; camera RAW thumbnails are unavailable \ + (Debian/Ubuntu: libimage-exiftool-perl)" + ); + }); + } + return None; + } + }; + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let started = Instant::now(); + let success = loop { + match child.try_wait() { + Ok(Some(status)) => break status.success(), + Ok(None) if started.elapsed() < EXIFTOOL_TIMEOUT => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break false; + } + } + }; + let output = reader.join().ok()?.ok()?; + if !success { + return None; + } + serde_json::from_slice(&output).ok() +} + +fn exiftool_object( + value: &serde_json::Value, +) -> Option<&serde_json::Map> { + value.as_array()?.first()?.as_object() +} + +fn exiftool_binary( + object: &serde_json::Map, + key: &str, +) -> Option> { + let encoded = object.get(key)?.as_str()?.strip_prefix("base64:")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + (!decoded.is_empty()).then_some(decoded) +} + +/// Extract the embedded display preview from one concrete camera RAW. PreviewImage +/// is queried alone first so common files do not also serialize a discarded +/// full-resolution JpgFromRaw; rarer preview tags are requested only on a miss. +fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec, u8)> { + let file = RawTempFile::create(bytes, name)?; + let primary = exiftool_query(&file.0, &["-Orientation", "-PreviewImage"])?; + let primary = exiftool_object(&primary)?; + let orientation = primary + .get("Orientation") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + .unwrap_or(1); + if let Some(preview) = exiftool_binary(primary, "PreviewImage") { + return Some((preview, orientation)); + } + + const FALLBACKS: [&str; 5] = [ + "OtherImage", + "JpgFromRaw", + "PreviewTIFF", + "ThumbnailImage", + "ThumbnailTIFF", + ]; + let fallback = exiftool_query( + &file.0, + &[ + "-OtherImage", + "-JpgFromRaw", + "-PreviewTIFF", + "-ThumbnailImage", + "-ThumbnailTIFF", + ], + )?; + let fallback = exiftool_object(&fallback)?; + FALLBACKS + .iter() + .find_map(|key| exiftool_binary(fallback, key)) + .map(|preview| (preview, orientation)) +} + +/// Formats supported by the local thumbnail decoder and exposed by the GUI. impl Core { pub(crate) fn photos_timeline( &self, @@ -206,6 +430,10 @@ impl Core { .iter() .map(|p| (p.uid.clone(), p.capture_time)) .collect(); + let names: HashMap = stored + .iter() + .filter_map(|photo| photo.name.clone().map(|name| (photo.uid.clone(), name))) + .collect(); // Ask the server only for photos that are missing a cached thumbnail and // haven't already been written off as un-thumbnailable. @@ -260,7 +488,7 @@ impl Core { // takes far longer to fetch than the whole rest of the batch, and the // thumbnails that *are* ready must not wait behind it. if !missing.is_empty() { - self.spawn_generate_thumbs(missing, &tags); + self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos); } } @@ -278,10 +506,101 @@ impl Core { .collect() } + /// Thumbnails for image files shown by the regular file browser, Shared and + /// Trash pages. Unlike [`Core::photo_thumbs`], these nodes need not be in the + /// Photos timeline: the request supplies each file's modification time as + /// the cache validity tag. + pub(crate) fn file_thumbs( + &self, + items: &[FileThumbRequest], + generation: u64, + ) -> Vec { + let previous = self + .file_thumb_generation + .fetch_max(generation, Ordering::SeqCst); + if generation > previous { + self.file_thumb_cancel.notify_waiters(); + } + let current = self.file_thumb_generation.load(Ordering::SeqCst) == generation; + let ttype = ThumbnailType::Thumbnail.as_i32(); + let parsed: Vec<(String, NodeUid, i64)> = items + .iter() + .filter_map(|item| { + parse_uid(&item.uid).map(|uid| (item.uid.clone(), uid, item.modified)) + }) + .collect(); + let tags: HashMap = parsed + .iter() + .map(|(raw, _, modified)| (raw.clone(), *modified)) + .collect(); + let names: HashMap = items + .iter() + .map(|item| (item.uid.clone(), item.name.clone())) + .collect(); + + // Proton only stores server-generated thumbnails for nodes in Photos. + // These requests come exclusively from ordinary Drive, Shared and Trash + // views, so downloading a remote thumbnail here would be a guaranteed + // miss. Generate directly from the full image and cache the result. + let mut wanted = Vec::new(); + let mut seen = HashSet::new(); + for (raw, uid, modified) in &parsed { + if self + .cache + .cached_thumbnail_path(uid, ttype, *modified) + .is_some() + || self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) + || !seen.insert(raw.clone()) + { + continue; + } + wanted.push(uid.clone()); + } + + if current && !wanted.is_empty() { + self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation)); + } + + let pending = self.thumb_gen.lock(); + items + .iter() + .map(|item| { + let uid = parse_uid(&item.uid); + PhotoThumb { + uid: item.uid.clone(), + path: uid.as_ref().and_then(|uid| { + self.cache + .cached_thumbnail_path(uid, ttype, item.modified) + .map(|path| path.display().to_string()) + }), + pending: current && uid.as_ref().is_some_and(|uid| pending.contains(uid)), + } + }) + .collect() + } + + /// Advance the ordinary-file thumbnail generation. Every queued/download in + /// an older listing observes the new value and exits; Photos and explicit + /// recursive builds do not use this generation and continue normally. + pub(crate) fn cancel_file_thumbs(&self, generation: u64) { + let previous = self + .file_thumb_generation + .fetch_max(generation, Ordering::SeqCst); + if generation > previous { + self.file_thumb_cancel.notify_waiters(); + } + } + /// Generate the missing thumbnails on the runtime, skipping any photo already /// being generated. The uids are marked in-flight before the task starts, so /// the reply this call is about to send already reports them as pending. - pub(crate) fn spawn_generate_thumbs(&self, uids: Vec, tags: &HashMap) { + fn spawn_generate_thumbs( + &self, + uids: Vec, + tags: &HashMap, + names: &HashMap, + job: ThumbJob, + ) { let fresh: Vec = { let mut inflight = self.thumb_gen.lock(); uids.into_iter() @@ -294,14 +613,21 @@ impl Core { let core = self.clone(); let tags = tags.clone(); + let names = names.clone(); // `generate_thumbs` blocks on the runtime itself, so it belongs on the // blocking pool rather than on an async worker. self.rt.spawn_blocking(move || { - core.generate_thumbs(&fresh, &tags); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + core.generate_thumbs(&fresh, &tags, &names, job) + })); let mut inflight = core.thumb_gen.lock(); for uid in &fresh { inflight.remove(uid); } + drop(inflight); + if result.is_err() { + warn!("thumbnail generation worker panicked"); + } }); } @@ -313,31 +639,100 @@ impl Core { /// screenful of them at once would saturate the link. A photo whose bytes /// can't be decoded (a codec `image` doesn't speak) is marked /// [`db::THUMB_NONE`] and never attempted again. - pub(crate) fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap) { - info!( - count = uids.len(), - "generating thumbnails the server has none for" - ); + fn thumb_job_current(&self, job: ThumbJob) -> bool { + match job { + ThumbJob::Files(generation) => { + self.file_thumb_generation.load(Ordering::SeqCst) == generation + } + ThumbJob::Photos | ThumbJob::Build => true, + } + } + + fn generate_thumbs( + &self, + uids: &[NodeUid], + tags: &HashMap, + names: &HashMap, + job: ThumbJob, + ) -> ThumbBatchSummary { + info!(count = uids.len(), "generating local thumbnails"); let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async { let mut out = Vec::with_capacity(uids.len()); for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) { let mut set = tokio::task::JoinSet::new(); for uid in chunk { + let core = self.clone(); let client = self.client.clone(); let uid = uid.clone(); + let name = names.get(&uid.to_string()).cloned().unwrap_or_default(); set.spawn(async move { - let bytes = match client.download_file(&uid).await { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + + // One shared permit pool bounds all thumbnail batches, + // rather than multiplying the limit by every control + // request the GUI happens to have in flight. + let acquire = core.thumb_gen_budget.clone().acquire_owned(); + tokio::pin!(acquire); + let _permit = match job { + ThumbJob::Files(_) => loop { + tokio::select! { + permit = &mut acquire => break permit.ok(), + _ = core.file_thumb_cancel.notified() => { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + } + } + }, + ThumbJob::Photos | ThumbJob::Build => acquire.await.ok(), + }; + let Some(_permit) = _permit else { + return (uid, ThumbAttempt::Unavailable); + }; + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + + let download_uid = uid.clone(); + let download = async move { client.download_file(&download_uid).await }; + tokio::pin!(download); + let downloaded = match job { + ThumbJob::Files(_) => loop { + tokio::select! { + result = &mut download => break Some(result), + _ = core.file_thumb_cancel.notified() => { + if !core.thumb_job_current(job) { + break None; + } + } + } + }, + ThumbJob::Photos | ThumbJob::Build => Some(download.await), + }; + let Some(downloaded) = downloaded else { + return (uid, ThumbAttempt::Cancelled); + }; + let bytes = match downloaded { Ok(bytes) => bytes, Err(e) => { - warn!(%uid, error = %e, "photo download for thumbnail failed"); + warn!(%uid, error = %e, "image download for thumbnail failed"); return (uid, ThumbAttempt::Unavailable); } }; + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } // Decoding + scaling a 20 MP JPEG is CPU-bound and would // stall the runtime's worker; hand it to the blocking pool. - let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes)) - .await - .unwrap_or(None); + let made = + tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name)) + .await + .unwrap_or(None); + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } match made { Some(thumb) => (uid, ThumbAttempt::Made(thumb)), None => (uid, ThumbAttempt::Undecodable), @@ -354,26 +749,222 @@ impl Core { }); let ttype = ThumbnailType::Thumbnail.as_i32(); + let mut summary = ThumbBatchSummary::default(); for (uid, attempt) in results { match attempt { ThumbAttempt::Made(thumb) => { let Some(&tag) = tags.get(&uid.to_string()) else { + summary.unavailable += 1; continue; }; match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { - Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)), - Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"), + Ok(()) => { + summary.made += 1; + self.no_thumbnail.lock().remove(&(uid.clone(), ttype)); + self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)); + } + Err(e) => { + summary.unavailable += 1; + warn!(%uid, error = %e, "storing generated thumbnail failed"); + } } } // The photo's own bytes aren't an image we can read: no thumbnail // will ever exist for it, so stop trying. - ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None), + ThumbAttempt::Undecodable => { + summary.undecodable += 1; + if let Some(&tag) = tags.get(&uid.to_string()) { + self.no_thumbnail.lock().insert((uid.clone(), ttype), tag); + } + self.record_thumb(&uid, db::THUMB_NONE, None); + } // The download failed — a dropped connection, an expired link. That // is not a verdict on the photo: leave its state alone so the next // scroll past it tries again. - ThumbAttempt::Unavailable => {} + ThumbAttempt::Unavailable => summary.unavailable += 1, + ThumbAttempt::Cancelled => summary.cancelled += 1, } } + summary + } + + pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus { + self.thumbnail_build.lock().clone() + } + + /// Start one recursive local-thumbnail build. A second request while it is + /// running attaches to the existing job instead of duplicating every + /// download; the caller polls [`Core::thumbnail_build_status`]. + pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> ThumbnailBuildStatus { + let path = root.to_string_lossy().into_owned(); + let status = { + let mut status = self.thumbnail_build.lock(); + if status.running { + return status.clone(); + } + *status = ThumbnailBuildStatus { + running: true, + scanning: true, + path, + folders_scanned: 0, + images_found: 0, + completed: 0, + failed: 0, + message: None, + }; + status.clone() + }; + + let core = self.clone(); + self.rt.spawn_blocking(move || { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + core.run_thumbnail_build(root) + })) + .is_err() + { + let mut status = core.thumbnail_build.lock(); + status.running = false; + status.scanning = false; + status.message = Some("Thumbnail generation stopped unexpectedly".to_string()); + warn!(path = %status.path, "recursive thumbnail build panicked"); + } + }); + status + } + + fn run_thumbnail_build(&self, root: PathBuf) { + let mut folders = vec![root]; + let mut images: Vec<(NodeUid, i64, String)> = Vec::new(); + let mut seen = HashSet::new(); + + while let Some(folder) = folders.pop() { + let listing = self.list_dir(&folder); + { + let mut status = self.thumbnail_build.lock(); + status.folders_scanned += 1; + } + let entries = match listing { + Ok(entries) => entries, + Err(error) => { + let mut status = self.thumbnail_build.lock(); + if status.message.is_none() { + status.message = Some(format!( + "Some folders could not be read ({}: {error})", + folder.display() + )); + } + continue; + } + }; + + let mut found_here = 0_u64; + let mut invalid_here = 0_u64; + for entry in entries { + if entry.is_dir { + if seen.insert(entry.uid) { + folders.push(folder.join(entry.name)); + } + } else if is_thumbnail_image_name(&entry.name) { + found_here += 1; + match parse_uid(&entry.uid) { + Some(uid) => images.push((uid, entry.modified, entry.name)), + None => invalid_here += 1, + } + } + } + let mut status = self.thumbnail_build.lock(); + status.images_found += found_here; + status.completed += invalid_here; + status.failed += invalid_here; + } + + self.thumbnail_build.lock().scanning = false; + let ttype = ThumbnailType::Thumbnail.as_i32(); + let mut todo = Vec::new(); + for (uid, modified, name) in images { + if self + .cache + .cached_thumbnail_path(&uid, ttype, modified) + .is_some() + { + self.thumbnail_build.lock().completed += 1; + } else if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(&modified) { + let mut status = self.thumbnail_build.lock(); + status.completed += 1; + status.failed += 1; + } else { + todo.push((uid, modified, name)); + } + } + + for chunk in todo.chunks(THUMB_BUILD_CHUNK) { + let mut claimed = Vec::with_capacity(chunk.len()); + let mut tags = HashMap::with_capacity(chunk.len()); + let mut names = HashMap::with_capacity(chunk.len()); + for (uid, modified, name) in chunk { + // A visible tile may have started this image just before the + // toolbar action. Wait for that one bounded job to finish rather + // than downloading the full image twice. + loop { + if self + .cache + .cached_thumbnail_path(uid, ttype, *modified) + .is_some() + { + self.thumbnail_build.lock().completed += 1; + break; + } + if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) { + let mut status = self.thumbnail_build.lock(); + status.completed += 1; + status.failed += 1; + break; + } + if self.thumb_gen.lock().insert(uid.clone()) { + tags.insert(uid.to_string(), *modified); + names.insert(uid.to_string(), name.clone()); + claimed.push(uid.clone()); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + + if claimed.is_empty() { + continue; + } + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.generate_thumbs(&claimed, &tags, &names, ThumbJob::Build) + })); + { + let mut inflight = self.thumb_gen.lock(); + for uid in &claimed { + inflight.remove(uid); + } + } + let mut status = self.thumbnail_build.lock(); + match result { + Ok(summary) => { + status.completed += summary.processed(); + status.failed += summary.failed(); + } + Err(_) => { + status.completed += claimed.len() as u64; + status.failed += claimed.len() as u64; + status.message = Some("Some thumbnail workers stopped unexpectedly".into()); + } + } + } + + let mut status = self.thumbnail_build.lock(); + status.running = false; + status.scanning = false; + info!( + path = %status.path, + images = status.images_found, + failed = status.failed, + "recursive thumbnail build finished" + ); } /// Persist what a thumbnail attempt learned about a photo, against the @@ -501,7 +1092,9 @@ impl Core { #[cfg(test)] mod thumb_tests { - use super::{THUMB_EDGE, ratio_of, scale_thumbnail}; + use super::{RawTempFile, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail}; + use pdfs_core::control::is_thumbnail_image_name; + use std::os::unix::fs::PermissionsExt as _; /// A `width`×`height` JPEG, standing in for a camera photo the server never /// generated a thumbnail for. @@ -519,7 +1112,7 @@ mod thumb_tests { #[test] fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() { let photo = jpeg(4000, 3000); - let thumb = scale_thumbnail(&photo).expect("a JPEG scales"); + let thumb = scale_thumbnail(&photo, "photo.jpg").expect("a JPEG scales"); let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) .with_guessed_format() @@ -537,7 +1130,7 @@ mod thumb_tests { #[test] fn a_portrait_photo_fits_its_long_edge_too() { - let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales"); + let thumb = scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg").expect("a JPEG scales"); assert!(thumb.ratio < 1.0, "portrait stays portrait"); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); } @@ -546,7 +1139,7 @@ mod thumb_tests { fn undecodable_bytes_are_not_a_thumbnail() { // What a photo in a format this build has no decoder for looks like: the // caller writes it off as un-thumbnailable rather than retrying forever. - assert!(scale_thumbnail(b"not an image at all").is_none()); + assert!(scale_thumbnail(b"not an image at all", "broken.jpg").is_none()); assert!(ratio_of(b"not an image at all").is_none()); } @@ -554,4 +1147,81 @@ mod thumb_tests { fn ratio_is_read_from_the_header_alone() { assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5)); } + + #[test] + fn raw_staging_file_is_private_and_removed_on_drop() { + let file = + RawTempFile::create(b"private camera bytes", "shot.NEF").expect("temporary RAW file"); + let path = file.0.clone(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + assert_eq!( + path.extension().and_then(|value| value.to_str()), + Some("NEF") + ); + drop(file); + assert!(!path.exists()); + } + + /// Optional real-file regression harness. RAW fixtures are too large to keep + /// in the repository; developers can point this at any supported camera file. + #[test] + #[ignore = "set PDFS_RAW_FIXTURE to a supported camera RAW file"] + fn real_raw_fixture_extracts_and_scales() { + let path = std::env::var_os("PDFS_RAW_FIXTURE").expect("PDFS_RAW_FIXTURE"); + let path = std::path::PathBuf::from(path); + let name = path.file_name().unwrap().to_string_lossy(); + let bytes = std::fs::read(&path).unwrap(); + let thumb = scale_thumbnail(&bytes, &name).expect("embedded RAW preview"); + let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) + .with_guessed_format() + .unwrap() + .into_dimensions() + .unwrap(); + assert!(width <= THUMB_EDGE && height <= THUMB_EDGE); + assert!(width > 0 && height > 0); + } + + #[test] + fn recursive_build_recognises_the_same_image_names_as_the_gui() { + for name in [ + "photo.jpg", + "SCAN.PNG", + "animation.Gif", + "archive.tiff", + "camera.NEF", + "negative.cr3", + "sensor.orf", + ] { + assert!(is_thumbnail_image_name(name), "{name}"); + } + for name in [ + "notes.txt", + "movie.mp4", + "image.jpg.zip", + "jpg", + "unsupported.heic", + "unsupported.avif", + "vector.svg", + ] { + assert!(!is_thumbnail_image_name(name), "{name}"); + } + } + + #[test] + fn exiftool_binary_accepts_only_prefixed_base64() { + let value = serde_json::json!({ + "PreviewImage": "base64:aGVsbG8=", + "OtherImage": "not-binary", + "ThumbnailImage": "base64:" + }); + let object = value.as_object().unwrap(); + assert_eq!( + exiftool_binary(object, "PreviewImage"), + Some(b"hello".to_vec()) + ); + assert_eq!(exiftool_binary(object, "OtherImage"), None); + assert_eq!(exiftool_binary(object, "ThumbnailImage"), None); + assert_eq!(exiftool_binary(object, "Missing"), None); + } } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 76304b6..37b9307 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -609,6 +609,7 @@ impl Core { items.push(SharedItem { uid: uid.to_string(), is_dir: n.is_folder(), + modified: n.modification_time, name: n.name, path: self.rel_path_for_uid(&uid).unwrap_or_default(), member_count: members, From b0f95139155d564f5191561046610c9ad6accd3c Mon Sep 17 00:00:00 2001 From: emptyname Date: Tue, 18 Aug 2026 13:06:36 +0100 Subject: [PATCH 2/4] feat(gui): show file thumbnails and browser status bar --- crates/pdfs-gui/build.rs | 3 + .../pdfs-build-thumbnails-symbolic.svg | 5 + crates/pdfs-gui/resources/pdfs.gresource.xml | 1 + crates/pdfs-gui/src/app/main.rs | 34 +- crates/pdfs-gui/src/app/pages/browser.rs | 543 ++++++++++++++++-- crates/pdfs-gui/src/app/pages/shared.rs | 7 +- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 11 +- crates/pdfs-gui/src/app/pages/status.rs | 123 +++- crates/pdfs-gui/src/app/pages/trash.rs | 52 +- crates/pdfs-gui/src/app/widgets/mod.rs | 1 + crates/pdfs-gui/src/app/widgets/thumbnails.rs | 387 +++++++++++++ 11 files changed, 1053 insertions(+), 114 deletions(-) create mode 100644 crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg create mode 100644 crates/pdfs-gui/src/app/widgets/thumbnails.rs diff --git a/crates/pdfs-gui/build.rs b/crates/pdfs-gui/build.rs index 6953c08..58a5198 100644 --- a/crates/pdfs-gui/build.rs +++ b/crates/pdfs-gui/build.rs @@ -22,6 +22,9 @@ fn main() { } println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); + println!( + "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg" + ); println!( "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg" ); diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg new file mode 100644 index 0000000..65ccdeb --- /dev/null +++ b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg @@ -0,0 +1,5 @@ + + + + diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index a84f6ea..cc21020 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -4,6 +4,7 @@ icon-theme resource path at startup, so they resolve by name (and recolour with the theme) exactly like a stock symbolic icon. --> + scalable/actions/pdfs-build-thumbnails-symbolic.svg scalable/actions/pdfs-folder-upload-symbolic.svg diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index b2545a9..97c942d 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -19,6 +19,7 @@ use pages::trash::*; use pages::verify::*; use widgets::details::*; use widgets::share_dialog::*; +use widgets::thumbnails::*; use widgets::versions_dialog::*; use std::cell::{Cell, RefCell}; @@ -49,8 +50,8 @@ use pdfs_core::control::{ ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, - SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, - pending_summary, send, + SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, + TransferItem, pending_summary, send, }; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; @@ -107,6 +108,10 @@ struct Ui { /// The sidebar/content split. Collapsed while signed out, so the login page /// owns the whole window and no destination is reachable without a session. nav: adw::NavigationSplitView, + /// Shared thumbnails for ordinary image files outside the Photos gallery. + /// One cache and request queue serves Files, search, Shared and Trash, so + /// the same image is downloaded and decoded only once. + pub(crate) file_thumbs: FileThumbnailState, // Per-page state. Each page module owns its own struct; `Ui` keeps only // what more than one page genuinely shares. @@ -207,10 +212,15 @@ fn load_proton_theme() { .file-grid {{ padding: 6px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\ + .file-thumbnail {{ border-radius: 7px; }}\n\ .file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\ .badge-pinned {{ color: #f5c211; }}\n\ .badge-cached {{ color: #2ec27e; }}\n\ .badge-cloud {{ color: #9aa0a6; }}\n\ + .browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\ + scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\ + scale.browser-status-meter trough {{ min-height: 6px; }}\n\ + progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\ .photo-viewer-window {{ background-color: #111014; }}\n\ .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\ .viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ @@ -345,6 +355,7 @@ fn build_window(app: &adw::Application) { mounted: RefCell::new(false), sidebar: sidebar_list.clone(), nav: split.clone(), + file_thumbs: FileThumbnailState::new(), login: LoginState { email: login_widgets.0, password: login_widgets.1, @@ -390,7 +401,14 @@ fn build_window(app: &adw::Application) { new_folder: browser_widgets.new_folder.clone(), upload: browser_widgets.upload.clone(), upload_folder: browser_widgets.upload_folder.clone(), + build_thumbnails: browser_widgets.build_thumbnails.clone(), + thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(), + thumbnail_progress: browser_widgets.thumbnail_progress.clone(), + thumbnail_status: browser_widgets.thumbnail_status.clone(), + thumbnail_poll: RefCell::new(None), + thumbnail_build_running: Cell::new(false), search_source: RefCell::new(None), + load_generation: Cell::new(0), views: browser_widgets.views.clone(), bulk: browser_widgets.bulk.clone(), bulk_label: browser_widgets.bulk_label.clone(), @@ -398,6 +416,13 @@ fn build_window(app: &adw::Application) { bulk_pin: browser_widgets.bulk_pin.clone(), bulk_unpin: browser_widgets.bulk_unpin.clone(), empty_actions: browser_widgets.empty_actions.clone(), + summary: browser_widgets.summary.clone(), + zoom: browser_widgets.zoom.clone(), + grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT), + grid_tiles: RefCell::new(Vec::new()), + quota_box: browser_widgets.quota_box.clone(), + quota: browser_widgets.quota.clone(), + quota_text: browser_widgets.quota_text.clone(), }, details: DetailsState { details: browser_widgets.details, @@ -552,6 +577,7 @@ fn build_window(app: &adw::Application) { &browser_widgets.new_folder, &browser_widgets.upload, &browser_widgets.upload_folder, + &browser_widgets.build_thumbnails, ); wire_details(&ui); wire_search(&ui); @@ -588,6 +614,10 @@ fn build_window(app: &adw::Application) { // network round-trip only happens on demand rather than on every refresh. let ui_nav = ui.clone(); stack.connect_visible_child_name_notify(move |st| { + // Rows from the page being left must not keep full-size image downloads + // alive in the daemon. The page being entered establishes a fresh + // thumbnail generation as it paints. + cancel_file_thumbnails(&ui_nav); sync_sidebar(&ui_nav); match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index e01cecc..03a675f 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -28,9 +28,20 @@ pub(crate) struct BrowserState { pub(crate) new_folder: gtk4::Button, pub(crate) upload: gtk4::Button, pub(crate) upload_folder: gtk4::Button, + /// Starts a daemon-side recursive local-thumbnail build for [`Self::path`]. + pub(crate) build_thumbnails: gtk4::Button, + pub(crate) thumbnail_build_row: gtk4::Box, + pub(crate) thumbnail_progress: gtk4::ProgressBar, + pub(crate) thumbnail_status: gtk4::Label, + pub(crate) thumbnail_poll: RefCell>, + pub(crate) thumbnail_build_running: Cell, /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, + /// Identity of the newest folder/search request. Paths and queries can be + /// requested repeatedly, so comparing their text alone cannot reject an + /// older response that finishes after a manual refresh. + pub(crate) load_generation: Cell, /// The grid/list view stack, read to find out which of the two selections is /// the one the user is actually working in. pub(crate) views: gtk4::Stack, @@ -46,6 +57,17 @@ pub(crate) struct BrowserState { /// state is a place to act rather than a dead end. Hidden on every other /// status (a load error is not the moment to offer an upload). pub(crate) empty_actions: gtk4::Box, + /// Dolphin-style bottom status bar: current listing counts, the icon-grid + /// zoom value, and Proton account storage usage. + pub(crate) summary: gtk4::Label, + pub(crate) zoom: gtk4::Scale, + pub(crate) grid_thumbnail_size: Cell, + /// Weak references to realised grid cells. Zoom resizes only these visible, + /// recycled surfaces instead of invalidating the whole list model. + pub(crate) grid_tiles: RefCell, glib::WeakRef)>>, + pub(crate) quota_box: gtk4::Box, + pub(crate) quota: gtk4::ProgressBar, + pub(crate) quota_text: gtk4::Label, } /// Idle pause after the last keystroke before a search query is sent, so typing @@ -55,6 +77,13 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); /// Cap on search hits requested from the daemon. pub(crate) const SEARCH_LIMIT: usize = 200; +/// Browser grid thumbnail size controlled by the bottom zoom slider. The +/// default preserves the former fixed 72 px presentation. +pub(crate) const GRID_THUMB_MIN: i32 = 48; +pub(crate) const GRID_THUMB_MAX: i32 = 144; +pub(crate) const GRID_THUMB_DEFAULT: i32 = 72; +pub(crate) const GRID_THUMB_STEP: i32 = 8; + /// The Files page: a Nautilus-style file manager. A back/breadcrumb header with /// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an /// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] @@ -86,6 +115,15 @@ pub(crate) struct BrowserWidgets { pub(crate) new_folder: gtk4::Button, pub(crate) upload: gtk4::Button, pub(crate) upload_folder: gtk4::Button, + pub(crate) build_thumbnails: gtk4::Button, + pub(crate) thumbnail_build_row: gtk4::Box, + pub(crate) thumbnail_progress: gtk4::ProgressBar, + pub(crate) thumbnail_status: gtk4::Label, + pub(crate) summary: gtk4::Label, + pub(crate) zoom: gtk4::Scale, + pub(crate) quota_box: gtk4::Box, + pub(crate) quota: gtk4::ProgressBar, + pub(crate) quota_text: gtk4::Label, pub(crate) refresh: gtk4::Button, /// Wraps the views + the details pane; the pane slides in on selection. pub(crate) split: adw::OverlaySplitView, @@ -149,6 +187,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .valign(gtk4::Align::Center) .build(); upload_folder.add_css_class("flat"); + let build_thumbnails = gtk4::Button::builder() + .icon_name("pdfs-build-thumbnails-symbolic") + .tooltip_text("Build thumbnails in this folder and its subfolders") + .valign(gtk4::Align::Center) + .build(); + build_thumbnails.add_css_class("flat"); // Linked grid/list toggle, top-right, Nautilus-style. let grid_toggle = gtk4::ToggleButton::builder() @@ -178,12 +222,24 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { header.append(&back); header.append(&crumb_scroll); header.append(&refresh); + header.append(&build_thumbnails); header.append(&new_folder); header.append(&upload); header.append(&upload_folder); header.append(&search); header.append(&toggles); + let thumbnail_status = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + thumbnail_status.add_css_class("caption"); + let thumbnail_progress = gtk4::ProgressBar::builder().hexpand(true).build(); + let thumbnail_build_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + thumbnail_build_row.append(&thumbnail_status); + thumbnail_build_row.append(&thumbnail_progress); + thumbnail_build_row.set_visible(false); + // Empty / loading / error surface, shown in place of the views. let retry = gtk4::Button::builder() .label("Retry") @@ -314,17 +370,94 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { .child(&bulk_box) .build(); - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&bulk); - inner.append(&split); + // Mirror DolphinStatusBar's full-width order: contextual text (stretch 1), + // "Zoom:", its slider, then capacity information. Both visible troughs use + // the same CSS dimensions. + let summary = gtk4::Label::builder() + .label("Loading…") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .hexpand(true) + .build(); + let zoom_label = gtk4::Label::new(Some("Zoom:")); + zoom_label.set_valign(gtk4::Align::Center); + let zoom = gtk4::Scale::with_range( + gtk4::Orientation::Horizontal, + f64::from(GRID_THUMB_MIN), + f64::from(GRID_THUMB_MAX), + f64::from(GRID_THUMB_STEP), + ); + zoom.set_value(f64::from(GRID_THUMB_DEFAULT)); + zoom.set_draw_value(false); + zoom.set_valign(gtk4::Align::Center); + zoom.set_tooltip_text(Some("Size: 72 pixels")); + zoom.add_css_class("browser-status-meter"); + + let quota = gtk4::ProgressBar::new(); + quota.set_hexpand(false); + quota.set_valign(gtk4::Align::Center); + quota.set_tooltip_text(Some("Proton account storage")); + quota.add_css_class("browser-status-meter"); + let quota_text = gtk4::Label::builder() + .label("Loading…") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .margin_end(6) + .tooltip_text("Proton account storage") + .build(); + let quota_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + quota_box.set_valign(gtk4::Align::Center); + quota_box.append("a); + quota_box.append("a_text); + quota_box.set_visible(false); + + let status_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + status_bar.add_css_class("browser-statusbar"); + status_bar.set_margin_start(6); + status_bar.set_margin_end(2); + status_bar.append(&summary); + status_bar.append(&zoom_label); + status_bar.append(&zoom); + status_bar.append("a_box); + + let zoom_label_for_view = zoom_label.clone(); + let zoom_for_view = zoom.clone(); + let content_for_view = content.clone(); + view_stack.connect_visible_child_name_notify(move |stack| { + let visible = content_for_view.visible_child_name().as_deref() == Some("views") + && stack.visible_child_name().as_deref() == Some("grid"); + zoom_label_for_view.set_visible(visible); + zoom_for_view.set_visible(visible); + }); + let zoom_label_for_content = zoom_label.clone(); + let zoom_for_content = zoom.clone(); + let view_for_content = view_stack.clone(); + content.connect_visible_child_name_notify(move |stack| { + let visible = stack.visible_child_name().as_deref() == Some("views") + && view_for_content.visible_child_name().as_deref() == Some("grid"); + zoom_label_for_content.set_visible(visible); + zoom_for_content.set_visible(visible); + }); + + let body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + body.set_vexpand(true); + body.set_margin_top(12); + body.set_margin_bottom(6); + body.set_margin_start(12); + body.set_margin_end(12); + body.append(&header); + body.append(&thumbnail_build_row); + body.append(&bulk); + body.append(&split); + + let page = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + page.append(&body); + page.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + page.append(&status_bar); ( - inner.upcast(), + page.upcast(), BrowserWidgets { model, back, @@ -338,6 +471,15 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { new_folder, upload, upload_folder, + build_thumbnails, + thumbnail_build_row, + thumbnail_progress, + thumbnail_status, + summary, + zoom, + quota_box, + quota, + quota_text, refresh, split, details, @@ -653,23 +795,51 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k load_browser(&ui_back); }); - // Grid tiles: a big icon over an ellipsized name, with a right-click menu. + // Resize only realised grid cells. Rebuilding the whole model for every + // slider step would repeatedly tear down selection state while the pointer + // is still moving. + let zoom = ui.browser.zoom.clone(); + let ui_zoom = ui.clone(); + let grid_zoom = grid.clone(); + zoom.connect_value_changed(move |scale| { + let size = (scale.value().round() as i32).clamp(GRID_THUMB_MIN, GRID_THUMB_MAX); + scale.set_tooltip_text(Some(&format!("Size: {size} pixels"))); + if ui_zoom.browser.grid_thumbnail_size.replace(size) == size { + return; + } + ui_zoom + .browser + .grid_tiles + .borrow_mut() + .retain(|(thumbnail_ref, label_ref)| { + let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade()) + else { + return false; + }; + resize_grid_tile(&thumbnail, &label, size); + true + }); + grid_zoom.queue_resize(); + }); + + // Grid tiles: a thumbnail over an ellipsized name, with a right-click menu. let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(64).build(); - // Corner sync-state badge, overlaid on the big icon. + let size = ui.browser.grid_thumbnail_size.get(); + let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size)); + // Keep the sync-state badge inside the thumbnail surface. let badge = gtk4::Image::builder() .pixel_size(18) .halign(gtk4::Align::End) - .valign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .margin_top(2) + .margin_end(2) .build(); badge.add_css_class("file-badge"); - let overlay = gtk4::Overlay::new(); - overlay.set_child(Some(&icon)); - overlay.add_overlay(&badge); + thumbnail.add_overlay(&badge); // `WordChar` rather than the default `Word`: a name with no spaces // offers no word-break opportunity, so word wrapping cannot break it // at all and the label asks for its full natural width instead — @@ -686,9 +856,13 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k .wrap_mode(gtk4::pango::WrapMode::WordChar) .lines(2) .build(); + ui.browser + .grid_tiles + .borrow_mut() + .push((thumbnail.downgrade(), label.downgrade())); let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); tile.add_css_class("file-tile"); - tile.append(&overlay); + tile.append(&thumbnail); tile.append(&label); attach_context_menu(&ui, item, &tile); attach_drag(&ui, item, &tile); @@ -696,21 +870,29 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k item.set_child(Some(&tile)); } }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let tile = item.child().and_downcast::().unwrap(); - let overlay = tile.first_child().and_downcast::().unwrap(); - let icon = overlay.first_child().and_downcast::().unwrap(); - let badge = overlay.last_child().and_downcast::().unwrap(); - let label = tile.last_child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - icon.set_icon_name(Some(icon_base_for(&entry))); - label.set_label(&entry.name); - // The tile shows at most two lines of it, so the full name has to be - // reachable somehow. - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let tile = item.child().and_downcast::().unwrap(); + let thumbnail = tile.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + let size = ui.browser.grid_thumbnail_size.get(); + resize_grid_tile(&thumbnail, &label, size); + bind_file_thumbnail(&ui, &thumbnail, &entry, false); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + apply_badge(&badge, &entry); + } }); grid.set_factory(Some(&factory)); @@ -740,15 +922,33 @@ pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k }); } -/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, -/// with the same right-click menu the grid tiles carry. +fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) { + resize_file_thumbnail(thumbnail, size, grid_fallback_size(size)); + let name_width = (size / 6 + 1).clamp(8, 24); + label.set_width_chars(name_width); + label.set_max_width_chars(name_width); +} + +fn grid_fallback_size(thumbnail_size: i32) -> i32 { + (thumbnail_size * 8 / 9).clamp(24, thumbnail_size) +} + +/// Build the Name column: a small thumbnail with its local-state badge overlaid, +/// followed by the name and the same right-click menu the grid tiles carry. pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup({ let ui = ui.clone(); move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(16).build(); + let thumbnail = file_thumbnail_widget(28, 16); + let badge = gtk4::Image::builder() + .pixel_size(14) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Start) + .build(); + badge.add_css_class("file-badge"); + thumbnail.add_overlay(&badge); // Ellipsized so the Name column can be *narrower* than its longest // name. Without it the label's minimum width is the whole string, // the column inherits that minimum, and one long name pushes Size @@ -757,30 +957,36 @@ pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); - let badge = gtk4::Image::builder().pixel_size(14).build(); - badge.add_css_class("file-badge"); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - cell.append(&icon); + cell.append(&thumbnail); cell.append(&label); - cell.append(&badge); attach_context_menu(&ui, item, &cell); attach_drag(&ui, item, &cell); attach_drop(&ui, item, &cell); item.set_child(Some(&cell)); } }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let cell = item.child().and_downcast::().unwrap(); - let icon = cell.first_child().and_downcast::().unwrap(); - let badge = cell.last_child().and_downcast::().unwrap(); - let label = icon.next_sibling().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - label.set_label(&entry.name); - label.set_tooltip_text(Some(&entry.name)); - apply_badge(&badge, &entry); + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let thumbnail = cell.first_child().and_downcast::().unwrap(); + let badge = thumbnail + .last_child() + .and_downcast::() + .unwrap(); + let label = thumbnail + .next_sibling() + .and_downcast::() + .unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + bind_file_thumbnail(&ui, &thumbnail, &entry, true); + label.set_label(&entry.name); + label.set_tooltip_text(Some(&entry.name)); + apply_badge(&badge, &entry); + } }); let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); column.set_expand(true); @@ -1254,6 +1460,7 @@ pub(crate) fn wire_browser_actions( new_folder: >k4::Button, upload: >k4::Button, upload_folder: >k4::Button, + build_thumbnails: >k4::Button, ) { let ui_nf = ui.clone(); new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf)); @@ -1261,6 +1468,159 @@ pub(crate) fn wire_browser_actions( upload.connect_clicked(move |_| prompt_upload(&ui_up)); let ui_uf = ui.clone(); upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); + let ui_thumbs = ui.clone(); + build_thumbnails.connect_clicked(move |_| start_thumbnail_build(&ui_thumbs)); +} + +const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500); + +/// Start the daemon's deliberate recursive job. Opportunistic requests for +/// visible rows are cancelled first; the explicit job has its own lifetime and +/// continues if the user navigates while watching its progress. +fn start_thumbnail_build(ui: &Rc) { + if !*ui.mounted.borrow() { + toast_error( + ui, + "Couldn't build thumbnails", + "Proton Drive isn't connected.", + ); + return; + } + cancel_file_thumbnails(ui); + ui.browser.thumbnail_build_running.set(true); + ui.browser.build_thumbnails.set_sensitive(false); + ui.browser.thumbnail_build_row.set_visible(true); + ui.browser.thumbnail_progress.set_fraction(0.0); + ui.browser + .thumbnail_status + .set_label("Starting thumbnail build…"); + + let path = ui.browser.path.borrow().clone(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::StartThumbnailBuild { path }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::ThumbnailBuild { status })) => { + repaint_thumbnail_build(&ui, &status); + } + Ok(Ok(Response::Error { message, kind })) => { + thumbnail_build_failed(&ui, &message); + toast_failure(&ui, "Couldn't build thumbnails", &message, kind); + } + _ => { + let message = "The mount service didn't respond."; + thumbnail_build_failed(&ui, message); + toast_error(&ui, "Couldn't build thumbnails", message); + } + } + }); +} + +fn schedule_thumbnail_build_poll(ui: &Rc) { + if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() { + source.remove(); + } + let ui_poll = ui.clone(); + let source = glib::timeout_add_local_once(THUMBNAIL_BUILD_POLL, move || { + ui_poll.browser.thumbnail_poll.borrow_mut().take(); + let rx = spawn_request(ui_poll.dirs.control_socket(), Request::ThumbnailBuildStatus); + let ui_result = ui_poll.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::ThumbnailBuild { status })) => { + repaint_thumbnail_build(&ui_result, &status) + } + Ok(Ok(Response::Error { message, .. })) => { + thumbnail_build_failed(&ui_result, &message) + } + _ => thumbnail_build_failed( + &ui_result, + "The mount service stopped reporting thumbnail progress.", + ), + } + }); + }); + *ui.browser.thumbnail_poll.borrow_mut() = Some(source); +} + +fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { + ui.browser + .thumbnail_build_row + .set_visible(show_thumbnail_build_progress(status)); + let root = if status.path.is_empty() { + "Proton Drive" + } else { + &status.path + }; + let text = if status.scanning { + ui.browser.thumbnail_progress.pulse(); + format!( + "Scanning {root}… {} folders, {} images", + status.folders_scanned, status.images_found + ) + } else if status.running { + let fraction = if status.images_found == 0 { + 0.0 + } else { + status.completed as f64 / status.images_found as f64 + }; + ui.browser + .thumbnail_progress + .set_fraction(fraction.clamp(0.0, 1.0)); + format!( + "Building thumbnails in {root}… {} of {}", + status.completed, status.images_found + ) + } else { + ui.browser.thumbnail_progress.set_fraction(1.0); + let available = status.completed.saturating_sub(status.failed); + match status.failed { + 0 => format!("Thumbnails ready for {available} images in {root}"), + failed => { + format!("Thumbnails ready for {available} images in {root}; {failed} unavailable") + } + } + }; + let text = match status.message.as_deref() { + Some(message) => format!("{text}. {message}"), + None => text, + }; + ui.browser.thumbnail_status.set_label(&text); + ui.browser.thumbnail_status.set_tooltip_text(Some(&text)); + + if status.running { + ui.browser.thumbnail_build_running.set(true); + ui.browser.build_thumbnails.set_sensitive(false); + schedule_thumbnail_build_poll(ui); + } else { + ui.browser.thumbnail_build_running.set(false); + ui.browser + .build_thumbnails + .set_sensitive(*ui.mounted.borrow()); + if ui.stack.visible_child_name().as_deref() == Some("browser") { + reload_listing(ui); + } + } +} + +/// The progress row is transient: once the daemon reports completion, both its +/// label and bar leave the browser instead of retaining a stale success state. +fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool { + status.running +} + +fn thumbnail_build_failed(ui: &Rc, message: &str) { + ui.browser.thumbnail_build_running.set(false); + ui.browser + .build_thumbnails + .set_sensitive(*ui.mounted.borrow()); + ui.browser.thumbnail_build_row.set_visible(true); + ui.browser.thumbnail_progress.set_fraction(0.0); + ui.browser.thumbnail_status.set_label(message); + ui.browser.thumbnail_status.set_tooltip_text(Some(message)); } /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash @@ -1666,9 +2026,13 @@ pub(crate) fn format_modified(secs: i64) -> String { /// Request the current browser directory from the daemon and repaint both views. pub(crate) fn load_browser(ui: &Rc) { + cancel_file_thumbnails(ui); + let generation = ui.browser.load_generation.get().wrapping_add(1); + ui.browser.load_generation.set(generation); let path = ui.browser.path.borrow().clone(); repaint_crumb(ui, &path); ui.browser.back.set_sensitive(!path.is_empty()); + ui.browser.summary.set_label("Loading…"); // Drop the previous folder's rows up front: a slow reply must not leave stale // entries visible, where clicking one would open with a wrong relative path. @@ -1693,7 +2057,7 @@ pub(crate) fn load_browser(ui: &Rc) { // The user may have navigated on while this folder was loading. A stale // out-of-order reply must not repaint rows for a folder we've left, or // the breadcrumb and the grid would disagree. - if *ui.browser.path.borrow() != path { + if ui.browser.load_generation.get() != generation || *ui.browser.path.borrow() != path { return; } match result { @@ -1714,6 +2078,7 @@ pub(crate) fn load_browser(ui: &Rc) { /// (which restarts the service) wouldn't help and isn't offered. pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { ui.browser.model.remove_all(); + ui.browser.summary.set_label("Folder unavailable"); browser_status( ui, "dialog-warning-symbolic", @@ -1730,6 +2095,7 @@ pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { /// once the systemd mount comes up but a real failure stays visible. pub(crate) fn browser_unreachable(ui: &Rc) { if service::is_failed() || !service::is_active() { + ui.browser.summary.set_label("Not connected"); browser_status( ui, "network-offline-symbolic", @@ -1739,6 +2105,7 @@ pub(crate) fn browser_unreachable(ui: &Rc) { ); return; } + ui.browser.summary.set_label("Connecting…"); browser_status( ui, "folder-remote-symbolic", @@ -1759,6 +2126,15 @@ pub(crate) fn browser_unreachable(ui: &Rc) { /// which refreshes both the grid and the column list. pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { ui.browser.model.remove_all(); + ui.browser.summary.set_label(&listing_summary( + entries.iter().filter(|entry| !entry.is_dir).count(), + entries.iter().filter(|entry| entry.is_dir).count(), + entries + .iter() + .filter(|entry| !entry.is_dir) + .map(|entry| entry.size) + .sum(), + )); if entries.is_empty() { browser_status( ui, @@ -1810,7 +2186,11 @@ pub(crate) fn wire_search(ui: &Rc) { /// views, reusing the same row model so click-to-open and pin work unchanged /// (each hit carries its full path; see [`entry_rel`]). pub(crate) fn run_search(ui: &Rc, query: &str) { + cancel_file_thumbnails(ui); + let generation = ui.browser.load_generation.get().wrapping_add(1); + ui.browser.load_generation.set(generation); ui.browser.model.remove_all(); + ui.browser.summary.set_label("Searching…"); browser_status( ui, "system-search-symbolic", @@ -1835,7 +2215,9 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { // The box may have been cleared or typed past while the reply was in // flight; if the query no longer matches, a fresher load/search already // owns the model — drop this stale, possibly out-of-order result. - if ui.browser.search.text().trim() != query { + if ui.browser.load_generation.get() != generation + || ui.browser.search.text().trim() != query + { return; } match result { @@ -1856,6 +2238,17 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { /// handlers already understand. pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { ui.browser.model.remove_all(); + let counts = listing_summary( + hits.iter().filter(|hit| !hit.is_dir).count(), + hits.iter().filter(|hit| hit.is_dir).count(), + hits.iter() + .filter(|hit| !hit.is_dir) + .map(|hit| hit.size) + .sum(), + ); + ui.browser + .summary + .set_label(&format!("{counts} — search results")); if hits.is_empty() { browser_status( ui, @@ -1892,3 +2285,47 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { ui.browser.model.append(&BoxedAnyObject::new(entry)); } } + +fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String { + let file_word = if files == 1 { "file" } else { "files" }; + let folder_word = if folders == 1 { "folder" } else { "folders" }; + match (folders, files) { + (0, 0) => "0 folders, 0 files".to_string(), + (_, 0) => format!("{folders} {folder_word}"), + (0, _) => format!("{files} {file_word} ({})", human_bytes(file_bytes)), + _ => format!( + "{folders} {folder_word}, {files} {file_word} ({})", + human_bytes(file_bytes) + ), + } +} + +#[cfg(test)] +mod tests { + use super::{listing_summary, show_thumbnail_build_progress}; + use pdfs_core::control::ThumbnailBuildStatus; + + #[test] + fn listing_summary_matches_dolphin_order_and_wording() { + assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files"); + assert_eq!(listing_summary(0, 2, 0), "2 folders"); + assert_eq!(listing_summary(1, 0, 1024), "1 file (1.0 KiB)"); + assert_eq!(listing_summary(3, 1, 3072), "1 folder, 3 files (3.0 KiB)"); + } + + #[test] + fn thumbnail_progress_disappears_when_build_finishes() { + let mut status = ThumbnailBuildStatus { + running: true, + scanning: true, + ..Default::default() + }; + assert!(show_thumbnail_build_progress(&status)); + + status.scanning = false; + assert!(show_thumbnail_build_progress(&status)); + + status.running = false; + assert!(!show_thumbnail_build_progress(&status)); + } +} diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 622d087..7f13d65 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc) { if ui.shared.inflight.get() { return; } + cancel_file_thumbnails(ui); let current = ui.shared.nav.borrow().last().cloned(); if let Some((uid, _)) = current { load_shared_folder(ui, uid); @@ -308,11 +309,7 @@ fn shared_entry_row(ui: &Rc, entry: &DirEntry) -> adw::ActionRow { if let Some(badge) = role_badge(&entry.role) { row.add_suffix(&badge); } - row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); + row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true)); let ui_act = ui.clone(); let uid = entry.uid.clone(); let name = entry.name.clone(); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index a465450..317010e 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc) { if ui.shared_by_me.inflight.get() { return; } + cancel_file_thumbnails(ui); ui.shared_by_me.inflight.set(true); shared_by_me_status( ui, @@ -200,15 +201,12 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { ui.shared_by_me.content.set_visible_child_name("list"); let mut rows: Vec = Vec::new(); for item in items { + let entry = shared_item_as_entry(item); let row = adw::ActionRow::builder() .title(&item.name) .subtitle(shared_item_summary(item)) .build(); - row.add_prefix(>k4::Image::from_icon_name(if item.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); + row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true)); // Copy / open the public link, when there is one with a recovered URL. if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { @@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { .build(); manage.add_css_class("flat"); let ui_manage = ui.clone(); - let entry = shared_item_as_entry(item); manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); row.add_suffix(&manage); @@ -289,7 +286,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { name: item.name.clone(), is_dir: item.is_dir, size: 0, - modified: 0, + modified: item.modified, pinned: false, cached: false, uid: item.uid.clone(), diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index be0b788..8f7c1a4 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -572,7 +572,7 @@ pub(crate) fn refresh(ui: &Rc) { refresh_takeout(ui); } match ui.stack.visible_child_name().as_deref() { - Some("main") => refresh_quota(ui), + Some("main" | "browser") => refresh_quota(ui), Some("locations") => refresh_locations(ui), Some("activity") => refresh_activity(ui), _ => {} @@ -580,13 +580,11 @@ pub(crate) fn refresh(ui: &Rc) { } /// How long a quota reading stays fresh. Account storage barely moves, so the -/// Settings tick refetches it only this often rather than every 2s. +/// active-page tick refetches it only this often rather than every 2s. const QUOTA_TTL: Duration = Duration::from_secs(60); -/// Fetch the account quota (if the last reading is stale) and paint the Account -/// storage group. Runs only while Settings is on screen. The group stays hidden -/// until a reading lands, so an account the API can't report — or a still-starting -/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last +/// Fetch the account quota (if the last reading is stale) and paint both the +/// Settings storage group and Files status bar. A failed fetch leaves the last /// good reading in place. pub(crate) fn refresh_quota(ui: &Rc) { if ui.status.quota_inflight.get() { @@ -609,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc) { })) = result { ui.status.quota_checked_at.set(Some(Instant::now())); - let used = used_space.max(0) as u64; - if max_space > 0 { - let total = max_space as u64; - let fraction = (used as f64 / total as f64).min(1.0); - ui.status.quota_bar.set_fraction(fraction); - let pct = (fraction * 100.0).round() as u64; - ui.status.quota_label.set_text(&format!( - "{} of {} used ({pct}%)", - human_bytes(used), - human_bytes(total) - )); - } else { - ui.status.quota_bar.set_fraction(0.0); - ui.status - .quota_label - .set_text(&format!("{} used", human_bytes(used))); - } + paint_account_quota(&ui, max_space, used_space); ui.status.quota_group.set_visible(true); + } else if ui.status.quota_checked_at.get().is_none() { + // Match Dolphin: capacity information does not occupy the bar until + // the backing observer has real figures. + ui.browser.quota_box.set_visible(false); } }); } +fn paint_account_quota(ui: &Rc, max_space: i64, used_space: i64) { + let (fraction, text) = quota_display(max_space, used_space); + ui.status.quota_bar.set_fraction(fraction); + ui.status.quota_label.set_text(&text); + if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) { + ui.browser.quota.set_fraction(fraction); + ui.browser.quota.set_tooltip_text(Some(&tooltip)); + ui.browser.quota_text.set_label(&free_text); + ui.browser.quota_text.set_tooltip_text(Some(&tooltip)); + ui.browser.quota_box.set_visible(true); + } else { + ui.browser.quota_box.set_visible(false); + } +} + +fn quota_display(max_space: i64, used_space: i64) -> (f64, String) { + let used = used_space.max(0) as u64; + if max_space <= 0 { + return (0.0, format!("{} used", human_bytes(used))); + } + let total = max_space as u64; + let fraction = (used as f64 / total as f64).clamp(0.0, 1.0); + let pct = (fraction * 100.0).round() as u64; + ( + fraction, + format!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + ), + ) +} + +/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the +/// full free/total/percentage sentence is a tooltip rather than inline bar text. +fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> { + if max_space <= 0 { + return None; + } + let total = max_space as u64; + let used = (used_space.max(0) as u64).min(total); + let free = total.saturating_sub(used); + let fraction = used as f64 / total as f64; + let pct = (fraction * 100.0).round() as u64; + Some(( + fraction, + format!("{} free", human_bytes(free)), + format!( + "{} free out of {} ({pct}% used)", + human_bytes(free), + human_bytes(total) + ), + )) +} + /// Record the mount state seen by the last status poll: gate every control that /// needs a live daemon, and notify the desktop when the state actually flips. /// @@ -642,6 +683,9 @@ pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { ui.browser.new_folder.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted); ui.browser.upload_folder.set_sensitive(mounted); + ui.browser + .build_thumbnails + .set_sensitive(mounted && !ui.browser.thumbnail_build_running.get()); ui.gallery.upload.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted); @@ -1010,3 +1054,36 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: .push(PinRow { row, unpin: None }); } } + +#[cfg(test)] +mod tests { + use super::{quota_display, quota_status_display}; + + #[test] + fn quota_display_reports_used_total_and_percentage() { + let gib = 1024_i64.pow(3); + let (fraction, text) = quota_display(4 * gib, gib); + assert!((fraction - 0.25).abs() < f64::EPSILON); + assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)"); + } + + #[test] + fn quota_display_clamps_bad_api_values() { + assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string())); + assert_eq!(quota_display(100, 150).0, 1.0); + } + + #[test] + fn quota_status_display_matches_dolphin_wording() { + let gib = 1024_i64.pow(3); + assert_eq!( + quota_status_display(4 * gib, gib), + Some(( + 0.25, + "3.0 GiB free".to_string(), + "3.0 GiB free out of 4.0 GiB (25% used)".to_string() + )) + ); + assert_eq!(quota_status_display(0, 0), None); + } +} diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs index 0bf9cb9..2fb53ce 100644 --- a/crates/pdfs-gui/src/app/pages/trash.rs +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto factory.connect_setup(move |_, item| { let item = item.downcast_ref::().unwrap(); - let icon = gtk4::Image::builder().pixel_size(32).build(); + let thumbnail = file_thumbnail_widget(40, 32); let name = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::Middle) @@ -169,35 +169,38 @@ pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Butto row.set_margin_bottom(6); row.set_margin_start(6); row.set_margin_end(6); - row.append(&icon); + row.append(&thumbnail); row.append(&text); row.append(&restore); row.append(&purge); item.set_child(Some(&row)); }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let Some(entry) = bound_entry(item) else { - return; - }; - let row = item.child().and_downcast::().unwrap(); - let Some(icon) = row.first_child().and_downcast::() else { - return; - }; - icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - let Some(text) = icon.next_sibling().and_downcast::() else { - return; - }; - if let Some(name) = text.first_child().and_downcast::() { - name.set_label(&entry.name); - } - if let Some(meta) = text.last_child().and_downcast::() { - let kind = if entry.is_dir { - "Folder".to_string() - } else { - human_bytes(entry.size) + factory.connect_bind({ + let ui = ui.clone(); + move |_, item| { + let item = item.downcast_ref::().unwrap(); + let Some(entry) = bound_entry(item) else { + return; }; - meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + let row = item.child().and_downcast::().unwrap(); + let Some(thumbnail) = row.first_child().and_downcast::() else { + return; + }; + bind_file_thumbnail(&ui, &thumbnail, &entry, true); + let Some(text) = thumbnail.next_sibling().and_downcast::() else { + return; + }; + if let Some(name) = text.first_child().and_downcast::() { + name.set_label(&entry.name); + } + if let Some(meta) = text.last_child().and_downcast::() { + let kind = if entry.is_dir { + "Folder".to_string() + } else { + human_bytes(entry.size) + }; + meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + } } }); list.set_factory(Some(&factory)); @@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option { /// Fetch the trash listing and repaint the page. pub(crate) fn load_trash(ui: &Rc) { + cancel_file_thumbnails(ui); // Drop the old rows first: a stale row here would offer Restore on something // that may already be gone. ui.trash.model.remove_all(); diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 5584e1f..ce1e722 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod details; pub(crate) mod share_dialog; +pub(crate) mod thumbnails; pub(crate) mod versions_dialog; diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs new file mode 100644 index 0000000..f69614b --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -0,0 +1,387 @@ +//! Shared thumbnails for ordinary Drive image files. +//! +//! The Photos gallery has timeline-specific batching and aspect-ratio state. +//! File listings need a smaller, reusable surface: one loader backs My files, +//! search, Shared and Trash, with modification-time cache keys +//! so a replaced image never flashes its previous contents. + +use crate::*; +use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const THUMB_BATCH: usize = 32; +const THUMB_CACHE_MAX: usize = 256; +const THUMB_MISSING_MAX: usize = 2048; +const THUMB_DEBOUNCE: Duration = Duration::from_millis(40); +const THUMB_RETRY: Duration = Duration::from_secs(2); + +pub(crate) struct FileThumbnailState { + textures: RefCell>, + texture_order: RefCell>, + missing: RefCell>, + missing_order: RefCell>, + wanted: RefCell>>, + queue: RefCell>, + inflight: Cell, + source: RefCell>, + generation: Cell, +} + +impl FileThumbnailState { + pub(crate) fn new() -> Self { + Self { + textures: RefCell::new(HashMap::new()), + texture_order: RefCell::new(VecDeque::new()), + missing: RefCell::new(HashSet::new()), + missing_order: RefCell::new(VecDeque::new()), + wanted: RefCell::new(HashMap::new()), + queue: RefCell::new(VecDeque::new()), + inflight: Cell::new(false), + source: RefCell::new(None), + generation: Cell::new( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .min(u128::from(u64::MAX)) as u64, + ), + } + } + + fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) { + let mut textures = self.textures.borrow_mut(); + let mut order = self.texture_order.borrow_mut(); + if textures.insert(key.to_string(), texture).is_none() { + order.push_back(key.to_string()); + } + while order.len() > THUMB_CACHE_MAX { + if let Some(old) = order.pop_front() { + textures.remove(&old); + } + } + } + + fn store_missing(&self, key: String) { + let mut missing = self.missing.borrow_mut(); + let mut order = self.missing_order.borrow_mut(); + if missing.insert(key.clone()) { + order.push_back(key); + } + while order.len() > THUMB_MISSING_MAX { + if let Some(old) = order.pop_front() { + missing.remove(&old); + } + } + } +} + +/// Abandon every opportunistic ordinary-file thumbnail associated with the +/// listing being left. Advancing a wire generation lets the daemon cancel work +/// even when an older request reaches it after this one. +pub(crate) fn cancel_file_thumbnails(ui: &Rc) { + if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { + source.remove(); + } + ui.file_thumbs.queue.borrow_mut().clear(); + ui.file_thumbs.wanted.borrow_mut().clear(); + let generation = ui.file_thumbs.generation.get().saturating_add(1); + ui.file_thumbs.generation.set(generation); + // The request is useful even when its reply is not. `spawn_request` owns the + // socket round-trip on a worker thread, so dropping the receiver is safe. + drop(spawn_request( + ui.dirs.control_socket(), + Request::CancelFileThumbs { generation }, + )); +} + +/// A square thumbnail surface with a generic file icon underneath the picture. +/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry. +pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay { + let fallback = gtk4::Image::builder().pixel_size(fallback_size).build(); + let picture = gtk4::Picture::builder() + .width_request(size) + .height_request(size) + .content_fit(gtk4::ContentFit::Cover) + .can_shrink(true) + .visible(false) + .build(); + picture.add_css_class("file-thumbnail"); + + let overlay = gtk4::Overlay::new(); + overlay.set_width_request(size); + overlay.set_height_request(size); + overlay.set_halign(gtk4::Align::Center); + overlay.set_valign(gtk4::Align::Center); + overlay.set_overflow(gtk4::Overflow::Hidden); + overlay.set_child(Some(&fallback)); + overlay.add_overlay(&picture); + overlay +} + +/// Resize an existing thumbnail surface. Grid rows are recycled, so changing +/// these requests in place and rebinding the model is enough to implement the +/// status-bar zoom without downloading or decoding an image again. +pub(crate) fn resize_file_thumbnail(widget: >k4::Overlay, size: i32, fallback_size: i32) { + widget.set_width_request(size); + widget.set_height_request(size); + let Some(fallback) = widget.child().and_downcast::() else { + return; + }; + fallback.set_pixel_size(fallback_size); + if let Some(picture) = fallback.next_sibling().and_downcast::() { + picture.set_width_request(size); + picture.set_height_request(size); + } +} + +/// Bind a thumbnail surface to one entry, painting a cached texture immediately +/// or queueing an on-demand daemon request. Non-images retain the generic icon. +pub(crate) fn bind_file_thumbnail( + ui: &Rc, + widget: >k4::Overlay, + entry: &DirEntry, + symbolic_fallback: bool, +) { + let Some(fallback) = widget.child().and_downcast::() else { + return; + }; + // The browser may add a status badge after the picture. Locate the picture + // immediately after the base fallback instead of assuming it is the last + // overlay child. + let Some(picture) = fallback.next_sibling().and_downcast::() else { + return; + }; + + let fallback_name = if symbolic_fallback { + format!("{}-symbolic", icon_base_for(entry)) + } else { + icon_base_for(entry).to_string() + }; + fallback.set_icon_name(Some(&fallback_name)); + fallback.set_visible(true); + picture.set_visible(false); + picture.set_paintable(gtk4::gdk::Paintable::NONE); + + // The widget name is a cheap binding token. List factories recycle their + // children; a reply for the previous entry must not paint into the new one. + let key = thumbnail_key(&entry.uid, entry.modified); + picture.set_widget_name(&key); + remove_waiter(ui, &picture); + + if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() { + return; + } + if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) { + paint(&picture, &key, texture); + return; + } + if ui.file_thumbs.missing.borrow().contains(&key) { + return; + } + + ui.file_thumbs + .wanted + .borrow_mut() + .entry(key) + .or_default() + .push(picture); + let mut queue = ui.file_thumbs.queue.borrow_mut(); + queue.retain(|item| item.uid != entry.uid); + queue.push_back(FileThumbRequest { + uid: entry.uid.clone(), + modified: entry.modified, + name: entry.name.clone(), + }); + drop(queue); + schedule_file_thumbs(ui); +} + +/// Create and immediately bind a thumbnail for a non-recycled row. +pub(crate) fn file_thumbnail( + ui: &Rc, + entry: &DirEntry, + size: i32, + fallback_size: i32, + symbolic_fallback: bool, +) -> gtk4::Overlay { + let widget = file_thumbnail_widget(size, fallback_size); + bind_file_thumbnail(ui, &widget, entry, symbolic_fallback); + widget +} + +/// Whether the file name denotes an image format for which Proton or the local +/// decoder can reasonably provide a thumbnail. +pub(crate) fn is_image_name(name: &str) -> bool { + is_thumbnail_image_name(name) +} + +fn thumbnail_key(uid: &str, modified: i64) -> String { + format!("{uid}:{modified}") +} + +fn remove_waiter(ui: &Rc, picture: >k4::Picture) { + let mut wanted = ui.file_thumbs.wanted.borrow_mut(); + wanted.retain(|_, pictures| { + pictures.retain(|candidate| candidate != picture); + !pictures.is_empty() + }); +} + +fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) { + if picture.widget_name() != key { + return; + } + picture.set_paintable(Some(texture)); + picture.set_visible(true); + if let Some(overlay) = picture.parent().and_downcast::() + && let Some(fallback) = overlay.child().and_downcast::() + { + fallback.set_visible(false); + } +} + +fn schedule_file_thumbs(ui: &Rc) { + if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() { + return; + } + if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { + source.remove(); + } + let ui_flush = ui.clone(); + let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || { + ui_flush.file_thumbs.source.borrow_mut().take(); + flush_file_thumbs(&ui_flush); + }); + *ui.file_thumbs.source.borrow_mut() = Some(source); +} + +fn flush_file_thumbs(ui: &Rc) { + if ui.file_thumbs.inflight.get() { + return; + } + let items: Vec = { + let mut queue = ui.file_thumbs.queue.borrow_mut(); + let wanted = ui.file_thumbs.wanted.borrow(); + let mut batch = Vec::new(); + while batch.len() < THUMB_BATCH { + let Some(item) = queue.pop_front() else { + break; + }; + if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) { + batch.push(item); + } + } + batch + }; + if items.is_empty() { + return; + } + + ui.file_thumbs.inflight.set(true); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::FileThumbs { + items: items.clone(), + generation: ui.file_thumbs.generation.get(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.file_thumbs.inflight.set(false); + match result { + Ok(Ok(Response::Thumbs { items: replies })) => { + let requested: HashMap = items + .iter() + .cloned() + .map(|item| (item.uid.clone(), item)) + .collect(); + let mut pending = Vec::new(); + for reply in replies { + let Some(request) = requested.get(&reply.uid) else { + continue; + }; + let key = thumbnail_key(&request.uid, request.modified); + match reply.path { + Some(path) => match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => { + ui.file_thumbs.store_texture(&key, texture.clone()); + if let Some(pictures) = + ui.file_thumbs.wanted.borrow_mut().remove(&key) + { + for picture in pictures { + paint(&picture, &key, &texture); + } + } + } + Err(e) => { + tracing::debug!("cannot decode file thumbnail {path}: {e}"); + ui.file_thumbs.store_missing(key.clone()); + ui.file_thumbs.wanted.borrow_mut().remove(&key); + } + }, + None if reply.pending => pending.push(request.clone()), + None => { + ui.file_thumbs.store_missing(key.clone()); + ui.file_thumbs.wanted.borrow_mut().remove(&key); + } + } + } + if !pending.is_empty() { + retry_file_thumbs(&ui, pending); + } + } + Ok(Ok(Response::Error { message, .. })) => { + tracing::debug!("file thumbnails failed: {message}"); + } + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { + tracing::debug!("file thumbnails: no reply"); + } + } + schedule_file_thumbs(&ui); + }); +} + +fn retry_file_thumbs(ui: &Rc, items: Vec) { + let ui = ui.clone(); + glib::timeout_add_local_once(THUMB_RETRY, move || { + let wanted = ui.file_thumbs.wanted.borrow(); + let retry: Vec = items + .into_iter() + .filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified))) + .collect(); + drop(wanted); + ui.file_thumbs.queue.borrow_mut().extend(retry); + schedule_file_thumbs(&ui); + }); +} + +#[cfg(test)] +mod tests { + use super::is_image_name; + + #[test] + fn image_extensions_are_case_insensitive_and_specific() { + for name in [ + "photo.jpg", + "SCAN.PNG", + "animation.Gif", + "archive.tiff", + "camera.nef", + "negative.CR3", + ] { + assert!(is_image_name(name), "{name}"); + } + for name in [ + "notes.txt", + "movie.mp4", + "image.jpg.zip", + "jpg", + "camera.heic", + "frame.avif", + "graphic.svg", + ] { + assert!(!is_image_name(name), "{name}"); + } + } +} From b3e6af1ae51b6d1dc4a9735f7567164ab55890a7 Mon Sep 17 00:00:00 2001 From: emptyname Date: Tue, 18 Aug 2026 13:06:40 +0100 Subject: [PATCH 3/4] docs: document thumbnail runtime support --- .github/workflows/release.yml | 2 +- README.md | 9 ++++++--- packaging/PKGBUILD | 2 +- packaging/proton-drive-linux.spec | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f69d38..d67ae02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,7 +122,7 @@ jobs: Priority: optional Architecture: amd64 Maintainer: Nils Pukropp - Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4 + Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl Installed-Size: ${INSTALLED_SIZE} Description: Proton Drive client for Linux Proton Drive client for Linux featuring: diff --git a/README.md b/README.md index a33b675..c2f20a7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc - **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes. - **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal. - **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop. +- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar. - **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI. - **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account. - **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here. @@ -270,19 +271,21 @@ sudo apt-get install -y \ libadwaita-1-dev \ libwebkitgtk-6.0-dev \ libsecret-1-dev \ - libdbus-1-dev + libdbus-1-dev \ + libimage-exiftool-perl ``` ### Arch Linux ```bash -sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 +sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool ``` ### Fedora (44+) ```bash sudo dnf install -y \ pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \ - webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust + webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \ + perl-Image-ExifTool cargo rust ``` Runtime extras (pick your desktop): diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 95d6c0f..7ada14b 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -15,7 +15,7 @@ pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 ap arch=('x86_64') url="https://github.com/narl/proton-drive-linux" license=('MIT') -depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0') +depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool') makedepends=('cargo' 'git') provides=('pdfs') # Files-on-demand mount + keyring need these at runtime. diff --git a/packaging/proton-drive-linux.spec b/packaging/proton-drive-linux.spec index df23818..f7c02e9 100644 --- a/packaging/proton-drive-linux.spec +++ b/packaging/proton-drive-linux.spec @@ -33,6 +33,7 @@ Requires: gtk4 Requires: libadwaita Requires: libsecret Requires: webkitgtk6.0 +Requires: perl-Image-ExifTool Requires: xdg-utils # DE-specific; do not Require a single desktop environment. From 53caf63ad445394102c60ee68cedf27c986c67d5 Mon Sep 17 00:00:00 2001 From: emptyname Date: Fri, 21 Aug 2026 12:32:31 +0100 Subject: [PATCH 4/4] Fix thumbnail lifecycle failures --- crates/pdfs-core/src/cache.rs | 25 + crates/pdfs-core/src/control.rs | 91 ++-- crates/pdfs-fuse/src/control.rs | 18 +- crates/pdfs-fuse/src/lib.rs | 93 +++- crates/pdfs-fuse/src/mount.rs | 4 +- crates/pdfs-fuse/src/photos.rs | 472 ++++++++++++++---- crates/pdfs-gui/src/app/main.rs | 1 + crates/pdfs-gui/src/app/pages/browser.rs | 109 +++- crates/pdfs-gui/src/app/pages/status.rs | 8 +- crates/pdfs-gui/src/app/widgets/thumbnails.rs | 150 ++++-- 10 files changed, 748 insertions(+), 223 deletions(-) diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index 77d2e24..c9a6ed0 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -19,6 +19,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::io::{Read, Seek, SeekFrom}; +use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -415,6 +416,10 @@ pub struct ContentCache { /// Subdirectory holding cached thumbnails (`.t` + `.meta`). Kept out /// of `content_dir` so the budget scan never sees thumbnail files. thumb_dir: PathBuf, + /// Owner-protected scratch space for decrypted camera RAWs passed to + /// exiftool. Files are removed on drop and swept on the next cache open after + /// an unclean shutdown. + raw_thumb_dir: PathBuf, /// Subdirectory holding on-demand block-cache files (`.b` + /// `.meta`). Kept out of `content_dir` so the whole-file budget scan never /// sees them; blocks carry their own LRU budget. @@ -507,6 +512,19 @@ impl ContentCache { std::fs::create_dir_all(&content_dir)?; let thumb_dir = content_dir.join("thumbs"); std::fs::create_dir_all(&thumb_dir)?; + let raw_thumb_dir = content_dir.join("raw-thumbnails"); + std::fs::create_dir_all(&raw_thumb_dir)?; + std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?; + if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with("pdfs-raw-") + && entry.file_type().is_ok_and(|kind| kind.is_file()) + { + let _ = std::fs::remove_file(entry.path()); + } + } + } let block_dir = content_dir.join("blocks"); std::fs::create_dir_all(&block_dir)?; // Scratch holds disk-backed write buffers. A previous run's leftovers are @@ -530,6 +548,7 @@ impl ContentCache { let cache = Self { content_dir, thumb_dir, + raw_thumb_dir, block_dir, scratch_dir, staging_dir, @@ -1666,6 +1685,12 @@ impl ContentCache { .join(format!("{}.t{ttype}.meta", Self::key(uid))) } + /// Owner-protected transient directory used to stage decrypted camera RAWs + /// for metadata helpers. Callers still remove individual files promptly. + pub fn raw_thumbnail_staging_dir(&self) -> &Path { + &self.raw_thumb_dir + } + /// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale. /// Validated by node `mtime` alone: a new revision (which is what changes the /// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index f81601a..46b7f0e 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -104,6 +104,10 @@ pub enum Request { #[serde(default)] generation: u64, }, + /// Reserve a daemon-issued generation for ordinary-file thumbnail work. + /// The daemon is the ordering authority, so GUI restarts and wall-clock + /// corrections cannot make a new listing look older than an earlier one. + ReserveFileThumbGeneration, /// Stop ordinary-file thumbnail work belonging to older listings. Photos /// timeline work and an explicit recursive build are deliberately separate. CancelFileThumbs { generation: u64 }, @@ -111,6 +115,8 @@ pub enum Request { /// The request returns immediately; progress is read with /// [`Request::ThumbnailBuildStatus`]. StartThumbnailBuild { path: String }, + /// Stop the current recursive ordinary-file thumbnail build, if any. + CancelThumbnailBuild, /// Read progress for the recursive ordinary-file thumbnail build. ThumbnailBuildStatus, /// Download a photo's full content into the cache; replies with its path. @@ -1232,35 +1238,9 @@ pub fn is_thumbnail_image_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; - matches!( - extension.to_ascii_lowercase().as_str(), - "bmp" - | "gif" - | "jpeg" - | "jpg" - | "png" - | "tif" - | "tiff" - | "webp" - | "arw" - | "cr2" - | "cr3" - | "crw" - | "dng" - | "k25" - | "kdc" - | "mrw" - | "nef" - | "nrw" - | "orf" - | "pef" - | "raf" - | "rw" - | "rw2" - | "sr2" - | "srf" - | "x3f" - ) + let extension = extension.to_ascii_lowercase(); + STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) + || RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) } /// Whether `name` is one of the concrete RAW formats handled by the exiftool @@ -1270,29 +1250,21 @@ pub fn is_raw_image_name(name: &str) -> bool { let Some((_, extension)) = name.rsplit_once('.') else { return false; }; - matches!( - extension.to_ascii_lowercase().as_str(), - "arw" - | "cr2" - | "cr3" - | "crw" - | "dng" - | "k25" - | "kdc" - | "mrw" - | "nef" - | "nrw" - | "orf" - | "pef" - | "raf" - | "rw" - | "rw2" - | "sr2" - | "srf" - | "x3f" - ) + let extension = extension.to_ascii_lowercase(); + RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str()) } +const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] = + &["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"]; + +/// Concrete camera RAW formats with embedded previews handled by exiftool. +/// This is the single source used by both RAW classification and the broader +/// thumbnail capability check. +const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[ + "arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf", + "raw", "rw2", "sr2", "srf", "x3f", +]; + /// Progress of the one recursive ordinary-file thumbnail build the daemon may /// run at a time. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] @@ -1467,6 +1439,13 @@ pub enum Response { PhotoMonths { months: Vec }, /// Thumbnails for a [`Request::PhotoThumbs`] batch. Thumbs { items: Vec }, + /// A [`Request::FileThumbs`] generation is no longer current. This is + /// retryable after reserving a fresh generation and must never be interpreted + /// as a permanent "no thumbnail" verdict. + FileThumbsStale, + /// Daemon-issued generation reserved by + /// [`Request::ReserveFileThumbGeneration`]. + FileThumbGeneration { generation: u64 }, /// Current recursive thumbnail-build progress. ThumbnailBuild { status: ThumbnailBuildStatus }, /// An on-disk path the front-end can open (e.g. a downloaded photo). @@ -1801,10 +1780,12 @@ mod tests { #[test] fn thumbnail_build_requests_and_progress_roundtrip() { let requests = [ + Request::ReserveFileThumbGeneration, Request::CancelFileThumbs { generation: 43 }, Request::StartThumbnailBuild { path: "pictures/events".into(), }, + Request::CancelThumbnailBuild, Request::ThumbnailBuildStatus, ]; for request in requests { @@ -1828,6 +1809,16 @@ mod tests { let line = serde_json::to_string(&response).unwrap(); let decoded: Response = serde_json::from_str(&line).unwrap(); assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + + let response = Response::FileThumbGeneration { generation: 44 }; + let line = serde_json::to_string(&response).unwrap(); + let decoded: Response = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + + let response = Response::FileThumbsStale; + let line = serde_json::to_string(&response).unwrap(); + let decoded: Response = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } #[test] diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 445384e..c5557f3 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -329,8 +329,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U items: core.photo_thumbs(&parsed), } } - Ok(CtlRequest::FileThumbs { items, generation }) => CtlResponse::Thumbs { - items: core.file_thumbs(&items, generation), + Ok(CtlRequest::FileThumbs { items, generation }) => { + match core.file_thumbs(&items, generation) { + Some(items) => CtlResponse::Thumbs { items }, + None => CtlResponse::FileThumbsStale, + } + } + Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration { + generation: core.reserve_file_thumb_generation(), }, Ok(CtlRequest::CancelFileThumbs { generation }) => { core.cancel_file_thumbs(generation); @@ -339,11 +345,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } } Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) { - Ok(rel) => CtlResponse::ThumbnailBuild { - status: core.start_thumbnail_build(rel), + Ok(rel) => match core.start_thumbnail_build(rel) { + Ok(status) => CtlResponse::ThumbnailBuild { status }, + Err(error) => CtlResponse::error(error), }, Err(error) => CtlResponse::error(error), }, + Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild { + status: core.cancel_thumbnail_build(), + }, Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild { status: core.thumbnail_build_status(), }, diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index f22b32e..822ecfb 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -374,6 +374,45 @@ impl SearchRoots { } } +/// Negative thumbnail knowledge has two different authorities. A remote miss +/// only says Proton has no thumbnail to download; it must never suppress local +/// generation. A local miss says this exact file revision was successfully +/// inspected and could not be decoded. Both maps are bounded independently. +#[derive(Default)] +struct ThumbnailMissCaches { + remote: HashMap<(NodeUid, i32), i64>, + local: HashMap<(NodeUid, i32), i64>, +} + +impl ThumbnailMissCaches { + fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool { + self.remote.get(key) == Some(&tag) + } + + fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool { + self.local.get(key) == Some(&tag) + } + + fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) { + Self::remember(&mut self.remote, key, tag); + } + + fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) { + Self::remember(&mut self.local, key, tag); + } + + fn forget_local(&mut self, key: &(NodeUid, i32)) { + self.local.remove(key); + } + + fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) { + if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) { + map.clear(); + } + map.insert(key, tag); + } +} + /// `path` expressed relative to `root`, or `None` if it is not below it. The /// root itself resolves to the empty path; an empty root is the mount itself /// and covers everything. @@ -521,8 +560,12 @@ struct Core { file_thumb_cancel: Arc, /// Progress of the explicit recursive build started from the Files toolbar. thumbnail_build: Arc>, - /// Nodes the remote has told us have *no* thumbnail of a given type, keyed by - /// `(uid, thumbnail type)` and holding the mtime the answer was learned at. + /// Cancellation for the explicit recursive build. Separate from ordinary + /// listing cancellation because the two jobs have independent lifetimes. + thumbnail_build_cancelled: Arc, + thumbnail_build_cancel: Arc, + /// Negative thumbnail knowledge, separated by whether the remote lacked a + /// thumbnail or local decoding proved this revision unsupported. /// /// Absence has to be cached or it costs a round trip every time it is asked /// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister @@ -530,7 +573,7 @@ struct Core { /// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime /// is the validity tag — a new revision may well have a thumbnail — matching /// how [`ContentCache::read_thumbnail`] validates the positive side. - no_thumbnail: Arc>>, + thumbnail_misses: Arc>, /// Last account quota answer and when it was learned, for `statfs(2)`. /// /// `df` and the free-space preflight of every file manager and installer ask @@ -3590,7 +3633,7 @@ impl Core { // "This file has no thumbnail" is an answer worth remembering: without // it every listing pays a round trip per file to be told nothing (B5). let key = (uid.clone(), ttype.as_i32()); - if self.no_thumbnail.lock().get(&key) == Some(&mtime) { + if self.thumbnail_misses.lock().remote_contains(&key, mtime) { return Ok(None); } let bytes = self @@ -3607,15 +3650,7 @@ impl Core { .store_thumbnail(&uid, ttype.as_i32(), mtime, bytes); } None => { - let mut misses = self.no_thumbnail.lock(); - // Bounded by clearing rather than by LRU: the entries are two - // words each, the cap is far above any plausible working set, - // and re-learning a miss costs one round trip. Not worth a - // second data structure to order them. - if misses.len() >= MAX_THUMBNAIL_MISSES { - misses.clear(); - } - misses.insert(key, mtime); + self.thumbnail_misses.lock().remember_remote(key, mtime); } } Ok(bytes) @@ -7556,6 +7591,38 @@ mod tests { } } +#[cfg(test)] +mod thumbnail_miss_tests { + use super::*; + + #[test] + fn remote_misses_never_suppress_local_generation() { + let uid = parse_uid("volume~node").expect("uid"); + let key = (uid, ThumbnailType::Thumbnail.as_i32()); + let mut misses = ThumbnailMissCaches::default(); + + misses.remember_remote(key.clone(), 42); + assert!(misses.remote_contains(&key, 42)); + assert!(!misses.local_contains(&key, 42)); + + misses.remember_local(key.clone(), 42); + assert!(misses.local_contains(&key, 42)); + misses.forget_local(&key); + assert!(!misses.local_contains(&key, 42)); + assert!(misses.remote_contains(&key, 42)); + } + + #[test] + fn local_miss_cache_is_bounded_on_gui_only_paths() { + let mut misses = ThumbnailMissCaches::default(); + for index in 0..=MAX_THUMBNAIL_MISSES { + let uid = parse_uid(&format!("volume~node-{index}")).expect("uid"); + misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42); + } + assert_eq!(misses.local.len(), 1); + } +} + #[cfg(test)] mod search_root_tests { use super::*; diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index c9f392e..bea904f 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -395,7 +395,9 @@ pub fn mount( file_thumb_generation: Arc::new(AtomicU64::new(0)), file_thumb_cancel: Arc::new(tokio::sync::Notify::new()), thumbnail_build: Arc::new(Mutex::new(Default::default())), - no_thumbnail: Arc::new(Mutex::new(HashMap::new())), + thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)), + thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()), + thumbnail_misses: Arc::new(Mutex::new(Default::default())), quota: Arc::new(Mutex::new(None)), size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_waiters: Arc::new(Default::default()), diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 7185be5..feb0e65 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -6,10 +6,11 @@ //! a phone) are generated locally and stored as if the server had served them. use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; use std::fs::OpenOptions; use std::io::{Read as _, Write as _}; use std::os::unix::fs::OpenOptionsExt as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Once; use std::sync::atomic::{AtomicU64, Ordering}; @@ -44,6 +45,11 @@ const THUMB_QUALITY: u8 = 82; pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4; /// Images processed between recursive-build progress updates. const THUMB_BUILD_CHUNK: usize = 16; +/// A recursive build may wait briefly for an opportunistic tile job that +/// already owns a uid, but never forever. +const THUMB_BUILD_CLAIM_TIMEOUT: Duration = Duration::from_secs(30); +/// Backstop for cancellation if a notification races with waiter registration. +const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100); /// A broken metadata helper must not hold one of the four thumbnail permits /// indefinitely. Matches the deliberately generous KDE RAW thumbnail budget. const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); @@ -68,6 +74,17 @@ struct GeneratedThumb { ratio: f64, } +/// Scaling distinguishes a permanent content verdict from an environmental +/// failure. Only `Undecodable` may enter the local negative cache. +enum ScaleAttempt { + Made(GeneratedThumb), + Undecodable, + Unavailable, +} + +#[derive(Debug, Clone, Copy)] +struct RawPreviewUnavailable; + /// How one attempt at generating a missing thumbnail ended. The distinction that /// matters is *permanent* versus *transient*: only bytes we cannot decode prove /// the photo will never have a thumbnail, and only that verdict is persisted. @@ -109,13 +126,49 @@ impl ThumbBatchSummary { } } +/// `true` means publish a new build, `false` means the caller may attach to the +/// already-running build for the same root. A different root is never silently +/// accepted as if it were the requested job. +fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreResult { + if !status.running { + return Ok(true); + } + if status.path == path { + return Ok(false); + } + Err(CoreError::invalid(format!( + "a thumbnail build is already running for {}", + if status.path.is_empty() { + "Proton Drive" + } else { + &status.path + } + ))) +} + /// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its -/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an -/// image this build can decode — the caller then writes the photo off as -/// un-thumbnailable. +/// longest side, JPEG, aspect ratio preserved. Environmental RAW-extraction +/// failures remain retryable; only bytes conclusively outside the supported +/// formats return an undecodable verdict. /// /// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. -fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { +fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt { + scale_thumbnail_with_exiftool( + bytes, + name, + staging_dir, + OsStr::new("exiftool"), + EXIFTOOL_TIMEOUT, + ) +} + +fn scale_thumbnail_with_exiftool( + bytes: &[u8], + name: &str, + staging_dir: &Path, + exiftool: &OsStr, + timeout: Duration, +) -> ScaleAttempt { let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() .ok() @@ -123,22 +176,29 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { let image = match direct { Some(image) => image, None if is_raw_image_name(name) => { - let (preview, orientation) = extract_raw_preview(bytes, name)?; - let mut image = image::ImageReader::new(std::io::Cursor::new(preview)) + let (preview, orientation) = + match extract_raw_preview(bytes, name, staging_dir, exiftool, timeout) { + Ok(Some(preview)) => preview, + Ok(None) => return ScaleAttempt::Undecodable, + Err(_) => return ScaleAttempt::Unavailable, + }; + let Some(mut image) = image::ImageReader::new(std::io::Cursor::new(preview)) .with_guessed_format() - .ok()? - .decode() - .ok()?; + .ok() + .and_then(|reader| reader.decode().ok()) + else { + return ScaleAttempt::Undecodable; + }; if let Some(orientation) = image::metadata::Orientation::from_exif(orientation) { image.apply_orientation(orientation); } image } - None => return None, + None => return ScaleAttempt::Undecodable, }; let (width, height) = (image.width(), image.height()); if width == 0 || height == 0 { - return None; + return ScaleAttempt::Undecodable; } let ratio = f64::from(width) / f64::from(height); @@ -146,10 +206,13 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { // THUMB_EDGE and the ratio is untouched. let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8(); let mut bytes = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY) + if image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY) .encode_image(&thumb) - .ok()?; - Some(GeneratedThumb { bytes, ratio }) + .is_err() + { + return ScaleAttempt::Unavailable; + } + ScaleAttempt::Made(GeneratedThumb { bytes, ratio }) } /// Temporary file passed to exiftool. The helper identifies most RAWs from their @@ -158,7 +221,7 @@ fn scale_thumbnail(bytes: &[u8], name: &str) -> Option { struct RawTempFile(PathBuf); impl RawTempFile { - fn create(bytes: &[u8], name: &str) -> Option { + fn create_in(staging_dir: &Path, bytes: &[u8], name: &str) -> Option { let extension = std::path::Path::new(name) .extension() .and_then(|value| value.to_str()) @@ -174,7 +237,7 @@ impl RawTempFile { .as_nanos(); for _ in 0..16 { let nonce = RAW_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( + let path = staging_dir.join(format!( "pdfs-raw-{}-{started}-{nonce}.{extension}", std::process::id(), )); @@ -208,8 +271,13 @@ impl Drop for RawTempFile { /// Run one bounded exiftool JSON query while draining stdout concurrently. The /// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a /// pipe buffer long before the child exits. -fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option { - let mut command = Command::new("exiftool"); +fn exiftool_query( + exiftool: &OsStr, + path: &Path, + tags: &[&str], + timeout: Duration, +) -> Result { + let mut command = Command::new(exiftool); command.args(["-j", "-b", "-n"]); command.args(tags); command @@ -227,10 +295,14 @@ fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option Option break status.success(), - Ok(None) if started.elapsed() < EXIFTOOL_TIMEOUT => { + Ok(None) if started.elapsed() < timeout => { std::thread::sleep(Duration::from_millis(10)); } Ok(None) | Err(_) => { @@ -249,11 +321,15 @@ fn exiftool_query(path: &std::path::Path, tags: &[&str]) -> Option Option<(Vec, u8)> { - let file = RawTempFile::create(bytes, name)?; - let primary = exiftool_query(&file.0, &["-Orientation", "-PreviewImage"])?; - let primary = exiftool_object(&primary)?; +fn extract_raw_preview( + bytes: &[u8], + name: &str, + staging_dir: &Path, + exiftool: &OsStr, + timeout: Duration, +) -> Result, u8)>, RawPreviewUnavailable> { + let file = RawTempFile::create_in(staging_dir, bytes, name).ok_or(RawPreviewUnavailable)?; + let primary = exiftool_query( + exiftool, + &file.0, + &["-Orientation", "-PreviewImage"], + timeout, + )?; + let primary = exiftool_object(&primary).ok_or(RawPreviewUnavailable)?; let orientation = primary .get("Orientation") .and_then(serde_json::Value::as_u64) .and_then(|value| u8::try_from(value).ok()) .unwrap_or(1); if let Some(preview) = exiftool_binary(primary, "PreviewImage") { - return Some((preview, orientation)); + return Ok(Some((preview, orientation))); } const FALLBACKS: [&str; 5] = [ @@ -297,6 +384,7 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec, u8)> { "ThumbnailTIFF", ]; let fallback = exiftool_query( + exiftool, &file.0, &[ "-OtherImage", @@ -305,12 +393,13 @@ fn extract_raw_preview(bytes: &[u8], name: &str) -> Option<(Vec, u8)> { "-ThumbnailImage", "-ThumbnailTIFF", ], + timeout, )?; - let fallback = exiftool_object(&fallback)?; - FALLBACKS + let fallback = exiftool_object(&fallback).ok_or(RawPreviewUnavailable)?; + Ok(FALLBACKS .iter() .find_map(|key| exiftool_binary(fallback, key)) - .map(|preview| (preview, orientation)) + .map(|preview| (preview, orientation))) } /// Formats supported by the local thumbnail decoder and exposed by the GUI. @@ -514,14 +603,12 @@ impl Core { &self, items: &[FileThumbRequest], generation: u64, - ) -> Vec { - let previous = self - .file_thumb_generation - .fetch_max(generation, Ordering::SeqCst); - if generation > previous { - self.file_thumb_cancel.notify_waiters(); + ) -> Option> { + let current = + generation != 0 && self.file_thumb_generation.load(Ordering::SeqCst) == generation; + if !current { + return None; } - let current = self.file_thumb_generation.load(Ordering::SeqCst) == generation; let ttype = ThumbnailType::Thumbnail.as_i32(); let parsed: Vec<(String, NodeUid, i64)> = items .iter() @@ -549,7 +636,10 @@ impl Core { .cache .cached_thumbnail_path(uid, ttype, *modified) .is_some() - || self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) + || self + .thumbnail_misses + .lock() + .local_contains(&(uid.clone(), ttype), *modified) || !seen.insert(raw.clone()) { continue; @@ -557,26 +647,44 @@ impl Core { wanted.push(uid.clone()); } - if current && !wanted.is_empty() { + if !wanted.is_empty() { self.spawn_generate_thumbs(wanted, &tags, &names, ThumbJob::Files(generation)); } let pending = self.thumb_gen.lock(); - items - .iter() - .map(|item| { - let uid = parse_uid(&item.uid); - PhotoThumb { - uid: item.uid.clone(), - path: uid.as_ref().and_then(|uid| { - self.cache - .cached_thumbnail_path(uid, ttype, item.modified) - .map(|path| path.display().to_string()) - }), - pending: current && uid.as_ref().is_some_and(|uid| pending.contains(uid)), - } + Some( + items + .iter() + .map(|item| { + let uid = parse_uid(&item.uid); + PhotoThumb { + uid: item.uid.clone(), + path: uid.as_ref().and_then(|uid| { + self.cache + .cached_thumbnail_path(uid, ttype, item.modified) + .map(|path| path.display().to_string()) + }), + pending: uid.as_ref().is_some_and(|uid| pending.contains(uid)), + } + }) + .collect(), + ) + } + + /// Reserve the next ordinary-file listing generation. This daemon-owned + /// monotonic value replaces GUI wall-clock seeds, which can move backwards + /// across NTP corrections, suspend, or dual boot. + pub(crate) fn reserve_file_thumb_generation(&self) -> u64 { + let next = |current| if current == u64::MAX { 1 } else { current + 1 }; + let previous = self + .file_thumb_generation + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + Some(next(current)) }) - .collect() + .expect("generation update always succeeds"); + let generation = next(previous); + self.file_thumb_cancel.notify_waiters(); + generation } /// Advance the ordinary-file thumbnail generation. Every queued/download in @@ -644,7 +752,8 @@ impl Core { ThumbJob::Files(generation) => { self.file_thumb_generation.load(Ordering::SeqCst) == generation } - ThumbJob::Photos | ThumbJob::Build => true, + ThumbJob::Photos => true, + ThumbJob::Build => !self.thumbnail_build_cancelled.load(Ordering::SeqCst), } } @@ -684,9 +793,29 @@ impl Core { return (uid, ThumbAttempt::Cancelled); } } + _ = tokio::time::sleep(THUMB_CANCEL_POLL) => { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + } } }, - ThumbJob::Photos | ThumbJob::Build => acquire.await.ok(), + ThumbJob::Build => loop { + tokio::select! { + permit = &mut acquire => break permit.ok(), + _ = core.thumbnail_build_cancel.notified() => { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + } + _ = tokio::time::sleep(THUMB_CANCEL_POLL) => { + if !core.thumb_job_current(job) { + return (uid, ThumbAttempt::Cancelled); + } + } + } + }, + ThumbJob::Photos => acquire.await.ok(), }; let Some(_permit) = _permit else { return (uid, ThumbAttempt::Unavailable); @@ -707,9 +836,29 @@ impl Core { break None; } } + _ = tokio::time::sleep(THUMB_CANCEL_POLL) => { + if !core.thumb_job_current(job) { + break None; + } + } } }, - ThumbJob::Photos | ThumbJob::Build => Some(download.await), + ThumbJob::Build => loop { + tokio::select! { + result = &mut download => break Some(result), + _ = core.thumbnail_build_cancel.notified() => { + if !core.thumb_job_current(job) { + break None; + } + } + _ = tokio::time::sleep(THUMB_CANCEL_POLL) => { + if !core.thumb_job_current(job) { + break None; + } + } + } + }, + ThumbJob::Photos => Some(download.await), }; let Some(downloaded) = downloaded else { return (uid, ThumbAttempt::Cancelled); @@ -726,16 +875,19 @@ impl Core { } // Decoding + scaling a 20 MP JPEG is CPU-bound and would // stall the runtime's worker; hand it to the blocking pool. - let made = - tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name)) - .await - .unwrap_or(None); + let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); + let made = tokio::task::spawn_blocking(move || { + scale_thumbnail(&bytes, &name, &staging_dir) + }) + .await + .unwrap_or(ScaleAttempt::Unavailable); if !core.thumb_job_current(job) { return (uid, ThumbAttempt::Cancelled); } match made { - Some(thumb) => (uid, ThumbAttempt::Made(thumb)), - None => (uid, ThumbAttempt::Undecodable), + ScaleAttempt::Made(thumb) => (uid, ThumbAttempt::Made(thumb)), + ScaleAttempt::Undecodable => (uid, ThumbAttempt::Undecodable), + ScaleAttempt::Unavailable => (uid, ThumbAttempt::Unavailable), } }); } @@ -760,7 +912,9 @@ impl Core { match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { Ok(()) => { summary.made += 1; - self.no_thumbnail.lock().remove(&(uid.clone(), ttype)); + self.thumbnail_misses + .lock() + .forget_local(&(uid.clone(), ttype)); self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)); } Err(e) => { @@ -774,7 +928,9 @@ impl Core { ThumbAttempt::Undecodable => { summary.undecodable += 1; if let Some(&tag) = tags.get(&uid.to_string()) { - self.no_thumbnail.lock().insert((uid.clone(), ttype), tag); + self.thumbnail_misses + .lock() + .remember_local((uid.clone(), ttype), tag); } self.record_thumb(&uid, db::THUMB_NONE, None); } @@ -792,20 +948,22 @@ impl Core { self.thumbnail_build.lock().clone() } - /// Start one recursive local-thumbnail build. A second request while it is - /// running attaches to the existing job instead of duplicating every - /// download; the caller polls [`Core::thumbnail_build_status`]. - pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> ThumbnailBuildStatus { + /// Start one recursive local-thumbnail build. A request for the same root + /// may attach to it; a different root is rejected explicitly instead of + /// silently pretending the existing job belongs to the caller. + pub(crate) fn start_thumbnail_build(&self, root: PathBuf) -> CoreResult { let path = root.to_string_lossy().into_owned(); let status = { let mut status = self.thumbnail_build.lock(); - if status.running { - return status.clone(); + if !thumbnail_build_may_start(&status, &path)? { + return Ok(status.clone()); } + self.thumbnail_build_cancelled + .store(false, Ordering::SeqCst); *status = ThumbnailBuildStatus { running: true, scanning: true, - path, + path: path.clone(), folders_scanned: 0, images_found: 0, completed: 0, @@ -826,18 +984,57 @@ impl Core { status.running = false; status.scanning = false; status.message = Some("Thumbnail generation stopped unexpectedly".to_string()); + core.thumbnail_build_cancelled + .store(false, Ordering::SeqCst); warn!(path = %status.path, "recursive thumbnail build panicked"); } }); + Ok(status) + } + + pub(crate) fn cancel_thumbnail_build(&self) -> ThumbnailBuildStatus { + let (status, cancelled) = { + let status = self.thumbnail_build.lock(); + let cancelled = status.running; + if cancelled { + self.thumbnail_build_cancelled.store(true, Ordering::SeqCst); + } + (status.clone(), cancelled) + }; + if cancelled { + self.thumbnail_build_cancel.notify_waiters(); + } status } + fn finish_thumbnail_build(&self) { + let mut status = self.thumbnail_build.lock(); + let cancelled = self.thumbnail_build_cancelled.swap(false, Ordering::SeqCst); + status.running = false; + status.scanning = false; + if cancelled { + status.message = Some("Thumbnail build cancelled".to_string()); + } + info!( + path = %status.path, + images = status.images_found, + failed = status.failed, + cancelled, + "recursive thumbnail build finished" + ); + } + fn run_thumbnail_build(&self, root: PathBuf) { let mut folders = vec![root]; let mut images: Vec<(NodeUid, i64, String)> = Vec::new(); - let mut seen = HashSet::new(); + let mut seen_folders = HashSet::new(); + let mut seen_images = HashSet::new(); while let Some(folder) = folders.pop() { + if self.thumbnail_build_cancelled.load(Ordering::SeqCst) { + self.finish_thumbnail_build(); + return; + } let listing = self.list_dir(&folder); { let mut status = self.thumbnail_build.lock(); @@ -861,14 +1058,20 @@ impl Core { let mut invalid_here = 0_u64; for entry in entries { if entry.is_dir { - if seen.insert(entry.uid) { + if seen_folders.insert(entry.uid) { folders.push(folder.join(entry.name)); } } else if is_thumbnail_image_name(&entry.name) { - found_here += 1; match parse_uid(&entry.uid) { - Some(uid) => images.push((uid, entry.modified, entry.name)), - None => invalid_here += 1, + Some(uid) if seen_images.insert(uid.clone()) => { + found_here += 1; + images.push((uid, entry.modified, entry.name)); + } + Some(_) => {} + None => { + found_here += 1; + invalid_here += 1; + } } } } @@ -879,6 +1082,10 @@ impl Core { } self.thumbnail_build.lock().scanning = false; + if self.thumbnail_build_cancelled.load(Ordering::SeqCst) { + self.finish_thumbnail_build(); + return; + } let ttype = ThumbnailType::Thumbnail.as_i32(); let mut todo = Vec::new(); for (uid, modified, name) in images { @@ -888,7 +1095,11 @@ impl Core { .is_some() { self.thumbnail_build.lock().completed += 1; - } else if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(&modified) { + } else if self + .thumbnail_misses + .lock() + .local_contains(&(uid.clone(), ttype), modified) + { let mut status = self.thumbnail_build.lock(); status.completed += 1; status.failed += 1; @@ -898,6 +1109,10 @@ impl Core { } for chunk in todo.chunks(THUMB_BUILD_CHUNK) { + if self.thumbnail_build_cancelled.load(Ordering::SeqCst) { + self.finish_thumbnail_build(); + return; + } let mut claimed = Vec::with_capacity(chunk.len()); let mut tags = HashMap::with_capacity(chunk.len()); let mut names = HashMap::with_capacity(chunk.len()); @@ -905,7 +1120,12 @@ impl Core { // A visible tile may have started this image just before the // toolbar action. Wait for that one bounded job to finish rather // than downloading the full image twice. + let waiting_since = Instant::now(); loop { + if self.thumbnail_build_cancelled.load(Ordering::SeqCst) { + self.finish_thumbnail_build(); + return; + } if self .cache .cached_thumbnail_path(uid, ttype, *modified) @@ -914,7 +1134,11 @@ impl Core { self.thumbnail_build.lock().completed += 1; break; } - if self.no_thumbnail.lock().get(&(uid.clone(), ttype)) == Some(modified) { + if self + .thumbnail_misses + .lock() + .local_contains(&(uid.clone(), ttype), *modified) + { let mut status = self.thumbnail_build.lock(); status.completed += 1; status.failed += 1; @@ -926,6 +1150,17 @@ impl Core { claimed.push(uid.clone()); break; } + if waiting_since.elapsed() >= THUMB_BUILD_CLAIM_TIMEOUT { + let mut status = self.thumbnail_build.lock(); + status.completed += 1; + status.failed += 1; + if status.message.is_none() { + status.message = Some( + "Some images stayed busy and were skipped after a timeout".into(), + ); + } + break; + } std::thread::sleep(Duration::from_millis(50)); } } @@ -955,16 +1190,7 @@ impl Core { } } } - - let mut status = self.thumbnail_build.lock(); - status.running = false; - status.scanning = false; - info!( - path = %status.path, - images = status.images_found, - failed = status.failed, - "recursive thumbnail build finished" - ); + self.finish_thumbnail_build(); } /// Persist what a thumbnail attempt learned about a photo, against the @@ -1092,9 +1318,14 @@ impl Core { #[cfg(test)] mod thumb_tests { - use super::{RawTempFile, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail}; - use pdfs_core::control::is_thumbnail_image_name; + use super::{ + RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail, + scale_thumbnail_with_exiftool, thumbnail_build_may_start, + }; + use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; + use std::ffi::OsStr; use std::os::unix::fs::PermissionsExt as _; + use std::time::Duration; /// A `width`×`height` JPEG, standing in for a camera photo the server never /// generated a thumbnail for. @@ -1112,7 +1343,10 @@ mod thumb_tests { #[test] fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() { let photo = jpeg(4000, 3000); - let thumb = scale_thumbnail(&photo, "photo.jpg").expect("a JPEG scales"); + let ScaleAttempt::Made(thumb) = scale_thumbnail(&photo, "photo.jpg", &std::env::temp_dir()) + else { + panic!("a JPEG scales"); + }; let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) .with_guessed_format() @@ -1130,7 +1364,11 @@ mod thumb_tests { #[test] fn a_portrait_photo_fits_its_long_edge_too() { - let thumb = scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg").expect("a JPEG scales"); + let ScaleAttempt::Made(thumb) = + scale_thumbnail(&jpeg(1000, 2000), "portrait.jpeg", &std::env::temp_dir()) + else { + panic!("a JPEG scales"); + }; assert!(thumb.ratio < 1.0, "portrait stays portrait"); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); } @@ -1139,7 +1377,10 @@ mod thumb_tests { fn undecodable_bytes_are_not_a_thumbnail() { // What a photo in a format this build has no decoder for looks like: the // caller writes it off as un-thumbnailable rather than retrying forever. - assert!(scale_thumbnail(b"not an image at all", "broken.jpg").is_none()); + assert!(matches!( + scale_thumbnail(b"not an image at all", "broken.jpg", &std::env::temp_dir()), + ScaleAttempt::Undecodable + )); assert!(ratio_of(b"not an image at all").is_none()); } @@ -1150,9 +1391,11 @@ mod thumb_tests { #[test] fn raw_staging_file_is_private_and_removed_on_drop() { - let file = - RawTempFile::create(b"private camera bytes", "shot.NEF").expect("temporary RAW file"); + let staging_dir = std::env::temp_dir(); + let file = RawTempFile::create_in(&staging_dir, b"private camera bytes", "shot.NEF") + .expect("temporary RAW file"); let path = file.0.clone(); + assert_eq!(path.parent(), Some(staging_dir.as_path())); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); assert_eq!( @@ -1172,7 +1415,10 @@ mod thumb_tests { let path = std::path::PathBuf::from(path); let name = path.file_name().unwrap().to_string_lossy(); let bytes = std::fs::read(&path).unwrap(); - let thumb = scale_thumbnail(&bytes, &name).expect("embedded RAW preview"); + let ScaleAttempt::Made(thumb) = scale_thumbnail(&bytes, &name, &std::env::temp_dir()) + else { + panic!("embedded RAW preview"); + }; let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) .with_guessed_format() .unwrap() @@ -1192,6 +1438,7 @@ mod thumb_tests { "camera.NEF", "negative.cr3", "sensor.orf", + "camera.raw", ] { assert!(is_thumbnail_image_name(name), "{name}"); } @@ -1203,6 +1450,7 @@ mod thumb_tests { "unsupported.heic", "unsupported.avif", "vector.svg", + "camera.rw", ] { assert!(!is_thumbnail_image_name(name), "{name}"); } @@ -1224,4 +1472,30 @@ mod thumb_tests { assert_eq!(exiftool_binary(object, "ThumbnailImage"), None); assert_eq!(exiftool_binary(object, "Missing"), None); } + + #[test] + fn missing_exiftool_is_retryable_not_a_permanent_raw_miss() { + let attempt = scale_thumbnail_with_exiftool( + b"camera bytes that require an embedded preview", + "shot.raw", + &std::env::temp_dir(), + OsStr::new("pdfs-exiftool-deliberately-absent"), + Duration::from_millis(10), + ); + assert!(matches!(attempt, ScaleAttempt::Unavailable)); + } + + #[test] + fn a_running_build_only_accepts_the_same_root() { + let status = ThumbnailBuildStatus { + running: true, + path: "pictures/first".into(), + ..Default::default() + }; + assert!(!thumbnail_build_may_start(&status, "pictures/first").unwrap()); + assert!(thumbnail_build_may_start(&status, "pictures/second").is_err()); + + let idle = ThumbnailBuildStatus::default(); + assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap()); + } } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 97c942d..15caf8a 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -407,6 +407,7 @@ fn build_window(app: &adw::Application) { thumbnail_status: browser_widgets.thumbnail_status.clone(), thumbnail_poll: RefCell::new(None), thumbnail_build_running: Cell::new(false), + thumbnail_cancel_pending: Cell::new(false), search_source: RefCell::new(None), load_generation: Cell::new(0), views: browser_widgets.views.clone(), diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 03a675f..b819a16 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -35,6 +35,7 @@ pub(crate) struct BrowserState { pub(crate) thumbnail_status: gtk4::Label, pub(crate) thumbnail_poll: RefCell>, pub(crate) thumbnail_build_running: Cell, + pub(crate) thumbnail_cancel_pending: Cell, /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. pub(crate) search_source: RefCell>, @@ -1469,7 +1470,13 @@ pub(crate) fn wire_browser_actions( let ui_uf = ui.clone(); upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); let ui_thumbs = ui.clone(); - build_thumbnails.connect_clicked(move |_| start_thumbnail_build(&ui_thumbs)); + build_thumbnails.connect_clicked(move |_| { + if ui_thumbs.browser.thumbnail_build_running.get() { + cancel_thumbnail_build(&ui_thumbs); + } else { + start_thumbnail_build(&ui_thumbs); + } + }); } const THUMBNAIL_BUILD_POLL: Duration = Duration::from_millis(500); @@ -1488,7 +1495,10 @@ fn start_thumbnail_build(ui: &Rc) { } cancel_file_thumbnails(ui); ui.browser.thumbnail_build_running.set(true); - ui.browser.build_thumbnails.set_sensitive(false); + // Do not expose Cancel until the daemon has acknowledged Start; the two + // requests use separate control connections and could otherwise reorder. + ui.browser.thumbnail_cancel_pending.set(true); + repaint_thumbnail_build_action(ui, true); ui.browser.thumbnail_build_row.set_visible(true); ui.browser.thumbnail_progress.set_fraction(0.0); ui.browser @@ -1504,21 +1514,61 @@ fn start_thumbnail_build(ui: &Rc) { glib::spawn_future_local(async move { match rx.recv().await { Ok(Ok(Response::ThumbnailBuild { status })) => { + ui.browser.thumbnail_cancel_pending.set(false); repaint_thumbnail_build(&ui, &status); } Ok(Ok(Response::Error { message, kind })) => { - thumbnail_build_failed(&ui, &message); + thumbnail_build_failed(&ui); toast_failure(&ui, "Couldn't build thumbnails", &message, kind); } _ => { let message = "The mount service didn't respond."; - thumbnail_build_failed(&ui, message); + thumbnail_build_failed(&ui); toast_error(&ui, "Couldn't build thumbnails", message); } } }); } +fn cancel_thumbnail_build(ui: &Rc) { + if ui.browser.thumbnail_cancel_pending.replace(true) { + return; + } + ui.browser.build_thumbnails.set_sensitive(false); + ui.browser + .thumbnail_status + .set_label("Stopping thumbnail build…"); + let rx = spawn_request(ui.dirs.control_socket(), Request::CancelThumbnailBuild); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::ThumbnailBuild { status })) if !status.running => { + repaint_thumbnail_build(&ui, &status) + } + Ok(Ok(Response::ThumbnailBuild { .. })) => schedule_thumbnail_build_poll(&ui), + Ok(Ok(Response::Error { message, kind })) => { + thumbnail_cancel_failed(&ui); + toast_failure(&ui, "Couldn't cancel thumbnail build", &message, kind); + } + _ => { + thumbnail_cancel_failed(&ui); + toast_error( + &ui, + "Couldn't cancel thumbnail build", + "The mount service didn't respond.", + ); + } + } + }); +} + +fn thumbnail_cancel_failed(ui: &Rc) { + ui.browser.thumbnail_cancel_pending.set(false); + ui.browser.thumbnail_build_running.set(true); + repaint_thumbnail_build_action(ui, true); + schedule_thumbnail_build_poll(ui); +} + fn schedule_thumbnail_build_poll(ui: &Rc) { if let Some(source) = ui.browser.thumbnail_poll.borrow_mut().take() { source.remove(); @@ -1534,12 +1584,17 @@ fn schedule_thumbnail_build_poll(ui: &Rc) { repaint_thumbnail_build(&ui_result, &status) } Ok(Ok(Response::Error { message, .. })) => { - thumbnail_build_failed(&ui_result, &message) + thumbnail_build_failed(&ui_result); + toast_error(&ui_result, "Thumbnail build stopped", &message); + } + _ => { + thumbnail_build_failed(&ui_result); + toast_error( + &ui_result, + "Thumbnail build stopped", + "The mount service stopped reporting thumbnail progress.", + ); } - _ => thumbnail_build_failed( - &ui_result, - "The mount service stopped reporting thumbnail progress.", - ), } }); }); @@ -1593,13 +1648,12 @@ fn repaint_thumbnail_build(ui: &Rc, status: &ThumbnailBuildStatus) { if status.running { ui.browser.thumbnail_build_running.set(true); - ui.browser.build_thumbnails.set_sensitive(false); + repaint_thumbnail_build_action(ui, true); schedule_thumbnail_build_poll(ui); } else { ui.browser.thumbnail_build_running.set(false); - ui.browser - .build_thumbnails - .set_sensitive(*ui.mounted.borrow()); + ui.browser.thumbnail_cancel_pending.set(false); + repaint_thumbnail_build_action(ui, false); if ui.stack.visible_child_name().as_deref() == Some("browser") { reload_listing(ui); } @@ -1612,15 +1666,32 @@ fn show_thumbnail_build_progress(status: &ThumbnailBuildStatus) -> bool { status.running } -fn thumbnail_build_failed(ui: &Rc, message: &str) { - ui.browser.thumbnail_build_running.set(false); +fn repaint_thumbnail_build_action(ui: &Rc, running: bool) { + ui.browser.build_thumbnails.set_icon_name(if running { + "process-stop-symbolic" + } else { + "pdfs-build-thumbnails-symbolic" + }); ui.browser .build_thumbnails - .set_sensitive(*ui.mounted.borrow()); - ui.browser.thumbnail_build_row.set_visible(true); + .set_tooltip_text(Some(if running { + "Cancel thumbnail build" + } else { + "Build thumbnails in this folder and its subfolders" + })); + ui.browser.build_thumbnails.set_sensitive( + *ui.mounted.borrow() && (!running || !ui.browser.thumbnail_cancel_pending.get()), + ); +} + +fn thumbnail_build_failed(ui: &Rc) { + ui.browser.thumbnail_build_running.set(false); + ui.browser.thumbnail_cancel_pending.set(false); + repaint_thumbnail_build_action(ui, false); + ui.browser.thumbnail_build_row.set_visible(false); ui.browser.thumbnail_progress.set_fraction(0.0); - ui.browser.thumbnail_status.set_label(message); - ui.browser.thumbnail_status.set_tooltip_text(Some(message)); + ui.browser.thumbnail_status.set_label(""); + ui.browser.thumbnail_status.set_tooltip_text(None); } /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 8f7c1a4..2f32734 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -683,9 +683,11 @@ pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { ui.browser.new_folder.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted); ui.browser.upload_folder.set_sensitive(mounted); - ui.browser - .build_thumbnails - .set_sensitive(mounted && !ui.browser.thumbnail_build_running.get()); + ui.browser.build_thumbnails.set_sensitive( + mounted + && (!ui.browser.thumbnail_build_running.get() + || !ui.browser.thumbnail_cancel_pending.get()), + ); ui.gallery.upload.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted); diff --git a/crates/pdfs-gui/src/app/widgets/thumbnails.rs b/crates/pdfs-gui/src/app/widgets/thumbnails.rs index f69614b..d6124ab 100644 --- a/crates/pdfs-gui/src/app/widgets/thumbnails.rs +++ b/crates/pdfs-gui/src/app/widgets/thumbnails.rs @@ -7,7 +7,6 @@ use crate::*; use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name}; -use std::time::{SystemTime, UNIX_EPOCH}; const THUMB_BATCH: usize = 32; const THUMB_CACHE_MAX: usize = 256; @@ -25,6 +24,27 @@ pub(crate) struct FileThumbnailState { inflight: Cell, source: RefCell>, generation: Cell, + generation_loading: Cell, +} + +#[derive(Debug, PartialEq, Eq)] +enum ReplyDisposition { + Ignore, + Load, + Retry, + Missing, +} + +fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition { + if !wanted { + ReplyDisposition::Ignore + } else if has_path { + ReplyDisposition::Load + } else if pending { + ReplyDisposition::Retry + } else { + ReplyDisposition::Missing + } } impl FileThumbnailState { @@ -38,13 +58,8 @@ impl FileThumbnailState { queue: RefCell::new(VecDeque::new()), inflight: Cell::new(false), source: RefCell::new(None), - generation: Cell::new( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - .min(u128::from(u64::MAX)) as u64, - ), + generation: Cell::new(0), + generation_loading: Cell::new(false), } } @@ -84,14 +99,17 @@ pub(crate) fn cancel_file_thumbnails(ui: &Rc) { } ui.file_thumbs.queue.borrow_mut().clear(); ui.file_thumbs.wanted.borrow_mut().clear(); - let generation = ui.file_thumbs.generation.get().saturating_add(1); - ui.file_thumbs.generation.set(generation); + let generation = ui.file_thumbs.generation.replace(0); // The request is useful even when its reply is not. `spawn_request` owns the // socket round-trip on a worker thread, so dropping the receiver is safe. - drop(spawn_request( - ui.dirs.control_socket(), - Request::CancelFileThumbs { generation }, - )); + if generation != 0 { + drop(spawn_request( + ui.dirs.control_socket(), + Request::CancelFileThumbs { + generation: generation.saturating_add(1), + }, + )); + } } /// A square thumbnail surface with a generic file icon underneath the picture. @@ -244,6 +262,10 @@ fn schedule_file_thumbs(ui: &Rc) { if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() { return; } + if ui.file_thumbs.generation.get() == 0 { + reserve_file_thumb_generation(ui); + return; + } if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { source.remove(); } @@ -255,6 +277,40 @@ fn schedule_file_thumbs(ui: &Rc) { *ui.file_thumbs.source.borrow_mut() = Some(source); } +fn reserve_file_thumb_generation(ui: &Rc) { + if ui.file_thumbs.generation_loading.replace(true) { + return; + } + let rx = spawn_request( + ui.dirs.control_socket(), + Request::ReserveFileThumbGeneration, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.file_thumbs.generation_loading.set(false); + match result { + Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => { + ui.file_thumbs.generation.set(generation); + schedule_file_thumbs(&ui); + } + _ => retry_file_thumb_generation(&ui), + } + }); +} + +fn retry_file_thumb_generation(ui: &Rc) { + if let Some(source) = ui.file_thumbs.source.borrow_mut().take() { + source.remove(); + } + let ui_retry = ui.clone(); + let source = glib::timeout_add_local_once(THUMB_RETRY, move || { + ui_retry.file_thumbs.source.borrow_mut().take(); + schedule_file_thumbs(&ui_retry); + }); + *ui.file_thumbs.source.borrow_mut() = Some(source); +} + fn flush_file_thumbs(ui: &Rc) { if ui.file_thumbs.inflight.get() { return; @@ -291,51 +347,63 @@ fn flush_file_thumbs(ui: &Rc) { ui.file_thumbs.inflight.set(false); match result { Ok(Ok(Response::Thumbs { items: replies })) => { - let requested: HashMap = items + let mut requested: HashMap = items .iter() .cloned() .map(|item| (item.uid.clone(), item)) .collect(); let mut pending = Vec::new(); for reply in replies { - let Some(request) = requested.get(&reply.uid) else { + let Some(request) = requested.remove(&reply.uid) else { continue; }; let key = thumbnail_key(&request.uid, request.modified); - match reply.path { - Some(path) => match gtk4::gdk::Texture::from_filename(&path) { - Ok(texture) => { - ui.file_thumbs.store_texture(&key, texture.clone()); - if let Some(pictures) = - ui.file_thumbs.wanted.borrow_mut().remove(&key) - { - for picture in pictures { - paint(&picture, &key, &texture); + let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key); + match reply_disposition(wanted, reply.path.is_some(), reply.pending) { + ReplyDisposition::Ignore => continue, + ReplyDisposition::Load => { + let Some(path) = reply.path else { continue }; + match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => { + ui.file_thumbs.store_texture(&key, texture.clone()); + if let Some(pictures) = + ui.file_thumbs.wanted.borrow_mut().remove(&key) + { + for picture in pictures { + paint(&picture, &key, &texture); + } } } + Err(e) => { + tracing::debug!("cannot decode file thumbnail {path}: {e}"); + ui.file_thumbs.store_missing(key.clone()); + ui.file_thumbs.wanted.borrow_mut().remove(&key); + } } - Err(e) => { - tracing::debug!("cannot decode file thumbnail {path}: {e}"); - ui.file_thumbs.store_missing(key.clone()); - ui.file_thumbs.wanted.borrow_mut().remove(&key); - } - }, - None if reply.pending => pending.push(request.clone()), - None => { + } + ReplyDisposition::Retry => pending.push(request), + ReplyDisposition::Missing => { ui.file_thumbs.store_missing(key.clone()); ui.file_thumbs.wanted.borrow_mut().remove(&key); } } } + pending.extend(requested.into_values()); if !pending.is_empty() { retry_file_thumbs(&ui, pending); } } + Ok(Ok(Response::FileThumbsStale)) => { + ui.file_thumbs.generation.set(0); + retry_file_thumbs(&ui, items); + } Ok(Ok(Response::Error { message, .. })) => { tracing::debug!("file thumbnails failed: {message}"); + retry_file_thumbs(&ui, items); } Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { tracing::debug!("file thumbnails: no reply"); + retry_file_thumbs(&ui, items); } } schedule_file_thumbs(&ui); @@ -358,7 +426,7 @@ fn retry_file_thumbs(ui: &Rc, items: Vec) { #[cfg(test)] mod tests { - use super::is_image_name; + use super::{ReplyDisposition, is_image_name, reply_disposition}; #[test] fn image_extensions_are_case_insensitive_and_specific() { @@ -369,6 +437,7 @@ mod tests { "archive.tiff", "camera.nef", "negative.CR3", + "camera.RAW", ] { assert!(is_image_name(name), "{name}"); } @@ -380,8 +449,21 @@ mod tests { "camera.heic", "frame.avif", "graphic.svg", + "camera.rw", ] { assert!(!is_image_name(name), "{name}"); } } + + #[test] + fn cancelled_listing_replies_cannot_become_permanent_misses() { + assert_eq!( + reply_disposition(false, false, false), + ReplyDisposition::Ignore + ); + assert_eq!( + reply_disposition(true, false, false), + ReplyDisposition::Missing + ); + } }