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::Buffer;
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(|g| g.len()).unwrap_or(0)
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) -> (Buffer, String) {
60        let path = match self.items.lock().ok().and_then(|g| g.get(idx).cloned()) {
61            Some(p) => p,
62            None => return (Buffer::new(), String::new()),
63        };
64        let abs = self.root.join(&path);
65        let (content, status) = load_preview(&abs);
66        (Buffer::from_str(&content), status)
67    }
68
69    fn preview_path(&self, idx: usize) -> Option<PathBuf> {
70        let rel = self.items.lock().ok()?.get(idx).cloned()?;
71        Some(self.root.join(rel))
72    }
73
74    fn select(&self, _idx: usize) -> PickerAction {
75        // FileSource is always wrapped by an app-side source that
76        // overrides `select` and boxes an app-specific `AppAction`.
77        // This base impl is never called directly.
78        PickerAction::None
79    }
80
81    fn requery_mode(&self) -> RequeryMode {
82        RequeryMode::FilterInMemory
83    }
84
85    fn enumerate(
86        &mut self,
87        _query: Option<&str>,
88        cancel: Arc<AtomicBool>,
89    ) -> Option<JoinHandle<()>> {
90        let items = Arc::clone(&self.items);
91        let done = Arc::clone(&self.scan_done);
92        let root = self.root.clone();
93        // Reset for re-enumerate.
94        if let Ok(mut g) = items.lock() {
95            g.clear();
96        }
97        done.store(false, Ordering::Release);
98        thread::Builder::new()
99            .name("hjkl-picker-scan".into())
100            .spawn(move || scan_walk(&root, &items, &done, &cancel))
101            .ok()
102    }
103}
104
105/// Background walker — streams `is_file()` entries into `items`,
106/// gitignore-aware via `ignore::WalkBuilder`.
107fn scan_walk(
108    root: &Path,
109    items: &Arc<Mutex<Vec<PathBuf>>>,
110    done: &Arc<AtomicBool>,
111    cancel: &Arc<AtomicBool>,
112) {
113    let walk = ignore::WalkBuilder::new(root)
114        .hidden(true)
115        .git_ignore(true)
116        .parents(true)
117        .build();
118    let mut batch: Vec<PathBuf> = Vec::with_capacity(256);
119    let mut total = 0usize;
120    const HARD_CAP: usize = 50_000;
121    for entry in walk {
122        if cancel.load(Ordering::Acquire) {
123            break;
124        }
125        let entry = match entry {
126            Ok(e) => e,
127            Err(_) => continue,
128        };
129        let Some(ft) = entry.file_type() else {
130            continue;
131        };
132        if !ft.is_file() {
133            continue;
134        }
135        let path = entry.into_path();
136        let rel = path
137            .strip_prefix(root)
138            .map(|p| p.to_path_buf())
139            .unwrap_or(path);
140        batch.push(rel);
141        total += 1;
142        if batch.len() >= 256
143            && let Ok(mut g) = items.lock()
144        {
145            g.extend(batch.drain(..));
146        }
147        if total >= HARD_CAP {
148            break;
149        }
150    }
151    if let Ok(mut g) = items.lock() {
152        g.extend(batch.drain(..));
153    }
154    done.store(true, Ordering::Release);
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn txt_preview_returns_buffer_and_path() {
163        let tmp = tempfile::tempdir().unwrap();
164        let path = tmp.path().join("notes.txt");
165        std::fs::write(&path, "hello world\nthis is plain text\n").unwrap();
166
167        let mut source = FileSource::new(tmp.path().to_path_buf());
168        let cancel = Arc::new(AtomicBool::new(false));
169        let _handle = source.enumerate(None, Arc::clone(&cancel));
170        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
171        loop {
172            if source.item_count() > 0 {
173                break;
174            }
175            if std::time::Instant::now() >= deadline {
176                break;
177            }
178            std::thread::sleep(std::time::Duration::from_millis(5));
179        }
180
181        let count = source.item_count();
182        let mut found_idx = None;
183        for i in 0..count {
184            if source.label(i).contains("notes.txt") {
185                found_idx = Some(i);
186                break;
187            }
188        }
189        let idx = found_idx.expect("notes.txt should appear in FileSource");
190        let (_buf, status) = source.preview(idx);
191        assert!(status.is_empty(), "unexpected status: {status:?}");
192        let preview_path = source.preview_path(idx).expect("preview_path");
193        assert!(preview_path.ends_with("notes.txt"));
194    }
195}