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::Buffer;
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(|g| g.len()).unwrap_or(0)
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) -> (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 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 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
105fn 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}