Skip to main content

fxcp_core/browser/
model.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3// fxcp-core/src/browser/model.rs — Browser state model
4
5#![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/// Browser operating mode.
12#[derive(Debug, Clone, PartialEq)]
13pub enum BrowserMode {
14    /// Left=filesystem, Right=snapshot list/tree
15    FilesystemSnapshots,
16    /// Left=archive contents, Right=target filesystem
17    ArchiveBrowse,
18}
19
20/// Which pane has focus.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum Focus {
23    Left,
24    Right,
25}
26
27/// State for archive browsing mode.
28pub struct ArchiveModel {
29    pub navigator: ArchiveNavigator,
30    pub tree_state: TreeState<String>,
31    /// Whether the full metadata panel is shown (or toggled off for narrow terminals).
32    pub show_metadata: bool,
33    /// Whether we're in search input mode.
34    pub search_mode: bool,
35    pub search_query: String,
36    /// Indices into navigator.entries that match the current search query.
37    pub search_results: Vec<usize>,
38    /// None = all snapshots; Some(name) = filtered to one snapshot.
39    pub snapshot_filter: Option<String>,
40    /// Whether the detail popup is shown (I key).
41    pub detail_popup: bool,
42    /// Whether the snapshot selector popup is open (S key).
43    pub snapshot_popup: bool,
44    /// Selected index in the snapshot popup list.
45    pub snapshot_popup_selected: usize,
46    /// Whether a file preview is shown (V key).
47    pub preview_mode: bool,
48    /// Raw bytes from `restore_file_head()` for the selected file.
49    pub preview_content_raw: Option<Vec<u8>>,
50    /// Vertical scroll offset for preview panel.
51    pub preview_scroll: u16,
52    /// True when the user pressed E and is entering a destination path.
53    pub extract_prompt: bool,
54    /// The destination path being typed in the extract prompt.
55    pub extract_dest: String,
56    /// Result message shown after extraction (e.g. "Extracted: /path (1234 B)").
57    pub extract_status: Option<String>,
58    /// Pending extract request: destination path confirmed by Enter.
59    pub pending_extract: Option<String>,
60    /// Whether we're in vector/semantic search mode (Ctrl+/).
61    pub vector_search_mode: bool,
62    /// The natural-language query being typed for vector search.
63    pub vector_query: String,
64    /// (score, file_path) pairs from the vector search callback, sorted desc by score.
65    pub vector_results: Vec<(f32, String)>,
66    /// Status message for vector search ("No AI index", "Searching...", "Found N results", or error).
67    pub vector_status: Option<String>,
68    /// Flag set by Enter in vector search mode; consumed by BrowserApp::run_vector_search().
69    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()]); // open root by default
76        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        // Glob metacharacters trigger glob matching; otherwise substring
193        // (consistent with CLI `snap inspect --file` which uses .contains())
194        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
215/// Main browser state.
216pub 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
254/// The main browser application.
255pub 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                // Strip the common prefix so we preserve relative structure under dest
368                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}