1use std::sync::mpsc::{self, Sender};
12use std::sync::Arc;
13use std::thread::JoinHandle;
14use std::time::Duration;
15
16use termesh_core::{FsEvent, FsRequest};
17
18use crate::ignore_rules::{IgnoreOptions, IgnoreRules};
19use crate::reader::DirReader;
20use crate::service::FileSystemService;
21use crate::watch::{is_relevant, RelevanceFilter, RootWatcher, DEFAULT_WINDOW};
22
23fn mutation_result(outcome: crate::service::FsResult<()>, path: &std::path::Path) -> FsEvent {
25 match outcome {
26 Ok(()) => FsEvent::Changed(vec![path.to_path_buf()]),
27 Err(e) => FsEvent::MutationFailed(e),
28 }
29}
30
31pub struct FsWorker {
33 tx: Sender<FsRequest>,
34 handle: Option<JoinHandle<()>>,
35}
36
37impl FsWorker {
38 pub fn spawn<S, F>(fs: S, options: IgnoreOptions, sink: F) -> Self
43 where
44 S: FileSystemService + 'static,
45 F: Fn(FsEvent) + Send + Sync + 'static,
46 {
47 Self::spawn_with_window(fs, options, DEFAULT_WINDOW, sink)
48 }
49
50 pub fn spawn_with_window<S, F>(
52 fs: S,
53 options: IgnoreOptions,
54 watch_window: Duration,
55 sink: F,
56 ) -> Self
57 where
58 S: FileSystemService + 'static,
59 F: Fn(FsEvent) + Send + Sync + 'static,
60 {
61 let sink = Arc::new(sink);
62 let (tx, rx) = mpsc::channel::<FsRequest>();
63 let handle = std::thread::Builder::new()
64 .name("termesh-fs".into())
65 .spawn(move || {
66 let mut reader: Option<DirReader> = None;
69 let mut watcher: Option<RootWatcher> = None;
70
71 while let Ok(req) = rx.recv() {
72 match req {
73 FsRequest::ReadDir { id, path } => {
74 let result = match reader.as_mut() {
75 Some(r) => r.read(&path),
76 None => DirReader::unfiltered(&fs).read(&path),
81 };
82 sink(match result {
83 Ok(entries) => FsEvent::DirLoaded { id, entries },
84 Err(error) => FsEvent::DirFailed { id, error },
85 });
86 }
87 FsRequest::ReadFile { buffer, path } => {
92 sink(match fs.read_file(&path) {
93 Ok(contents) => FsEvent::FileLoaded { buffer, path, contents },
94 Err(error) => FsEvent::FileFailed { buffer, error },
95 });
96 }
97 FsRequest::ReadPreview { request, path, line, context } => {
98 sink(match preview(&fs, &path, line, context.min(10)) {
99 Ok((start_line, text)) => {
100 FsEvent::PreviewLoaded { request, path, start_line, text }
101 }
102 Err(error) => FsEvent::PreviewFailed { request, path, error },
103 });
104 }
105 FsRequest::ResolvePath { request, path } => {
106 sink(match fs.canonicalize(&path) {
107 Ok(path) => FsEvent::PathResolved { request, path },
108 Err(error) => FsEvent::PathResolveFailed { request, path, error },
109 });
110 }
111 FsRequest::WriteFile { buffer, path, contents, version } => {
112 sink(match fs.write_file(&path, &contents) {
113 Ok(()) => FsEvent::FileSaved { buffer, version },
114 Err(error) => FsEvent::FileFailed { buffer, error },
115 });
116 }
117 FsRequest::Watch(root) => {
118 let r = DirReader::new(&fs, &root, options);
119
120 let rules = IgnoreRules::for_root(&fs, &root, options);
124 let filter = RelevanceFilter::new(move |p| is_relevant(p, &rules));
125
126 drop(watcher.take());
130
131 let sink_for_watch = sink.clone();
132 watcher =
135 RootWatcher::start(&root, watch_window, filter, move |paths| {
136 sink_for_watch(FsEvent::Changed(paths))
137 });
138 reader = Some(r);
139 }
140 FsRequest::CreateFile(path) => {
145 sink(mutation_result(fs.create_file(&path), &path));
146 }
147 FsRequest::CreateDir(path) => {
148 sink(mutation_result(fs.create_dir(&path), &path));
149 }
150 FsRequest::Rename { from, to } => {
151 sink(match fs.rename(&from, &to) {
154 Ok(()) => FsEvent::Changed(vec![from, to]),
155 Err(e) => FsEvent::MutationFailed(e),
156 });
157 }
158 FsRequest::Remove { path, recursive } => {
159 let outcome = if recursive {
160 fs.remove_dir_all(&path)
161 } else {
162 fs.remove_file(&path)
163 };
164 sink(mutation_result(outcome, &path));
165 }
166 FsRequest::Shutdown => break,
167 }
168 }
169 })
170 .expect("spawning the filesystem worker thread");
171
172 Self { tx, handle: Some(handle) }
173 }
174
175 pub fn request(&self, req: FsRequest) -> bool {
178 self.tx.send(req).is_ok()
179 }
180}
181
182fn preview(
183 fs: &dyn FileSystemService,
184 path: &std::path::Path,
185 line: usize,
186 context: usize,
187) -> crate::service::FsResult<(usize, String)> {
188 let bytes = fs.read_file(path)?;
189 let contents = String::from_utf8(bytes).map_err(|_| crate::service::FsError::Other {
190 path: path.to_path_buf(),
191 message: "not a UTF-8 text file".into(),
192 })?;
193 let lines: Vec<&str> = contents.split_inclusive('\n').collect();
194 let target = line.saturating_sub(1).min(lines.len().saturating_sub(1));
195 let start = target.saturating_sub(context);
196 let end = (target + context + 1).min(lines.len());
197 Ok((start + 1, lines[start..end].concat()))
198}
199
200impl Drop for FsWorker {
201 fn drop(&mut self) {
202 let _ = self.tx.send(FsRequest::Shutdown);
205 if let Some(h) = self.handle.take() {
206 let _ = h.join();
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::path::{Path, PathBuf};
215 use std::sync::mpsc::Receiver;
216 use std::time::Duration;
217
218 use termesh_core::{
219 DirEntryInfo, EntryKind, FsError, FsResult, LocationRequestId, NodeId, PreviewRequestId,
220 };
221
222 struct StubFs(Vec<DirEntryInfo>, Vec<u8>);
225
226 impl FileSystemService for StubFs {
227 fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
228 if path == Path::new("/denied") {
229 return Err(FsError::PermissionDenied(path.to_path_buf()));
230 }
231 Ok(self.0.clone())
232 }
233 fn read_file(&self, _: &Path) -> FsResult<Vec<u8>> {
234 Ok(self.1.clone())
235 }
236 fn create_file(&self, _: &Path) -> FsResult<()> {
237 Ok(())
238 }
239 fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
240 Ok(())
241 }
242 fn create_dir(&self, _: &Path) -> FsResult<()> {
243 Ok(())
244 }
245 fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
246 Ok(())
247 }
248 fn remove_file(&self, _: &Path) -> FsResult<()> {
249 Ok(())
250 }
251 fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
252 Ok(())
253 }
254 fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
255 if p == Path::new("/missing") {
256 Err(FsError::NotFound(p.to_path_buf()))
257 } else {
258 Ok(p.to_path_buf())
259 }
260 }
261 }
262
263 fn stub() -> StubFs {
264 StubFs(
265 vec![DirEntryInfo {
266 name: "main.rs".into(),
267 path: PathBuf::from("/src/main.rs"),
268 kind: EntryKind::File,
269 }],
270 Vec::new(),
271 )
272 }
273
274 fn worker_with_channel() -> (FsWorker, Receiver<FsEvent>) {
275 let (tx, rx) = mpsc::channel();
276 let worker = FsWorker::spawn(stub(), IgnoreOptions::show_all(), move |e| {
277 let _ = tx.send(e);
278 });
279 (worker, rx)
280 }
281
282 fn recv(rx: &Receiver<FsEvent>) -> FsEvent {
283 rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer")
284 }
285
286 #[test]
287 fn a_read_request_comes_back_as_dir_loaded() {
288 let (worker, rx) = worker_with_channel();
289 assert!(
290 worker.request(FsRequest::ReadDir { id: NodeId::new(7), path: PathBuf::from("/src") })
291 );
292
293 match recv(&rx) {
294 FsEvent::DirLoaded { id, entries } => {
295 assert_eq!(id, NodeId::new(7), "the answer names the node that asked");
296 assert_eq!(entries.len(), 1);
297 }
298 other => panic!("unexpected event: {other:?}"),
299 }
300 }
301
302 #[test]
303 fn preview_returns_only_the_requested_window() {
304 let (tx, rx) = mpsc::channel();
305 let worker = FsWorker::spawn(
306 StubFs(Vec::new(), b"one\ntwo\nneedle\nfour\nfive\n".to_vec()),
307 IgnoreOptions::show_all(),
308 move |event| {
309 let _ = tx.send(event);
310 },
311 );
312 worker.request(FsRequest::ReadPreview {
313 request: PreviewRequestId::new(3),
314 path: PathBuf::from("/p/src/lib.rs"),
315 line: 3,
316 context: 1,
317 });
318 assert!(matches!(
319 recv(&rx),
320 FsEvent::PreviewLoaded {
321 request,
322 start_line: 2,
323 text,
324 ..
325 } if request == PreviewRequestId::new(3) && text == "two\nneedle\nfour\n"
326 ));
327 }
328
329 #[test]
330 fn path_resolution_preserves_the_request_id_on_success_and_failure() {
331 let (worker, rx) = worker_with_channel();
332 worker.request(FsRequest::ResolvePath {
333 request: LocationRequestId::new(4),
334 path: PathBuf::from("/src/main.rs"),
335 });
336 assert!(matches!(
337 recv(&rx),
338 FsEvent::PathResolved { request, path }
339 if request == LocationRequestId::new(4) && path == Path::new("/src/main.rs")
340 ));
341 worker.request(FsRequest::ResolvePath {
342 request: LocationRequestId::new(5),
343 path: PathBuf::from("/missing"),
344 });
345 assert!(matches!(
346 recv(&rx),
347 FsEvent::PathResolveFailed { request, .. }
348 if request == LocationRequestId::new(5)
349 ));
350 }
351
352 #[test]
353 fn a_failed_read_comes_back_as_dir_failed_and_the_worker_survives() {
354 let (worker, rx) = worker_with_channel();
355 worker.request(FsRequest::ReadDir { id: NodeId::new(1), path: PathBuf::from("/denied") });
356 assert!(matches!(recv(&rx), FsEvent::DirFailed { .. }));
357
358 worker.request(FsRequest::ReadDir { id: NodeId::new(2), path: PathBuf::from("/src") });
360 assert!(matches!(recv(&rx), FsEvent::DirLoaded { .. }));
361 }
362
363 #[test]
364 fn requests_are_answered_in_order() {
365 let (worker, rx) = worker_with_channel();
366 for i in 0..5 {
367 worker.request(FsRequest::ReadDir { id: NodeId::new(i), path: PathBuf::from("/src") });
368 }
369 for i in 0..5 {
370 match recv(&rx) {
371 FsEvent::DirLoaded { id, .. } => assert_eq!(id, NodeId::new(i)),
372 other => panic!("unexpected event: {other:?}"),
373 }
374 }
375 }
376
377 #[test]
378 fn dropping_the_worker_stops_the_thread() {
379 let (worker, rx) = worker_with_channel();
380 worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
381 let _ = recv(&rx);
382
383 drop(worker); assert!(rx.recv().is_err(), "no further events once the worker is gone");
385 }
386
387 #[test]
392 fn the_first_listing_after_watch_is_already_ignore_filtered() {
393 use crate::real::RealFileSystem;
394
395 let stamp =
396 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
397 let root = std::env::temp_dir().join(format!("termesh-firstread-{stamp}"));
398 std::fs::create_dir_all(root.join("target")).unwrap();
399 std::fs::create_dir_all(root.join("src")).unwrap();
400 std::fs::write(root.join(".gitignore"), b"target\n").unwrap();
401
402 let (tx, rx) = mpsc::channel();
403 let worker = FsWorker::spawn(RealFileSystem::new(), IgnoreOptions::default(), move |e| {
404 let _ = tx.send(e);
405 });
406 worker.request(FsRequest::Watch(root.clone()));
407 worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });
408
409 let names: Vec<String> = loop {
410 match rx.recv_timeout(Duration::from_secs(5)).expect("worker should answer") {
411 FsEvent::DirLoaded { entries, .. } => {
412 break entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect()
413 }
414 _ => continue,
415 }
416 };
417
418 drop(worker);
419 let _ = std::fs::remove_dir_all(&root);
420
421 assert_eq!(names, ["src"], "target/ and .gitignore must be filtered from the first read");
422 }
423
424 #[test]
428 fn creating_a_file_under_a_watched_root_emits_a_changed_batch() {
429 use crate::real::RealFileSystem;
430
431 let stamp =
432 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
433 let root = std::env::temp_dir().join(format!("termesh-watch-{stamp}"));
434 std::fs::create_dir_all(&root).unwrap();
435
436 let (tx, rx) = mpsc::channel();
437 let worker = FsWorker::spawn_with_window(
438 RealFileSystem::new(),
439 IgnoreOptions::show_all(),
440 Duration::from_millis(50),
441 move |e| {
442 let _ = tx.send(e);
443 },
444 );
445 worker.request(FsRequest::Watch(root.clone()));
446
447 worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: root.clone() });
452 let handshake = std::time::Instant::now() + Duration::from_secs(10);
453 while std::time::Instant::now() < handshake {
454 if let Ok(FsEvent::DirLoaded { .. }) = rx.recv_timeout(Duration::from_millis(500)) {
455 break;
456 }
457 }
458
459 let created = root.join("created.rs");
463 let deadline = std::time::Instant::now() + Duration::from_secs(10);
464 let mut saw_change = false;
465 while std::time::Instant::now() < deadline && !saw_change {
466 std::fs::write(&created, b"fn main() {}").unwrap();
467 while let Ok(event) = rx.recv_timeout(Duration::from_millis(250)) {
468 if let FsEvent::Changed(paths) = event {
469 if paths.iter().any(|p| p.ends_with("created.rs")) {
470 saw_change = true;
471 break;
472 }
473 }
474 }
475 }
476
477 drop(worker);
478 let _ = std::fs::remove_dir_all(&root);
479 assert!(saw_change, "a new file under the watched root should reach the loop");
480 }
481
482 #[test]
483 fn requesting_after_shutdown_reports_failure_instead_of_panicking() {
484 let (worker, _rx) = worker_with_channel();
485 worker.request(FsRequest::Shutdown);
486 let _ =
489 worker.request(FsRequest::ReadDir { id: NodeId::new(0), path: PathBuf::from("/src") });
490 }
491}