Skip to main content

tui_file_explorer/explorer/
background.rs

1//! Off-thread work for the explorer: lazy item counts, recursive folder
2//! sizes and `git status`.
3//!
4//! Rendering asks for what the *visible* rows need; slow answers are computed
5//! on worker threads and picked up by [`FileExplorer::poll_background`] on a
6//! later frame. Until then the row shows a cheap placeholder (an item count),
7//! so the UI never blocks on a slow disk.
8
9use std::{
10    collections::{HashMap, HashSet},
11    ops::Range,
12    time::{Duration, Instant},
13};
14
15use super::*;
16use crate::{git, worker::Task};
17
18/// Folder-size walks allowed to run at once.
19const MAX_CONCURRENT_DIR_SIZES: usize = 4;
20
21/// Result of one folder-size walk: `(bytes, is_partial)`.
22type DirSize = (u64, bool);
23
24/// Cursor/selection to restore once a (possibly deferred) listing arrives.
25#[derive(Debug, Default, Clone)]
26pub(crate) struct AfterLoad {
27    /// Put the cursor on this path if it is in the new listing.
28    pub select: Option<PathBuf>,
29    /// Otherwise keep the cursor on this row index (clamped).
30    pub cursor: Option<usize>,
31}
32
33/// Outcome of reading a directory off-thread.
34type LoadResult = std::io::Result<(Vec<FsEntry>, Option<crate::DiskUsage>)>;
35
36/// A directory read running on a worker thread.
37#[derive(Debug)]
38pub(crate) struct LoadJob {
39    task: Task<LoadResult>,
40    after: AfterLoad,
41}
42
43/// How long [`FileExplorer::reload`] waits for a background read before
44/// giving up the foreground and showing a "loading…" state. Fast reads (the
45/// overwhelming majority) therefore complete synchronously with no flicker.
46const LOAD_GRACE: Duration = Duration::from_millis(25);
47
48/// In-flight and bookkeeping state; owned by [`FileExplorer`].
49#[derive(Debug, Default)]
50pub(crate) struct Background {
51    /// Running folder-size walks, with the item count they were started at
52    /// (the cache's staleness key).
53    dir_size_jobs: HashMap<PathBuf, (Task<DirSize>, usize)>,
54    /// Directories whose item count was already looked up since the last
55    /// reload (so unreadable directories are not retried every frame).
56    counted: HashSet<PathBuf>,
57    git_task: Option<Task<Option<git::GitStatus>>>,
58    /// Running directory read, when asynchronous loading is enabled.
59    load: Option<LoadJob>,
60}
61
62impl Background {
63    pub(crate) fn clear_counted(&mut self) {
64        self.counted.clear();
65    }
66
67    pub(crate) fn clear_dir_sizes(&mut self) {
68        self.dir_size_jobs.clear();
69    }
70
71    pub(crate) fn cancel_load(&mut self) {
72        self.load = None;
73    }
74}
75
76impl FileExplorer {
77    /// Fill in [`FsEntry::item_count`] for the directories in `range`.
78    ///
79    /// One cheap `read_dir` per visible directory — instead of one per entry
80    /// of a possibly enormous listing at load time.
81    pub(crate) fn fill_item_counts(&mut self, range: Range<usize>) {
82        for idx in range {
83            let Some(entry) = self.entries.get(idx) else {
84                break;
85            };
86            if !entry.is_dir || entry.item_count.is_some() {
87                continue;
88            }
89            let path = entry.path.clone();
90            if !self.bg.counted.insert(path.clone()) {
91                continue;
92            }
93            let count = self.filesystem.item_count(&path);
94            if let Some(entry) = self.entries.get_mut(idx) {
95                entry.item_count = count;
96            }
97        }
98    }
99
100    /// Start recursive-size walks for uncached directories in `range`.
101    ///
102    /// Call [`fill_item_counts`](Self::fill_item_counts) first: the count is
103    /// the cache's staleness key.
104    pub(crate) fn request_dir_sizes(&mut self, range: Range<usize>) {
105        for idx in range {
106            if self.bg.dir_size_jobs.len() >= MAX_CONCURRENT_DIR_SIZES {
107                break;
108            }
109            let Some(entry) = self.entries.get(idx) else {
110                break;
111            };
112            let (true, Some(count)) = (entry.is_dir, entry.item_count) else {
113                continue;
114            };
115            let path = &entry.path;
116            if self.bg.dir_size_jobs.contains_key(path) {
117                continue;
118            }
119            if self
120                .dir_size_cache
121                .get(path)
122                .is_some_and(|&(_, _, cached)| cached == count)
123            {
124                continue;
125            }
126            let fs = std::sync::Arc::clone(&self.filesystem);
127            let walk_path = path.clone();
128            let task = Task::spawn(move |emit| {
129                emit.send(fs.dir_size(&walk_path));
130            });
131            self.bg.dir_size_jobs.insert(path.clone(), (task, count));
132        }
133    }
134
135    // ── Directory loading ─────────────────────────────────────────────────
136
137    /// `true` while the directory listing is still being read in the
138    /// background (see [`FileExplorer::async_loading`]).
139    #[must_use]
140    pub fn is_loading(&self) -> bool {
141        self.bg.load.is_some()
142    }
143
144    /// Re-read the directory, then restore the cursor as described by
145    /// `after` — immediately when the read is quick (or loading is
146    /// synchronous), otherwise once it finishes.
147    pub(crate) fn reload_with(&mut self, after: AfterLoad) {
148        if !self.async_loading {
149            self.reload_sync(&after);
150            return;
151        }
152
153        let fs = std::sync::Arc::clone(&self.filesystem);
154        let dir = self.current_dir.clone();
155        let (show_hidden, filter, sort, query) = (
156            self.show_hidden,
157            self.extension_filter.clone(),
158            self.sort_mode,
159            self.search_query.clone(),
160        );
161        // Replacing the job drops (cancels) any read still in flight.
162        let mut task = Task::spawn(move |emit| {
163            let result = try_load_entries(fs.as_ref(), &dir, show_hidden, &filter, sort, &query)
164                .map(|entries| (entries, fs.disk_usage(&dir)));
165            emit.send(result);
166        });
167        self.status.clear();
168
169        if let Some(result) = task.wait_next(LOAD_GRACE) {
170            self.bg.load = None;
171            self.apply_load(result, &after);
172        } else {
173            // Slow disk: don't block the UI. Show an empty "loading" list
174            // (so no key can act on stale rows) and finish in `poll_background`.
175            self.entries.clear();
176            self.bg.load = Some(LoadJob { task, after });
177        }
178    }
179
180    /// Synchronous `reload` body, shared with the non-async path.
181    fn reload_sync(&mut self, after: &AfterLoad) {
182        self.bg.cancel_load();
183        self.status.clear();
184        let result = self.read_dir_now();
185        self.apply_load(result, after);
186    }
187
188    fn read_dir_now(&self) -> LoadResult {
189        try_load_entries(
190            self.filesystem.as_ref(),
191            &self.current_dir,
192            self.show_hidden,
193            &self.extension_filter,
194            self.sort_mode,
195            &self.search_query,
196        )
197        .map(|entries| (entries, self.filesystem.disk_usage(&self.current_dir)))
198    }
199
200    /// Install a finished listing (or its error) and restore the cursor.
201    fn apply_load(&mut self, result: LoadResult, after: &AfterLoad) {
202        match result {
203            Ok((entries, usage)) => {
204                self.entries = entries;
205                self.disk_usage = usage;
206                self.link_cache.clear();
207                self.bg.clear_counted();
208                self.refresh_git();
209                if let Some(row) = after.cursor {
210                    self.cursor = row;
211                }
212                // The list may have shrunk (filter change, external deletion).
213                self.clamp_cursor();
214                if let Some(path) = &after.select {
215                    self.select_path(path);
216                }
217            }
218            Err(source) => {
219                let error = ExplorerError {
220                    path: self.current_dir.clone(),
221                    source,
222                };
223                self.entries.clear();
224                self.disk_usage = None;
225                self.status = error.to_string();
226                self.clamp_cursor();
227            }
228        }
229    }
230
231    /// Collect finished background results. Returns `true` when something
232    /// changed and the view should be redrawn.
233    pub fn poll_background(&mut self) -> bool {
234        let mut changed = false;
235
236        // The directory listing comes first: everything else is about it.
237        if let Some(job) = &mut self.bg.load {
238            let results = job.task.poll();
239            if !results.is_empty() || job.task.is_done() {
240                let job = self.bg.load.take().expect("checked above");
241                for result in results {
242                    self.apply_load(result, &job.after);
243                }
244                changed = true;
245            }
246        }
247
248        let mut finished = Vec::new();
249        for (path, (task, count)) in &mut self.bg.dir_size_jobs {
250            for (bytes, partial) in task.poll() {
251                self.dir_size_cache
252                    .insert(path.clone(), (bytes, partial, *count));
253                changed = true;
254            }
255            if task.is_done() {
256                finished.push(path.clone());
257            }
258        }
259        for path in finished {
260            self.bg.dir_size_jobs.remove(&path);
261        }
262
263        if let Some(task) = &mut self.bg.git_task {
264            for status in task.poll() {
265                self.git = status;
266                changed = true;
267            }
268            if task.is_done() {
269                self.bg.git_task = None;
270            }
271        }
272        changed
273    }
274
275    /// `true` while any background computation is still running.
276    #[must_use]
277    pub fn has_pending_work(&self) -> bool {
278        self.bg.load.is_some() || !self.bg.dir_size_jobs.is_empty() || self.bg.git_task.is_some()
279    }
280
281    /// Block until background work finishes or `timeout` elapses.
282    ///
283    /// Intended for tests and headless callers; interactive UIs should call
284    /// [`poll_background`](Self::poll_background) once per frame instead.
285    pub fn wait_for_background(&mut self, timeout: Duration) {
286        let deadline = Instant::now() + timeout;
287        loop {
288            self.poll_background();
289            if !self.has_pending_work() || Instant::now() >= deadline {
290                return;
291            }
292            std::thread::sleep(Duration::from_millis(2));
293        }
294    }
295
296    // ── git ───────────────────────────────────────────────────────────────
297
298    /// (Re)start a `git status` for the current directory when git markers
299    /// are enabled and the backend is a real filesystem.
300    pub(crate) fn refresh_git(&mut self) {
301        if !self.show_git || !self.filesystem.is_local() {
302            self.bg.git_task = None;
303            return;
304        }
305        let dir = self.current_dir.clone();
306        self.bg.git_task = Some(Task::spawn(move |emit| {
307            emit.send(git::status_for(&dir));
308        }));
309    }
310
311    /// Turn the git change column on or off.
312    pub fn set_show_git(&mut self, show: bool) {
313        self.show_git = show;
314        if show {
315            self.refresh_git();
316        } else {
317            self.git = None;
318            self.bg.git_task = None;
319        }
320    }
321
322    /// The git marker for the entry at `path`, if any.
323    #[must_use]
324    pub fn git_mark(&self, path: &Path) -> Option<git::GitMark> {
325        self.git.as_ref()?.mark(path)
326    }
327}