1#![allow(clippy::expect_used, clippy::unwrap_used)]
6use glob::Pattern;
7use tui_tree_widget::{TreeItem, TreeState};
8use super::archive_navigator::ArchiveNavigator;
9use super::navigator::FileNavigator;
10
11#[derive(Debug, Clone, PartialEq)]
13pub enum BrowserMode {
14 FilesystemSnapshots,
16 ArchiveBrowse,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum Focus {
23 Left,
24 Right,
25}
26
27pub struct ArchiveModel {
29 pub navigator: ArchiveNavigator,
30 pub tree_state: TreeState<String>,
31 pub show_metadata: bool,
33 pub search_mode: bool,
35 pub search_query: String,
36 pub search_results: Vec<usize>,
38 pub snapshot_filter: Option<String>,
40 pub detail_popup: bool,
42 pub snapshot_popup: bool,
44 pub snapshot_popup_selected: usize,
46 pub preview_mode: bool,
48 pub preview_content_raw: Option<Vec<u8>>,
50 pub preview_scroll: u16,
52 pub extract_prompt: bool,
54 pub extract_dest: String,
56 pub extract_status: Option<String>,
58 pub pending_extract: Option<String>,
60 pub vector_search_mode: bool,
62 pub vector_query: String,
64 pub vector_results: Vec<(f32, String)>,
66 pub vector_status: Option<String>,
68 pub vector_search_pending: bool,
70}
71
72impl ArchiveModel {
73 pub fn new(navigator: ArchiveNavigator) -> Self {
74 let mut tree_state = TreeState::default();
75 tree_state.open(vec!["0".to_string()]); Self {
77 navigator,
78 tree_state,
79 show_metadata: true,
80 search_mode: false,
81 search_query: String::new(),
82 search_results: Vec::new(),
83 snapshot_filter: None,
84 detail_popup: false,
85 snapshot_popup: false,
86 snapshot_popup_selected: 0,
87 preview_mode: false,
88 preview_content_raw: None,
89 preview_scroll: 0,
90 extract_prompt: false,
91 extract_dest: String::new(),
92 extract_status: None,
93 pending_extract: None,
94 vector_search_mode: false,
95 vector_query: String::new(),
96 vector_results: Vec::new(),
97 vector_status: None,
98 vector_search_pending: false,
99 }
100 }
101
102 pub fn build_tree_items(&self) -> Vec<TreeItem<'static, String>> {
103 fn build_node(entries: &[super::archive_navigator::ArchiveEntry], idx: usize) -> TreeItem<'static, String> {
104 let entry = &entries[idx];
105 let label = if entry.is_dir {
106 format!("📁 {}", entry.name)
107 } else {
108 entry.name.clone()
109 };
110 let id = idx.to_string();
111 if entry.children.is_empty() {
112 TreeItem::new_leaf(id, label)
113 } else {
114 let children: Vec<_> = entry.children.iter()
115 .map(|&ci| build_node(entries, ci))
116 .collect();
117 let fallback_id = id.clone();
118 let fallback_label = label.clone();
119 TreeItem::new(id, label, children).unwrap_or_else(|_| TreeItem::new_leaf(fallback_id, fallback_label))
120 }
121 }
122
123 let root = match self.navigator.entry_at(0) {
124 None => return vec![],
125 Some(r) => r,
126 };
127
128 let top_children: Vec<usize> = if let Some(ref filter) = self.snapshot_filter {
129 root.children
130 .iter()
131 .copied()
132 .filter(|&idx| {
133 self.navigator
134 .entry_at(idx)
135 .map(|e| e.name == *filter)
136 .unwrap_or(false)
137 })
138 .collect()
139 } else {
140 root.children.clone()
141 };
142
143 top_children
144 .iter()
145 .filter_map(|&idx| Some(build_node(&self.navigator.entries, idx)))
146 .collect()
147 }
148
149 pub fn apply_search_filter(&mut self) {
150 let q = self.search_query.trim().to_lowercase();
151 if q.is_empty() {
152 self.search_results.clear();
153 return;
154 }
155
156 self.search_results.clear();
157
158 if let Some(rest) = q.strip_prefix("dc:") {
159 let (field, value) = rest.split_once('=').unwrap_or(("", rest));
160 let xattr_key = format!("user.dublincore.{}", field);
161 for (idx, entry) in self.navigator.entries.iter().enumerate() {
162 if entry.is_dir {
163 continue;
164 }
165 if let Some(hex_val) = entry.xattr.get(&xattr_key)
166 && let Ok(bytes) = hex::decode(hex_val)
167 && let Ok(s) = std::str::from_utf8(&bytes)
168 && s.to_lowercase().contains(value) {
169 self.search_results.push(idx);
170 }
171 }
172 return;
173 }
174
175 if let Some(tag) = q.strip_prefix("tag:") {
176 for (idx, entry) in self.navigator.entries.iter().enumerate() {
177 if entry.is_dir {
178 continue;
179 }
180 if let Some(hex_val) = entry.xattr.get("user.xdg.tags")
181 && let Ok(bytes) = hex::decode(hex_val)
182 && let Ok(s) = std::str::from_utf8(&bytes)
183 && s.to_lowercase().split(',').any(|t| {
184 t.trim() == tag || t.trim().contains(&*tag)
185 }) {
186 self.search_results.push(idx);
187 }
188 }
189 return;
190 }
191
192 let is_glob = q.contains('*') || q.contains('?') || q.contains('[');
195 let pattern = if is_glob { Pattern::new(&q).ok() } else { None };
196
197 for (idx, entry) in self.navigator.entries.iter().enumerate() {
198 if entry.is_dir {
199 continue;
200 }
201 let name_lower = entry.name.to_lowercase();
202 let path_lower = entry.full_path.to_lowercase();
203 let matches = if let Some(ref pat) = pattern {
204 pat.matches(&name_lower) || pat.matches(&path_lower)
205 } else {
206 name_lower.contains(q.as_str()) || path_lower.contains(q.as_str())
207 };
208 if matches {
209 self.search_results.push(idx);
210 }
211 }
212 }
213}
214
215pub struct BrowserModel {
217 pub left: FileNavigator,
218 pub right: FileNavigator,
219 pub focus: Focus,
220 pub mode: BrowserMode,
221 pub status_message: String,
222 pub should_quit: bool,
223 pub archive: Option<ArchiveModel>,
224}
225
226impl BrowserModel {
227 pub fn new(left_path: &std::path::Path, right_path: &std::path::Path, mode: BrowserMode) -> Self {
228 Self {
229 left: FileNavigator::new(left_path),
230 right: FileNavigator::new(right_path),
231 focus: Focus::Left,
232 mode,
233 status_message: String::new(),
234 should_quit: false,
235 archive: None,
236 }
237 }
238
239 pub fn active_nav(&mut self) -> &mut FileNavigator {
240 match self.focus {
241 Focus::Left => &mut self.left,
242 Focus::Right => &mut self.right,
243 }
244 }
245
246 pub fn toggle_focus(&mut self) {
247 self.focus = match self.focus {
248 Focus::Left => Focus::Right,
249 Focus::Right => Focus::Left,
250 };
251 }
252}
253
254pub struct BrowserApp {
256 pub model: BrowserModel,
257 pub fxar_path: Option<std::path::PathBuf>,
258 pub vector_search_fn: Option<std::sync::Arc<dyn Fn(&str) -> std::io::Result<Vec<(f32, String)>> + Send + Sync + 'static>>,
259}
260
261impl BrowserApp {
262 pub fn new(path: &std::path::Path) -> Self {
263 let versions_dir = path.join(".foxing_versions");
264 let right_path = if versions_dir.exists() { versions_dir } else { path.to_path_buf() };
265
266 Self {
267 model: BrowserModel::new(path, &right_path, BrowserMode::FilesystemSnapshots),
268 fxar_path: None,
269 vector_search_fn: None,
270 }
271 }
272
273 pub fn new_archive(archive_path: &std::path::Path, _target_path: &std::path::Path) -> std::io::Result<Self> {
274 use crate::fxar::FxarReader;
275 let file = std::fs::File::open(archive_path)?;
276 let mut reader = FxarReader::open(file)?;
277 let manifest = reader.read_manifest()?;
278 let navigator = ArchiveNavigator::from_manifest(manifest)?;
279 let archive_model = ArchiveModel::new(navigator);
280
281 let dummy_path = std::path::Path::new(".");
282 let mut model = BrowserModel::new(dummy_path, dummy_path, BrowserMode::ArchiveBrowse);
283 model.archive = Some(archive_model);
284 Ok(Self {
285 model,
286 fxar_path: Some(archive_path.to_path_buf()),
287 vector_search_fn: None,
288 })
289 }
290
291 pub fn load_archive_preview(&mut self) {
292 let fxar_path = match &self.fxar_path {
293 Some(p) => p.clone(),
294 None => return,
295 };
296 let archive = match &self.model.archive {
297 Some(a) if a.preview_mode && a.preview_content_raw.is_none() => a,
298 _ => return,
299 };
300 let selected = archive.tree_state.selected();
301 let idx = selected
302 .last()
303 .and_then(|s| s.parse::<usize>().ok())
304 .unwrap_or(0);
305 let path = match archive.navigator.entry_at(idx) {
306 Some(e) if !e.is_dir => e.full_path.clone(),
307 _ => return,
308 };
309 let file = match std::fs::File::open(&fxar_path) {
310 Ok(f) => f,
311 Err(_) => return,
312 };
313 if let Ok(mut reader) = crate::fxar::FxarReader::open(file) {
314 let bytes = reader.restore_file_head(&path, 8192).unwrap_or_default();
315 if let Some(archive) = &mut self.model.archive {
316 archive.preview_content_raw = Some(bytes);
317 archive.preview_scroll = 0;
318 }
319 }
320 }
321
322 pub(crate) fn do_extract(&mut self, dest: &str) {
323 let fxar_path = match &self.fxar_path {
324 Some(p) => p.clone(),
325 None => {
326 if let Some(archive) = &mut self.model.archive {
327 archive.extract_status = Some("No archive path".to_string());
328 archive.pending_extract = None;
329 }
330 return;
331 }
332 };
333
334 let (selected_entry, manifest_files) = match &self.model.archive {
335 Some(a) => {
336 let selected = a.tree_state.selected();
337 let idx = selected.last()
338 .and_then(|s| s.parse::<usize>().ok())
339 .unwrap_or(0);
340 match a.navigator.entry_at(idx) {
341 Some(e) => (e.clone(), self.collect_extract_files(idx)),
342 None => {
343 return;
344 }
345 }
346 }
347 None => return,
348 };
349
350 if manifest_files.is_empty() {
351 if let Some(archive) = &mut self.model.archive {
352 archive.extract_status = Some("No files to extract".to_string());
353 archive.pending_extract = None;
354 }
355 return;
356 }
357
358 let dest_path = std::path::PathBuf::from(dest);
359 let mut extracted_count = 0usize;
360 let mut total_bytes = 0u64;
361 let mut error_msg: Option<String> = None;
362
363 for (file_path, manifest_entry) in &manifest_files {
364 let out_path = if manifest_files.len() == 1 && !selected_entry.is_dir {
365 dest_path.clone()
366 } else {
367 let relative = file_path.strip_prefix(&format!("{}/", selected_entry.full_path))
369 .unwrap_or(file_path);
370 dest_path.join(relative)
371 };
372
373 if let Some(parent) = out_path.parent() {
374 let _ = std::fs::create_dir_all(parent);
375 }
376
377 let file = match std::fs::File::open(&fxar_path) {
378 Ok(f) => f,
379 Err(e) => { error_msg = Some(format!("Error opening archive: {}", e)); break; }
380 };
381 match crate::fxar::FxarReader::open(file) {
382 Ok(mut reader) => {
383 match reader.restore_file(file_path) {
384 Ok(data) => {
385 if std::fs::write(&out_path, &data).is_err() {
386 error_msg = Some(format!("Error writing {}", out_path.display()));
387 break;
388 }
389 use std::os::unix::fs::PermissionsExt;
390 let _ = std::fs::set_permissions(
391 &out_path,
392 std::fs::Permissions::from_mode(manifest_entry.mode),
393 );
394 let mtime = filetime::FileTime::from_unix_time(manifest_entry.mtime, 0);
395 let _ = filetime::set_file_mtime(&out_path, mtime);
396 for (key, hex_val) in &manifest_entry.xattr {
397 if let Ok(val) = hex::decode(hex_val) {
398 let _ = xattr::set(&out_path, key, &val);
399 }
400 }
401 total_bytes += data.len() as u64;
402 extracted_count += 1;
403 }
404 Err(e) => { error_msg = Some(format!("Error: {}", e)); break; }
405 }
406 }
407 Err(e) => { error_msg = Some(format!("Error opening archive: {}", e)); break; }
408 }
409 }
410
411 if let Some(archive) = &mut self.model.archive {
412 archive.extract_status = Some(match error_msg {
413 Some(e) => e,
414 None => {
415 let size_str = if total_bytes >= 1_048_576 {
416 format!("{:.1} MB", total_bytes as f64 / 1_048_576.0)
417 } else if total_bytes >= 1024 {
418 format!("{:.1} KB", total_bytes as f64 / 1024.0)
419 } else {
420 format!("{} B", total_bytes)
421 };
422 format!("Extracted {} file(s) ({}) to {}", extracted_count, size_str, dest)
423 }
424 });
425 archive.pending_extract = None;
426 }
427 }
428
429 fn collect_extract_files(&self, selected_idx: usize) -> Vec<(String, crate::fxar::FxarManifestEntry)> {
430 let archive = match &self.model.archive {
431 Some(a) => a,
432 None => return vec![],
433 };
434 let entry = match archive.navigator.entry_at(selected_idx) {
435 Some(e) => e,
436 None => return vec![],
437 };
438
439 if entry.is_dir {
440 archive.navigator.entries.iter()
441 .filter(|e| !e.is_dir && e.manifest_index.is_some())
442 .filter(|e| {
443 e.full_path.starts_with(&format!("{}/", entry.full_path))
444 || e.full_path == entry.full_path
445 })
446 .filter_map(|e| {
447 let mi = e.manifest_index?;
448 archive.navigator.manifest.files.get(mi)
449 .map(|mf| (e.full_path.clone(), mf.clone()))
450 })
451 .collect()
452 } else if let Some(mi) = entry.manifest_index {
453 archive.navigator.manifest.files.get(mi)
454 .map(|mf| vec![(entry.full_path.clone(), mf.clone())])
455 .unwrap_or_default()
456 } else {
457 vec![]
458 }
459 }
460
461 pub fn set_vector_search_fn<F>(&mut self, f: F)
462 where
463 F: Fn(&str) -> std::io::Result<Vec<(f32, String)>> + Send + Sync + 'static,
464 {
465 self.vector_search_fn = Some(std::sync::Arc::new(f));
466 }
467
468 pub(crate) fn run_vector_search(&mut self) {
469 let is_pending = self.model.archive.as_ref()
470 .map(|a| a.vector_search_pending)
471 .unwrap_or(false);
472 if !is_pending {
473 return;
474 }
475
476 if let Some(archive) = &mut self.model.archive {
477 archive.vector_search_pending = false;
478 }
479
480 if self.vector_search_fn.is_none() {
481 if let Some(archive) = &mut self.model.archive {
482 archive.vector_status = Some(
483 "No AI index \u{2014} run with: fxcp snap browse --ai archive.fxar".to_string()
484 );
485 }
486 return;
487 }
488
489 let query = self.model.archive.as_ref()
490 .map(|a| a.vector_query.clone())
491 .unwrap_or_default();
492 let fn_arc = self.vector_search_fn.as_ref().unwrap().clone();
493
494 match fn_arc(&query) {
495 Ok(mut results) => {
496 results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
497 let count = results.len();
498 if let Some(archive) = &mut self.model.archive {
499 archive.vector_results = results;
500 archive.vector_status = Some(format!("Found {} results", count));
501 }
502 }
503 Err(e) => {
504 if let Some(archive) = &mut self.model.archive {
505 archive.vector_status = Some(format!("Search error: {}", e));
506 }
507 }
508 }
509 }
510
511 pub fn run(&mut self) -> std::io::Result<()> {
512 use crossterm::{
513 terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
514 execute,
515 event::{self, Event},
516 };
517 use ratatui::prelude::*;
518
519 enable_raw_mode()?;
520 let mut stdout = std::io::stdout();
521 execute!(stdout, EnterAlternateScreen)?;
522 let backend = CrosstermBackend::new(stdout);
523 let mut terminal = Terminal::new(backend)?;
524
525 loop {
526 terminal.draw(|f| {
527 if self.model.archive.is_some() {
528 super::render::draw_archive_mode(f, &mut self.model);
529 } else {
530 super::render::draw(f, &self.model);
531 }
532 })?;
533
534 if event::poll(std::time::Duration::from_millis(crate::constants::BROWSER_EVENT_POLL_INTERVAL_MS))?
535 && let Event::Key(key) = event::read()? {
536 if super::events::handle_key(&mut self.model, key) {
537 break;
538 }
539 self.load_archive_preview();
540 }
541
542 if let Some(dest) = self.model.archive.as_ref()
543 .and_then(|a| a.pending_extract.as_ref()).cloned()
544 {
545 self.do_extract(&dest);
546 }
547
548 self.run_vector_search();
549
550 if self.model.should_quit { break; }
551 }
552
553 disable_raw_mode()?;
554 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
555 Ok(())
556 }
557}
558
559#[cfg(test)]
560#[allow(clippy::unwrap_used, clippy::expect_used)]
561mod search_tests {
562 use super::*;
563 use crate::fxar::{FxarManifest, FxarManifestEntry};
564 use std::collections::HashMap;
565
566 fn make_manifest(files: Vec<FxarManifestEntry>) -> FxarManifest {
567 FxarManifest {
568 version: 2,
569 created: "2026-01-01T00:00:00Z".into(),
570 files,
571 snapshots: vec!["snap1".into()],
572 }
573 }
574
575 fn make_entry(path: &str, xattr: HashMap<String, String>) -> FxarManifestEntry {
576 FxarManifestEntry {
577 path: path.into(),
578 size: 100,
579 mode: 0o100644,
580 mtime: 1700000000,
581 uid: 1000,
582 gid: 1000,
583 blake3: "0".repeat(64),
584 chunks: vec![0],
585 xattr,
586 }
587 }
588
589 fn build_model(files: Vec<FxarManifestEntry>) -> ArchiveModel {
590 let manifest = make_manifest(files);
591 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
592 ArchiveModel::new(nav)
593 }
594
595 #[test]
596 fn test_glob_search() {
597 let mut model = build_model(vec![
598 make_entry("snap1/tree/a.rs", HashMap::new()),
599 make_entry("snap1/tree/b.txt", HashMap::new()),
600 make_entry("snap1/tree/c.rs", HashMap::new()),
601 ]);
602 model.search_query = "*.rs".into();
603 model.apply_search_filter();
604 assert_eq!(model.search_results.len(), 2);
605 let names: Vec<&str> = model
606 .search_results
607 .iter()
608 .map(|&i| model.navigator.entries[i].name.as_str())
609 .collect();
610 assert!(names.contains(&"a.rs"));
611 assert!(names.contains(&"c.rs"));
612 }
613
614 #[test]
615 fn test_substring_search() {
616 let mut model = build_model(vec![
617 make_entry("snap1/tree/hello.txt", HashMap::new()),
618 make_entry("snap1/tree/say_hello.rs", HashMap::new()),
619 make_entry("snap1/tree/goodbye.txt", HashMap::new()),
620 ]);
621 model.search_query = "hello".into();
622 model.apply_search_filter();
623 assert_eq!(model.search_results.len(), 2);
624 let names: Vec<&str> = model
625 .search_results
626 .iter()
627 .map(|&i| model.navigator.entries[i].name.as_str())
628 .collect();
629 assert!(names.contains(&"hello.txt"));
630 assert!(names.contains(&"say_hello.rs"));
631 }
632
633 #[test]
634 fn test_substring_path_search() {
635 let mut model = build_model(vec![
636 make_entry("snap1/tree/src/main.rs", HashMap::new()),
637 make_entry("snap1/tree/docs/guide.md", HashMap::new()),
638 ]);
639 model.search_query = "src".into();
640 model.apply_search_filter();
641 assert_eq!(model.search_results.len(), 1);
642 assert_eq!(
643 model.navigator.entries[model.search_results[0]].name,
644 "main.rs"
645 );
646 }
647
648 #[test]
649 fn test_dc_search() {
650 let mut xattr = HashMap::new();
651 xattr.insert(
652 "user.dublincore.title".into(),
653 hex::encode("quarterly report".as_bytes()),
654 );
655 let mut model = build_model(vec![make_entry("snap1/tree/doc.pdf", xattr)]);
656 model.search_query = "dc:title=report".into();
657 model.apply_search_filter();
658 assert_eq!(model.search_results.len(), 1);
659 assert_eq!(
660 model.navigator.entries[model.search_results[0]].name,
661 "doc.pdf"
662 );
663 }
664
665 #[test]
666 fn test_tag_search() {
667 let mut xattr = HashMap::new();
668 xattr.insert(
669 "user.xdg.tags".into(),
670 hex::encode("python,ml".as_bytes()),
671 );
672 let mut model = build_model(vec![make_entry("snap1/tree/notebook.py", xattr)]);
673 model.search_query = "tag:ml".into();
674 model.apply_search_filter();
675 assert_eq!(model.search_results.len(), 1);
676 assert_eq!(
677 model.navigator.entries[model.search_results[0]].name,
678 "notebook.py"
679 );
680 }
681
682 #[test]
683 fn test_search_clear() {
684 let mut model = build_model(vec![
685 make_entry("snap1/tree/a.rs", HashMap::new()),
686 make_entry("snap1/tree/b.rs", HashMap::new()),
687 ]);
688 model.search_query = "*.rs".into();
689 model.apply_search_filter();
690 assert_eq!(model.search_results.len(), 2);
691
692 model.search_query.clear();
693 model.apply_search_filter();
694 assert!(model.search_results.is_empty());
695 }
696}