From a9de57d6cdbec8ddddd9e6617e16a82ca70cdd3c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 23 Oct 2026 09:08:24 -0600 Subject: [PATCH] feat(viewer): thumbnails drawn at load, kept on disk by hash; no picture crashes it Tiles draw a thumbnail, not the file: the top of the first page, 560 pixels wide (never scaled up) and at most 420 tall, rendered by two workers of their own so opening a file never waits behind them. Every file's thumbnail starts when the books load, before the documents page is opened, and the live viewer keeps each as .png in $XDG_CACHE_HOME (else ~/.cache)/wanderbooks/thumbs/v1-560 (private to the user, written whole and renamed into place), so the next launch reads small PNGs, not PDFs. A file's name is its content's hash, so a thumbnail always matches the file the books hold; the blob is still read through the verifying store each time, so a corrupt or missing file is an error, never a kept picture. From review: a picture or page past the device's largest texture is scaled to fit (egui panicked on it, in the full view too); a renderer panic is that file's error, not the end of its worker; a thumbnail asked for before the cache moved is dropped, never shown over a newer one; text thumbnails keep 2 KiB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-gui/src/app.rs | 19 ++ crates/ledger-gui/src/pages/documents.rs | 5 +- crates/ledger-gui/src/receipts.rs | 369 ++++++++++++++++++++-- crates/ledger-gui/tests/text_documents.rs | 97 ++++++ 4 files changed, 454 insertions(+), 36 deletions(-) diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 4d46e39..3c2600f 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -163,6 +163,7 @@ impl App { ); let mut app = Self::bare(&cc.egui_ctx, scheme); app.filter = filter; + app.receipts.thumb_dir = crate::receipts::thumb_dir(); app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); app } @@ -170,6 +171,7 @@ impl App { /// An app showing fixed books (tests and screenshots). pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self { let mut app = Self::bare(ctx, schemes::own(dark)); + app.receipts.prefetch(&books); app.books = Some(Arc::new(books)); app.nav = Nav::new(start); app @@ -369,6 +371,22 @@ impl App { } } + /// Keep thumbnails on disk in `dir` (the live viewer keeps them in + /// the user's cache directory, `wanderbooks/thumbs`); tests point it + /// at their own. + pub fn keep_thumbs_in(&mut self, dir: std::path::PathBuf) { + self.receipts.thumb_dir = Some(dir); + self.receipts.forget_thumbs(); + if let Some(b) = self.books.clone() { + self.receipts.prefetch(&b); + } + } + + /// The size in pixels of a file's thumbnail, once drawn (tests). + pub fn thumb_size(&self, sha: &str) -> Option<[usize; 2]> { + self.receipts.thumb_size(sha) + } + /// Block until every receipt on screen has rendered (screenshots). pub fn settle(&mut self, ctx: &egui::Context) { self.receipts.settle(ctx); @@ -1622,6 +1640,7 @@ impl eframe::App for App { if self.books.is_some() { self.updated_at = ui.input(|i| i.time); } + self.receipts.prefetch(&b); self.books = Some(b); self.error = None; } diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index 278b7c4..3c58fe3 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -140,7 +140,10 @@ fn tile( let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0)); ui.painter() .rect_filled(thumb, CornerRadius::same(6), p.hover); - match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) { + match shell + .receipts + .thumb(&a.blob_sha, &a.mime_type, b.blob_root()) + { Render::Ready(pages) => { if let Some(tex) = pages.first() { // Top of the first page, filling the width. diff --git a/crates/ledger-gui/src/receipts.rs b/crates/ledger-gui/src/receipts.rs index e327c3e..52e957c 100644 --- a/crates/ledger-gui/src/receipts.rs +++ b/crates/ledger-gui/src/receipts.rs @@ -7,6 +7,15 @@ //! twice, and a hash is the content, so the cache never goes stale. Bytes //! are read through the verifying blob store: a corrupt blob shows as an //! error, never as a picture. +//! +//! Tiles draw a thumbnail, not the file: its first page, [`THUMB_PX`] +//! wide, rendered by workers of their own (opening a file never waits +//! behind them). The live viewer keeps each thumbnail on disk as +//! `.png` ([`thumb_dir`]) and starts every one the books hold when +//! they load ([`Receipts::prefetch`]), so a tile's picture is there +//! before the page is opened and the next launch reads small PNGs, not +//! PDFs. A file's name is its content's hash, so a thumbnail always +//! matches the file the books hold: a changed file is another hash. use egui::{ColorImage, Context, TextureHandle, TextureOptions}; use ledger_store::BlobStore; @@ -35,19 +44,63 @@ enum Rendered { /// rather than lay out megabytes of text every frame. pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; +/// A thumbnail's width in pixels: a tile's picture is 260 points wide at +/// most, twice that on a high-density screen. +pub const THUMB_PX: f32 = 560.0; + +/// A thumbnail's greatest height in pixels: a tile shows only the top of +/// the first page (170 points of a 180 to 260 point width), so the rest +/// is cut, and a long receipt costs no more than a short one. +pub const THUMB_MAX_H: u32 = 420; + +/// The longest side of any texture drawn: GPUs refuse larger (8192 is +/// the least a wgpu device must take), and a refused texture panics. +pub const MAX_SIDE: f32 = 8192.0; + +/// A text thumbnail keeps only this much of its text: a tile shows a few +/// lines. +const THUMB_TEXT_BYTES: usize = 2048; + +/// Where the live viewer keeps thumbnails: the user's cache directory +/// (`$XDG_CACHE_HOME`, else `~/.cache`), `wanderbooks/thumbs/v1-560`; +/// the version and width name how they were drawn, so a change in either +/// is a new directory, never an old picture. None without a home. +pub fn thumb_dir() -> Option { + // A relative XDG_CACHE_HOME is ignored, as the XDG spec says. + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?; + Some(base.join("wanderbooks").join("thumbs").join("v1-560")) +} + struct Job { sha: String, mime: String, root: PathBuf, + /// Some: a thumbnail, kept in this directory when it is Some(Some). + thumb: Option>, + /// The thumbnails' generation when this was asked for. + generation: u64, } struct Done { sha: String, + thumb: bool, + generation: u64, result: Result, } pub struct Receipts { cache: HashMap, + thumbs: HashMap, + /// Where thumbnails are kept on disk; None keeps them in memory only + /// (tests and screenshots, unless they ask). + pub thumb_dir: Option, + /// Bumped when the thumbnails are forgotten: a thumbnail asked for + /// before is dropped when it comes, never shown over a newer one. + generation: u64, + thumb_jobs: Sender, /// egui_commonmark's layout cache, shared by every Markdown file. pub markdown: egui_commonmark::CommonMarkCache, jobs: Sender, @@ -56,37 +109,16 @@ pub struct Receipts { impl Receipts { pub fn new(ctx: &Context) -> Self { - let (jobs, job_rx) = channel::(); let (done_tx, done) = channel(); - let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx)); let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4)); - for i in 0..workers { - let ctx = ctx.clone(); - let job_rx = job_rx.clone(); - let done_tx = done_tx.clone(); - std::thread::Builder::new() - .name(format!("receipt-render-{i}")) - .spawn(move || { - loop { - let job = match job_rx.lock() { - Ok(rx) => rx.recv(), - Err(_) => return, - }; - let Ok(job) = job else { - return; - }; - let result = render(&job); - let _ = done_tx.send(Done { - sha: job.sha, - result, - }); - ctx.request_repaint(); - } - }) - .expect("spawn renderer"); - } + let jobs = workers_for(ctx, &done_tx, "receipt-render", workers); + let thumb_jobs = workers_for(ctx, &done_tx, "thumb-render", 2); Self { cache: HashMap::new(), + thumbs: HashMap::new(), + thumb_dir: None, + generation: 0, + thumb_jobs, markdown: egui_commonmark::CommonMarkCache::default(), jobs, done, @@ -101,11 +133,53 @@ impl Receipts { sha: sha.to_string(), mime: mime.to_string(), root, + thumb: None, + generation: 0, }); } &self.cache[sha] } + /// A blob's thumbnail (its first page, [`THUMB_PX`] wide; a text + /// file's text), starting it if needed. + pub fn thumb(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State { + if !self.thumbs.contains_key(sha) { + self.thumbs.insert(sha.to_string(), State::Loading); + let _ = self.thumb_jobs.send(Job { + sha: sha.to_string(), + mime: mime.to_string(), + root, + thumb: Some(self.thumb_dir.clone()), + generation: self.generation, + }); + } + &self.thumbs[sha] + } + + /// Start the thumbnail of every file the books hold, so each tile's + /// picture is ready before its page is opened. Files already started + /// cost nothing; call it whenever books load. + pub fn prefetch(&mut self, books: &crate::books::Books) { + let root = books.blob_root(); + for a in &books.attachments { + self.thumb(&a.blob_sha, &a.mime_type, root.clone()); + } + } + + /// Drop every thumbnail held in memory (their directory changed). + pub fn forget_thumbs(&mut self) { + self.thumbs.clear(); + self.generation += 1; + } + + /// A drawn thumbnail's size in pixels. + pub fn thumb_size(&self, sha: &str) -> Option<[usize; 2]> { + match self.thumbs.get(sha)? { + State::Ready(pages) => pages.first().map(|t| t.size()), + _ => None, + } + } + /// Turn finished renders into textures. Call once per frame. pub fn pump(&mut self, ctx: &Context) { while let Ok(done) = self.done.try_recv() { @@ -114,6 +188,9 @@ impl Receipts { } fn accept(&mut self, ctx: &Context, done: Done) { + if done.thumb && done.generation != self.generation { + return; + } let state = match done.result { Ok(Rendered::Text { text, markdown }) => State::Text { text, markdown }, Ok(Rendered::Pages(images)) => State::Ready( @@ -121,8 +198,13 @@ impl Receipts { .into_iter() .enumerate() .map(|(i, img)| { + let img = fit_side(img, ctx.input(|i| i.max_texture_side)); ctx.load_texture( - format!("blob-{}-{i}", done.sha), + format!( + "{}-{}-{i}", + if done.thumb { "thumb" } else { "blob" }, + done.sha + ), img, TextureOptions::LINEAR, ) @@ -131,13 +213,21 @@ impl Receipts { ), Err(e) => State::Failed(e), }; - self.cache.insert(done.sha, state); + if done.thumb { + self.thumbs.insert(done.sha, state); + } else { + self.cache.insert(done.sha, state); + } } /// Wait until nothing is rendering (tests and screenshots). pub fn settle(&mut self, ctx: &Context) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); - while self.cache.values().any(|s| matches!(s, State::Loading)) + while self + .cache + .values() + .chain(self.thumbs.values()) + .any(|s| matches!(s, State::Loading)) && std::time::Instant::now() < deadline { if let Ok(done) = self @@ -150,6 +240,181 @@ impl Receipts { } } +/// Workers taking jobs from one queue, sending what they render to +/// `done`; the queue's sender. +fn workers_for(ctx: &Context, done_tx: &Sender, name: &str, n: usize) -> Sender { + let (jobs, job_rx) = channel::(); + let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx)); + for i in 0..n { + let ctx = ctx.clone(); + let job_rx = job_rx.clone(); + let done_tx = done_tx.clone(); + std::thread::Builder::new() + .name(format!("{name}-{i}")) + .spawn(move || { + loop { + let job = match job_rx.lock() { + Ok(rx) => rx.recv(), + Err(_) => return, + }; + let Ok(job) = job else { + return; + }; + // A file that crashes its renderer is that file's + // error, not the end of the worker (and every file + // queued after it). + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match &job.thumb { + Some(dir) => render_thumb(&job, dir.as_deref()), + None => render(&job), + } + })) + .unwrap_or_else(|_| Err("the renderer crashed on this file".into())); + let _ = done_tx.send(Done { + sha: job.sha, + thumb: job.thumb.is_some(), + generation: job.generation, + result, + }); + ctx.request_repaint(); + } + }) + .expect("spawn renderer"); + } + jobs +} + +/// A thumbnail: the top of the first page, [`THUMB_PX`] wide (never +/// scaled up) and at most [`THUMB_MAX_H`] tall. The blob is read through +/// the verifying store every time, so a corrupt or missing file is an +/// error, never a picture; its thumbnail is then read from `dir` when it +/// is there, else drawn (a PDF's first page, an image) and kept in `dir`. +/// A text file's thumbnail is the start of its text, kept nowhere: it is +/// cheaper to read than a picture. A thumbnail that can't be kept is +/// still shown. +fn render_thumb(job: &Job, dir: Option<&std::path::Path>) -> Result { + let bytes = BlobStore::open_read_only(&job.root) + .get(&job.sha) + .map_err(|e| e.to_string())?; + if let Some(markdown) = text_kind(&job.mime) + && !bytes.starts_with(b"%PDF") + { + let mut text = text_of(bytes)?; + if text.len() > THUMB_TEXT_BYTES { + let mut cut = THUMB_TEXT_BYTES; + while !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + } + return Ok(Rendered::Text { text, markdown }); + } + // A blob's hash names the file; anything else never names a path. + let named = job.sha.len() == 64 && job.sha.bytes().all(|b| b.is_ascii_hexdigit()); + let kept = dir + .filter(|_| named) + .map(|d| d.join(format!("{}.png", job.sha))); + if let Some(path) = &kept + && let Ok(png) = std::fs::read(path) + && let Ok(img) = image::load_from_memory(&png) + { + return Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())])); + } + let img = if job.mime == "application/pdf" || bytes.starts_with(b"%PDF") { + let png = pdf_first_page_png(bytes)?; + image::load_from_memory(&png).map_err(|e| e.to_string())? + } else if job.mime.starts_with("image/") { + let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?; + if img.width() as f32 > THUMB_PX { + let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0); + img.resize_exact( + THUMB_PX as u32, + h as u32, + image::imageops::FilterType::Triangle, + ) + } else { + img + } + } else { + return Err(format!("{} can't be previewed", job.mime)); + }; + let img = img.crop_imm(0, 0, img.width(), img.height().min(THUMB_MAX_H)); + if let Some(path) = &kept { + let mut png = Vec::new(); + if img + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .is_ok() + { + keep(path, &png); + } + } + Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())])) +} + +/// Write a thumbnail into the cache: its directory made private to the +/// user (it pictures receipts), the bytes written to a temporary name +/// and renamed into place. A failure only means it is drawn again. +fn keep(path: &std::path::Path, png: &[u8]) { + let Some(dir) = path.parent() else { + return; + }; + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + if builder.create(dir).is_err() { + return; + } + // Made before, it may be looser: the thumbnails are private all the same. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + // One name per write, across viewers (the pid) and this one's workers. + static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let tmp = dir.join(format!( + ".{}.{}.{}.tmp", + path.file_name().and_then(|n| n.to_str()).unwrap_or("thumb"), + std::process::id(), + WRITES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if std::fs::write(&tmp, png).is_ok() && std::fs::rename(&tmp, path).is_err() { + let _ = std::fs::remove_file(&tmp); + } +} + +/// A PDF's first page as a PNG, [`THUMB_PX`] wide. +fn pdf_first_page_png(bytes: Vec) -> Result, String> { + use hayro::hayro_interpret::InterpreterSettings; + use hayro::hayro_syntax::Pdf; + use hayro::vello_cpu::color::palette::css::WHITE; + use hayro::{PixmapSettings, RenderCache, RenderSettings, render}; + let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?; + let pages = pdf.pages(); + let page = pages.first().ok_or("the PDF has no pages")?; + let (w, h) = page.render_dimensions(); + // THUMB_PX wide, but never past a texture's side on a long page (the + // picture is cut to THUMB_MAX_H after), and never nothing. + let scale = (THUMB_PX / w.max(1.0)) + .min(MAX_SIDE / h.max(1.0)) + .max(1.0 / w.max(h).max(1.0)); + let px = PixmapSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + }; + let pixmap = render( + page, + &RenderCache::new(), + &InterpreterSettings::default(), + &RenderSettings::default(), + &px, + ); + pixmap.into_png().map_err(|e| format!("{e:?}")) +} + fn render(job: &Job) -> Result { let bytes = BlobStore::open_read_only(&job.root) .get(&job.sha) @@ -159,6 +424,16 @@ fn render(job: &Job) -> Result { render_pdf(bytes).map(Rendered::Pages) } else if job.mime.starts_with("image/") { let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?; + // Past a texture's side, scaled down to fit (aspect kept). + let img = if img.width().max(img.height()) as f32 > MAX_SIDE { + img.resize( + MAX_SIDE as u32, + MAX_SIDE as u32, + image::imageops::FilterType::Triangle, + ) + } else { + img + }; Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())])) } else if let Some(markdown) = text_kind(&job.mime) { let text = text_of(bytes)?; @@ -212,13 +487,16 @@ fn render_pdf(bytes: Vec) -> Result, String> { let cache = RenderCache::new(); let interp = InterpreterSettings::default(); let settings = RenderSettings::default(); - let px = PixmapSettings { - x_scale: 2.0, - y_scale: 2.0, - bg_color: WHITE, - }; let mut out = Vec::new(); for page in pdf.pages().iter().take(30) { + // Twice the page's size, but never past a texture's side. + let (w, h) = page.render_dimensions(); + let scale = 2.0f32.min(MAX_SIDE / w.max(h).max(1.0)); + let px = PixmapSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + }; let pixmap = render(page, &cache, &interp, &settings, &px); let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?; let img = image::load_from_memory(&png).map_err(|e| e.to_string())?; @@ -230,6 +508,27 @@ fn render_pdf(bytes: Vec) -> Result, String> { Ok(out) } +/// A picture no side of which is past `max` pixels (the device's +/// largest texture: egui panics on a larger one), scaled down with its +/// aspect kept; most pictures are already within it and pass untouched. +fn fit_side(img: ColorImage, max: usize) -> ColorImage { + let [w, h] = img.size; + if w.max(h) <= max || max == 0 { + return img; + } + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + let Some(buf) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { + return img; + }; + let scaled = image::DynamicImage::ImageRgba8(buf).resize( + max as u32, + max as u32, + image::imageops::FilterType::Triangle, + ); + let size = [scaled.width() as usize, scaled.height() as usize]; + ColorImage::from_rgba_premultiplied(size, scaled.to_rgba8().as_raw()) +} + fn to_color_image(img: image::RgbaImage) -> ColorImage { let size = [img.width() as usize, img.height() as usize]; ColorImage::from_rgba_unmultiplied(size, img.as_raw()) diff --git a/crates/ledger-gui/tests/text_documents.rs b/crates/ledger-gui/tests/text_documents.rs index 2758556..74a5e86 100644 --- a/crates/ledger-gui/tests/text_documents.rs +++ b/crates/ledger-gui/tests/text_documents.rs @@ -121,3 +121,100 @@ fn the_documents_page_draws_text_tiles() { shot(&mut h, "text-tiles"); assert_eq!(h.state_mut().route(), &Route::Documents); } + +/// A PNG `w` by `h` pixels. +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 90, 160, 255])); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// receipts.rs: every file's thumbnail is started when the books load, +/// before its page is opened; it is kept on disk as `.png`, at most +/// 560 pixels wide (a smaller picture is never scaled up), and the next +/// launch draws what was kept instead of the file. +#[test] +fn thumbnails_are_drawn_at_load_and_kept_by_hash() { + let dir = TempDir::new("thumbs"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + // The second of what filing gives is the file's SHA-256. + let (_, big_sha, _) = s + .file_document("photo", "wide.png", "image/png", &png(1200, 600), None) + .unwrap(); + let (_, small_sha, _) = s + .file_document("photo", "small.png", "image/png", &png(300, 200), None) + .unwrap(); + drop(s); + let thumbs = dir.0.join("thumbs"); + // The overview, not the documents page: the thumbnails come anyway. + let mut h = open(&dir, Route::Overview); + h.state_mut().keep_thumbs_in(thumbs.clone()); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + h.run_steps(2); + assert_eq!(h.state().thumb_size(&big_sha), Some([560, 280])); + assert_eq!(h.state().thumb_size(&small_sha), Some([300, 200])); + let kept = thumbs.join(format!("{big_sha}.png")); + let img = image::open(&kept).unwrap(); + assert_eq!((img.width(), img.height()), (560, 280)); + assert!(thumbs.join(format!("{small_sha}.png")).exists()); + // What was kept is what the next launch draws. + std::fs::write(&kept, png(7, 5)).unwrap(); + let mut h = open(&dir, Route::Documents); + h.state_mut().keep_thumbs_in(thumbs.clone()); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + assert_eq!(h.state().thumb_size(&big_sha), Some([7, 5])); +} + +/// receipts.rs: a long picture's thumbnail is its top, at most 420 +/// pixels tall; a picture past a texture's side opens scaled to fit +/// rather than crash the viewer; a file whose blob is gone shows as an +/// error even with its thumbnail kept. +#[test] +fn long_and_huge_pictures_are_cut_or_scaled_and_a_lost_file_is_an_error() { + let dir = TempDir::new("long"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let (_, long_sha, _) = s + .file_document("photo", "long.png", "image/png", &png(400, 9000), None) + .unwrap(); + let (huge, huge_sha, _) = s + .file_document("photo", "huge.png", "image/png", &png(9000, 300), None) + .unwrap(); + drop(s); + let thumbs = dir.0.join("thumbs"); + let mut h = open(&dir, Route::Document(huge)); + h.state_mut().keep_thumbs_in(thumbs.clone()); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + h.run_steps(2); + assert_eq!(h.state().thumb_size(&long_sha), Some([400, 420])); + assert_eq!(h.state().thumb_size(&huge_sha), Some([560, 18])); + assert!(h.query_by_label_contains("Can't show").is_none()); + // The blob gone: the kept thumbnail is not shown in its place. + let blob = walk(&dir.0.join("books-data"), &long_sha).expect("the blob's file"); + std::fs::remove_file(blob).unwrap(); + let mut h = open(&dir, Route::Documents); + h.state_mut().keep_thumbs_in(thumbs); + let ctx = h.ctx.clone(); + h.state_mut().settle(&ctx); + assert_eq!(h.state().thumb_size(&long_sha), None); +} + +/// The file under `dir` named `name`. +fn walk(dir: &std::path::Path, name: &str) -> Option { + for e in std::fs::read_dir(dir).ok()?.flatten() { + let p = e.path(); + if p.is_dir() { + if let Some(f) = walk(&p, name) { + return Some(f); + } + } else if p.file_name().and_then(|n| n.to_str()) == Some(name) { + return Some(p); + } + } + None +}