file manager
git clone https://git.lucas.co/cce-files.git
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) {