hjkl_picker/source/
file.rs1use 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
12pub 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 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 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 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
104fn 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}