git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commit31a4fedceca3e2c49285a8ef88439968a0df2c6b
parent17afb4456d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 11:40
fix(preview): read only on a change, drop superseded reads

- Every browse message re-read the selected file's preview: a watcher
  refresh, a search keystroke, a hidden toggle. Each read decodes and
  resizes the image and spawns xdg-mime twice. The app now remembers what
  it asked for (path + stat stamp of link and target) and asks again only
  when either changes. 20 writes beside a selected 4000x3000 PNG: 2590 ms
  CPU -> 40 ms.
- Reads carry a generation. A superseded one is skipped before it starts
  and dropped if it finishes, so a slow decode no longer paints over the
  file selected after it (note -> image -> note left the image showing).
  Clearing the pane retires the read in flight too.
- Preview reads run on the blocking pool instead of an async worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs          | 91 +++++++++++++++++++++++++++++++++++++++++++++++-----
 src/pages/preview.rs |  6 ++--
 src/services/fs.rs   | 30 ++++++++++++++---
 3 files changed, 112 insertions(+), 15 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 06e1e95..b120d6f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -335,6 +335,26 @@ fn breadcrumb_menu_rows(path: String, current: Page) -> (Vec<String>, Vec<Option
 /// the container copies just coincided (the Phase 0 double-paint) — and (b) own the
 /// divider: its quad, hover tint, and proportion drag. This is (b), app-side, with the
 /// `SplitBox` two-child horizontal math verbatim.
+/// A file as `stat` sees it: what decides whether a preview already read
+/// is still the file's. The link itself and, through it, its target — a
+/// symlink to a file being rewritten keeps its own stamp.
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PreviewStamp {
+    link: Option<(u64, u64, std::time::SystemTime)>,
+    target: Option<(u64, u64, std::time::SystemTime)>,
+}
+
+impl PreviewStamp {
+    fn of(path: &std::path::Path) -> Self {
+        use std::os::unix::fs::MetadataExt;
+        let key = |m: std::fs::Metadata| (m.ino(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH));
+        Self {
+            link: std::fs::symlink_metadata(path).ok().map(key),
+            target: std::fs::metadata(path).ok().map(key),
+        }
+    }
+}
+
 /// Width of the preview pane's column while collapsed to its title stub:
 /// room for the label and the corner control that restores it.
 const PREVIEW_STUB_W: f32 = 170.0;
@@ -500,6 +520,16 @@ struct FilesystemApp {
     network: pages::network::NetworkState,
     space: pages::space::SpaceState,
     preview: cce_files::preview_pane::PreviewPane,
+    /// What the preview was last asked for: the path, and the file as it
+    /// stood then. Every browse message — a watcher refresh, a search
+    /// keystroke — used to ask again, and each ask decodes and resizes an
+    /// image and spawns `xdg-mime` twice; with a download growing beside a
+    /// selected photo that was a full decode per refresh. Now a request goes
+    /// out only when this changes.
+    preview_requested: Option<(std::path::PathBuf, PreviewStamp)>,
+    /// Generation of the newest preview request; a `PreviewLoaded` from any
+    /// other is a selection the user has already moved past.
+    preview_generation: u64,
     /// See [`FocusedWell`]; drives the per-well `focused` flags each rebuild.
     focused_well: FocusedWell,
 
@@ -588,6 +618,40 @@ impl FilesystemApp {
         self.context_menu = ContextMenu { visible: true, x, y, w, h, options, hovered: None };
     }
 
+    /// Ask for `path`'s preview unless that is what was last asked for and
+    /// the file has not changed since. `force` asks regardless (the texture
+    /// was lost with its renderer).
+    fn request_preview(&mut self, path: std::path::PathBuf, force: bool) {
+        let stamp = PreviewStamp::of(&path);
+        if !force
+            && self
+                .preview_requested
+                .as_ref()
+                .is_some_and(|(p, st)| *p == path && *st == stamp)
+        {
+            return;
+        }
+        self.preview_requested = Some((path.clone(), stamp));
+        let generation = self.next_preview_generation();
+        self.fs_service.send(services::fs::FsRequest::ReadPreview(path, generation));
+    }
+
+    /// Empty the preview pane. Also retires whatever request is in flight,
+    /// so a slow read cannot fill the pane back in after it was cleared.
+    fn clear_preview(&mut self) {
+        self.preview_requested = None;
+        self.next_preview_generation();
+        pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
+    }
+
+    fn next_preview_generation(&mut self) -> u64 {
+        self.preview_generation += 1;
+        self.fs_service
+            .preview_latest
+            .store(self.preview_generation, std::sync::atomic::Ordering::Relaxed);
+        self.preview_generation
+    }
+
     fn start_watching(&mut self, path: std::path::PathBuf) {
         use tokio::sync::mpsc;
         use std::time::Duration;
@@ -1436,6 +1500,8 @@ impl Application for FilesystemApp {
             network: pages::network::NetworkState::default(),
             space: pages::space::SpaceState::default(),
             preview: Default::default(),
+            preview_requested: None,
+            preview_generation: 0,
             focused_well: FocusedWell::Content,
             preview_dock: Default::default(),
             preview_prior_fracs: None,
@@ -1572,12 +1638,13 @@ impl Application for FilesystemApp {
                     self.ensure_space_scan();
                 }
 
-                // If NavigateTo or SelectEntry happened, update Preview path
+                // Whatever the message, the preview follows the selection —
+                // and only re-reads when it, or the selected file, changed.
                 let selected_path = self.browse.selected_path();
                 if let Some(path) = selected_path {
-                    self.fs_service.send(services::fs::FsRequest::ReadPreview(path));
+                    self.request_preview(path, false);
                 } else {
-                    pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
+                    self.clear_preview();
                 }
 
                 if let Some(idx) = self.browse.selected {
@@ -1606,6 +1673,13 @@ impl Application for FilesystemApp {
                 }
             }
             Message::Preview(msg) => {
+                if let pages::preview::PreviewMessage::PreviewLoaded { generation, .. } = &msg {
+                    // A read the selection has moved past: a slow decode
+                    // must not paint over the file now selected.
+                    if *generation != self.preview_generation {
+                        return;
+                    }
+                }
                 pages::preview::update(&mut self.preview, msg);
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
@@ -1803,7 +1877,7 @@ impl Application for FilesystemApp {
                     "[preview] renderer replaced; re-reading the preview of {}",
                     path.display()
                 );
-                self.fs_service.send(services::fs::FsRequest::ReadPreview(path));
+                self.request_preview(path, true);
             }
         }
         self.needs_rebuild = true;
@@ -2583,21 +2657,22 @@ impl Application for FilesystemApp {
                                     self.browse.save_name_box.edit_buffer = entry.name.clone();
                                 }
                             }
-                            self.fs_service.send(services::fs::FsRequest::ReadPreview(entry.path.clone()));
+                            let path = entry.path.clone();
+                            self.request_preview(path, false);
                         }
                         changed = true;
                     }
                 } else {
                     if self.browse.selected.is_some() {
                         self.browse.selected = None;
-                        pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
+                        self.clear_preview();
                         changed = true;
                     }
                 }
             } else {
                 if self.browse.selected.is_some() {
                     self.browse.selected = None;
-                    pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
+                    self.clear_preview();
                     changed = true;
                 }
             }
@@ -2665,7 +2740,7 @@ impl Application for FilesystemApp {
                         }
                     } else {
                         self.space.selected_path = Some(tile_path.clone());
-                        self.fs_service.send(services::fs::FsRequest::ReadPreview(tile_path));
+                        self.request_preview(tile_path, false);
                     }
                     changed = true;
                 }
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 76af54d..dce6ad7 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -8,7 +8,9 @@ use crate::preview_pane::PreviewPane;
 pub enum PreviewMessage {
     SetPath { path: PathBuf },
     Clear,
-    PreviewLoaded { path: PathBuf, data: crate::services::fs::PreviewData },
+    /// `generation` is the request's (`FsRequest::ReadPreview`); the app
+    /// drops any but the newest before it gets here.
+    PreviewLoaded { path: PathBuf, generation: u64, data: crate::services::fs::PreviewData },
 }
 
 // ── Update ──────────────────────────────────────────────────────────
@@ -24,7 +26,7 @@ pub fn update(state: &mut PreviewPane, msg: PreviewMessage) {
         PreviewMessage::SetPath { path: _ } => {
             // Deprecated direct SetPath, as we now load previews via the FsService.
         }
-        PreviewMessage::PreviewLoaded { path, data } => {
+        PreviewMessage::PreviewLoaded { path, data, .. } => {
             state.path_display = path.to_string_lossy().to_string();
             state.path = Some(path);
             state.name = data.name;
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 4c57dd3..fb59ab2 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -32,7 +32,11 @@ pub struct PreviewData {
 pub enum FsRequest {
     ReadDirectory(PathBuf),
     RefreshDirectory(PathBuf),
-    ReadPreview(PathBuf),
+    /// Read a selection's preview. The number is the request's generation
+    /// (`FsService::preview_latest` holds the newest): a read that has been
+    /// superseded before it starts is skipped, and its result, should it
+    /// finish anyway, is dropped by the app.
+    ReadPreview(PathBuf, u64),
     /// Move to the freedesktop trash (the default Delete).
     TrashPath(PathBuf),
     /// Unrecoverable delete — the trash's own rows, and "Delete Permanently".
@@ -54,11 +58,15 @@ pub enum FsRequest {
 
 pub struct FsService {
     pub sender: mpsc::Sender<FsRequest>,
+    /// Generation of the newest preview request.
+    pub preview_latest: std::sync::Arc<std::sync::atomic::AtomicU64>,
 }
 
 impl FsService {
     pub fn new(app_sender: calloop::channel::Sender<crate::Message>) -> Self {
         let (tx, mut rx) = mpsc::channel::<FsRequest>(100);
+        let preview_latest = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
+        let latest = preview_latest.clone();
 
         tokio::spawn(async move {
             while let Some(req) = rx.recv().await {
@@ -80,11 +88,23 @@ impl FsService {
                             ));
                         });
                     }
-                    FsRequest::ReadPreview(path) => {
-                        tokio::spawn(async move {
+                    FsRequest::ReadPreview(path, generation) => {
+                        // A full image decode plus two `xdg-mime` spawns:
+                        // blocking work, so the blocking pool. Arrowing down a
+                        // folder of photos queues one per row; the ones passed
+                        // over before their turn are never decoded.
+                        let latest = latest.clone();
+                        tokio::task::spawn_blocking(move || {
+                            if latest.load(std::sync::atomic::Ordering::Relaxed) != generation {
+                                return;
+                            }
                             let preview_data = load_preview_data_internal(&path);
                             let _ = app_sender.send(crate::Message::Preview(
-                                crate::pages::preview::PreviewMessage::PreviewLoaded { path, data: preview_data },
+                                crate::pages::preview::PreviewMessage::PreviewLoaded {
+                                    path,
+                                    generation,
+                                    data: preview_data,
+                                },
                             ));
                         });
                     }
@@ -201,7 +221,7 @@ impl FsService {
             }
         });
 
-        Self { sender: tx }
+        Self { sender: tx, preview_latest }
     }
 
     pub fn send(&self, req: FsRequest) {