Skip to main content

hjkl_picker/source/
file.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::thread::{self, JoinHandle};
6
7use hjkl_buffer::View;
8
9use crate::logic::{PickerAction, PickerLogic, RequeryMode};
10use crate::preview::load_preview;
11
12/// File-source: gitignore-aware cwd walker. Items are paths relative to
13/// `root`, preview reads from disk capped at `PREVIEW_MAX_BYTES` with a
14/// binary-byte heuristic.
15///
16/// Bonsai-agnostic — the preview returns just the file contents. The
17/// host (e.g. apps/hjkl) is responsible for layering syntax highlight
18/// spans by reading [`PickerLogic::preview_path`] and feeding the
19/// buffer bytes through its own highlighter.
20pub struct FileSource {
21    pub root: PathBuf,
22    pub items: Arc<Mutex<Vec<PathBuf>>>,
23    scan_done: Arc<AtomicBool>,
24}
25
26impl FileSource {
27    pub fn new(root: PathBuf) -> Self {
28        Self {
29            root,
30            items: Arc::new(Mutex::new(Vec::new())),
31            scan_done: Arc::new(AtomicBool::new(false)),
32        }
33    }
34}
35
36impl PickerLogic for FileSource {
37    fn title(&self) -> &str {
38        "files"
39    }
40
41    fn item_count(&self) -> usize {
42        self.items.lock().map_or(0, |g| g.len())
43    }
44
45    fn label(&self, idx: usize) -> String {
46        // Two-cell prefix matches BufferSource's marker column so labels
47        // stay vertically aligned across pickers.
48        self.items
49            .lock()
50            .ok()
51            .and_then(|g| g.get(idx).map(|p| format!("  {}", p.to_string_lossy())))
52            .unwrap_or_default()
53    }
54
55    fn match_text(&self, idx: usize) -> String {
56        self.label(idx)
57    }
58
59    fn preview(&self, idx: usize) -> (View, String) {
60        let Some(path) = self.items.lock().ok().and_then(|g| g.get(idx).cloned()) else {
61            return (View::new(), String::new());
62        };
63        let abs = self.root.join(&path);
64        let (content, status) = load_preview(&abs);
65        (View::from_str(&content), status)
66    }
67
68    fn preview_path(&self, idx: usize) -> Option<PathBuf> {
69        let rel = self.items.lock().ok()?.get(idx).cloned()?;
70        Some(self.root.join(rel))
71    }
72
73    fn select(&self, _idx: usize) -> PickerAction {
74        // FileSource is always wrapped by an app-side source that
75        // overrides `select` and boxes an app-specific `AppAction`.
76        // This base impl is never called directly.
77        PickerAction::None
78    }
79
80    fn requery_mode(&self) -> RequeryMode {
81        RequeryMode::FilterInMemory
82    }
83
84    fn enumerate(
85        &mut self,
86        _query: Option<&str>,
87        cancel: Arc<AtomicBool>,
88    ) -> Option<JoinHandle<()>> {
89        let items = Arc::clone(&self.items);
90        let done = Arc::clone(&self.scan_done);
91        let root = self.root.clone();
92        // Reset for re-enumerate.
93        if let Ok(mut g) = items.lock() {
94            g.clear();
95        }
96        done.store(false, Ordering::Release);
97        thread::Builder::new()
98            .name("hjkl-picker-scan".into())
99            .spawn(move || scan_walk(&root, &items, &done, &cancel))
100            .ok()
101    }
102}
103
104/// Background walker — streams `is_file()` entries into `items`,
105/// gitignore-aware via `ignore::WalkBuilder`.
106fn scan_walk(
107    root: &Path,
108    items: &Arc<Mutex<Vec<PathBuf>>>,
109    done: &Arc<AtomicBool>,
110    cancel: &Arc<AtomicBool>,
111) {
112    let walk = ignore::WalkBuilder::new(root)
113        .hidden(true)
114        .git_ignore(true)
115        .parents(true)
116        .build();
117    let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
118    let mut total = 0usize;
119    const HARD_CAP: usize = 50_000;
120    for entry in walk {
121        if cancel.load(Ordering::Acquire) {
122            break;
123        }
124        let Ok(entry) = entry else { continue };
125        let Some(ft) = entry.file_type() else {
126            continue;
127        };
128        if !ft.is_file() {
129            continue;
130        }
131        let path = entry.into_path();
132        let rel = path
133            .strip_prefix(root)
134            .map(|p| p.to_path_buf())
135            .unwrap_or(path);
136        batch.push(rel);
137        total += 1;
138        if batch.len() >= 256
139            && let Ok(mut g) = items.lock()
140        {
141            g.extend(batch.drain(..));
142        }
143        if total >= HARD_CAP {
144            break;
145        }
146    }
147    if let Ok(mut g) = items.lock() {
148        g.extend(batch.drain(..));
149    }
150    done.store(true, Ordering::Release);
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn txt_preview_returns_buffer_and_path() {
159        let tmp = tempfile::tempdir().unwrap();
160        let path = tmp.path().join("notes.txt");
161        std::fs::write(&path, "hello world\nthis is plain text\n").unwrap();
162
163        let mut source = FileSource::new(tmp.path().to_path_buf());
164        let cancel = Arc::new(AtomicBool::new(false));
165        let _handle = source.enumerate(None, Arc::clone(&cancel));
166        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
167        loop {
168            if source.item_count() > 0 {
169                break;
170            }
171            if std::time::Instant::now() >= deadline {
172                break;
173            }
174            std::thread::sleep(std::time::Duration::from_millis(5));
175        }
176
177        let count = source.item_count();
178        let mut found_idx = None;
179        for i in 0..count {
180            if source.label(i).contains("notes.txt") {
181                found_idx = Some(i);
182                break;
183            }
184        }
185        let idx = found_idx.expect("notes.txt should appear in FileSource");
186        let (_buf, status) = source.preview(idx);
187        assert!(status.is_empty(), "unexpected status: {status:?}");
188        let preview_path = source.preview_path(idx).expect("preview_path");
189        assert!(preview_path.ends_with("notes.txt"));
190    }
191}