Skip to main content

trail/preview/
directory.rs

1//! Directory preview provider.
2//!
3//! Defers the directory listing to the async worker pool so that
4//! `fs::read_dir` on large or slow-filesystem directories never stalls the
5//! UI thread. The `Loading` placeholder is shown until the worker delivers
6//! the content; the generation-guard in `workers::merge` discards stale
7//! results when the user navigates away before the worker finishes.
8
9use std::fs;
10use std::path::PathBuf;
11
12use tokio::sync::mpsc;
13
14use crate::app::state::{Entry, EntryKind};
15use crate::preview::provider::{PreviewContent, PreviewCtx, PreviewOutcome, PreviewProvider};
16use crate::workers::WorkerMsg;
17
18/// Maximum number of child entry names shown in the directory preview.
19const MAX_PREVIEW_ENTRIES: usize = 64;
20
21/// Preview provider for directory entries.
22///
23/// Spawns an async worker task that runs `build_directory_preview` off the UI
24/// thread, returning `PreviewOutcome::Deferred` immediately so navigation
25/// remains responsive while the listing is being read.
26pub struct DirectoryProvider;
27
28impl PreviewProvider for DirectoryProvider {
29    fn can_handle(&self, entry: &Entry) -> bool {
30        entry.kind == EntryKind::Dir
31    }
32
33    fn preview(&self, entry: &Entry, ctx: &PreviewCtx) -> PreviewOutcome {
34        spawn_directory_preview(
35            entry.path.clone(),
36            ctx.show_hidden,
37            ctx.generation,
38            ctx.worker_tx.clone(),
39        );
40        PreviewOutcome::Deferred
41    }
42}
43
44/// Spawns an async task that builds a directory preview off the UI thread.
45///
46/// The task runs `build_directory_preview` inside `spawn_blocking` and sends
47/// the result as a `WorkerMsg::Preview` tagged with `generation`. Mirrors the
48/// pattern used by `workers::highlight::spawn_highlight`.
49fn spawn_directory_preview(
50    path: PathBuf,
51    show_hidden: bool,
52    generation: u64,
53    tx: mpsc::Sender<WorkerMsg>,
54) {
55    tokio::spawn(async move {
56        let path_for_msg = path.clone();
57        let content =
58            tokio::task::spawn_blocking(move || build_directory_preview(&path, show_hidden))
59                .await
60                .unwrap_or(PreviewContent::Empty);
61
62        let msg = WorkerMsg::Preview {
63            generation,
64            path: path_for_msg,
65            content,
66            // The directory preview's own entry cap is not reported here; only
67            // the text path tracks truncation against `[preview] max_lines`.
68            truncated: false,
69            // A directory is neither text nor binary; there is nothing to cache.
70            is_text: None,
71        };
72        // If the channel is closed the UI thread has exited; ignore the error.
73        let _ = tx.send(msg).await;
74    });
75}
76
77/// Builds a `PreviewContent::Directory` for `path`.
78///
79/// Returns `PreviewContent::Empty` if the directory cannot be read.
80pub fn build_directory_preview(path: &std::path::Path, show_hidden: bool) -> PreviewContent {
81    let read = match fs::read_dir(path) {
82        Ok(r) => r,
83        Err(_) => return PreviewContent::Empty,
84    };
85
86    let mut file_count = 0usize;
87    let mut dir_count = 0usize;
88    let mut hidden_count = 0usize;
89    let mut names: Vec<String> = Vec::new();
90
91    for res in read {
92        let de = match res {
93            Ok(d) => d,
94            Err(_) => continue,
95        };
96
97        // Sanitized here rather than at render time: a file name is allowed to
98        // hold control characters on Unix, and a terminal acts on those instead
99        // of drawing them.
100        let name = match de.file_name().to_str() {
101            Some(n) => crate::preview::provider::sanitize(n),
102            None => continue,
103        };
104
105        let is_hidden = name.starts_with('.');
106        if is_hidden {
107            hidden_count += 1;
108        }
109
110        let metadata = de.metadata().ok();
111        let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
112
113        if is_dir {
114            dir_count += 1;
115        } else {
116            file_count += 1;
117        }
118
119        if (!is_hidden || show_hidden) && names.len() < MAX_PREVIEW_ENTRIES {
120            // Suffix directories with `/` for quick visual identification.
121            if is_dir {
122                names.push(format!("{name}/"));
123            } else {
124                names.push(name);
125            }
126        }
127    }
128
129    // Sort names: directories first (those ending with `/`), then files.
130    names.sort_by(|a, b| {
131        let a_dir = a.ends_with('/');
132        let b_dir = b.ends_with('/');
133        match (a_dir, b_dir) {
134            (true, false) => std::cmp::Ordering::Less,
135            (false, true) => std::cmp::Ordering::Greater,
136            _ => a.to_lowercase().cmp(&b.to_lowercase()),
137        }
138    });
139
140    PreviewContent::Directory {
141        file_count,
142        dir_count,
143        hidden_count,
144        entries: names,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::fs as sfs;
152    use tempfile::tempdir;
153
154    #[test]
155    fn directory_preview_counts() {
156        let dir = tempdir().unwrap();
157        sfs::create_dir(dir.path().join("subdir")).unwrap();
158        sfs::write(dir.path().join("file.txt"), b"").unwrap();
159        sfs::write(dir.path().join(".hidden"), b"").unwrap();
160
161        let content = build_directory_preview(dir.path(), false);
162
163        if let PreviewContent::Directory {
164            file_count,
165            dir_count,
166            hidden_count,
167            entries,
168        } = content
169        {
170            assert_eq!(dir_count, 1);
171            assert_eq!(file_count, 2); // 'file.txt' and '.hidden'
172            assert_eq!(hidden_count, 1);
173            // With show_hidden=false, .hidden is excluded from entries.
174            assert!(!entries.iter().any(|e| e.contains("hidden")));
175        } else {
176            panic!("expected Directory variant");
177        }
178    }
179
180    #[test]
181    fn directory_preview_shows_hidden_when_requested() {
182        let dir = tempdir().unwrap();
183        sfs::write(dir.path().join(".hidden"), b"").unwrap();
184
185        let content = build_directory_preview(dir.path(), true);
186
187        if let PreviewContent::Directory { entries, .. } = content {
188            assert!(entries.iter().any(|e| e.contains("hidden")));
189        } else {
190            panic!("expected Directory variant");
191        }
192    }
193}