Skip to main content

fxcp_core/browser/
mod.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/browser/mod.rs  --  MC-style dual-pane file/snapshot browser
5
6//! Midnight Commander-inspired TUI for browsing filesystems, snapshots,
7//! and .fxar archives. Feature-gated behind `tui`.
8
9mod model;
10mod navigator;
11mod render;
12mod events;
13pub mod archive_navigator;
14
15pub use model::{ArchiveModel, BrowserApp, BrowserMode};
16pub use archive_navigator::ArchiveNavigator;
17
18#[cfg(test)]
19#[allow(clippy::unwrap_used, clippy::expect_used)]
20mod archive_tree_tests {
21    #[cfg(feature = "tui")]
22    #[test]
23    fn test_tui_tree_widget_imports() {
24        use tui_tree_widget::TreeState;
25        let _state: TreeState<String> = TreeState::default();
26    }
27}
28
29#[cfg(test)]
30#[allow(clippy::unwrap_used, clippy::expect_used)]
31mod archive_event_tests {
32    use super::events;
33    use super::model::{ArchiveModel, BrowserModel, BrowserMode};
34    use super::archive_navigator::ArchiveNavigator;
35    use crate::fxar::FxarManifest;
36    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
37
38    fn dummy_manifest() -> FxarManifest {
39        FxarManifest {
40            version: 2,
41            created: "2026-01-01T00:00:00Z".into(),
42            files: vec![],
43            snapshots: vec!["snap1".into(), "snap2".into()],
44        }
45    }
46
47    fn make_model() -> BrowserModel {
48        let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
49        let dummy = std::path::Path::new(".");
50        let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
51        model.archive = Some(ArchiveModel::new(nav));
52        model
53    }
54
55    fn key(code: KeyCode) -> KeyEvent {
56        KeyEvent::new(code, KeyModifiers::NONE)
57    }
58
59    #[test]
60    fn test_archive_event_quit() {
61        let mut model = make_model();
62        let quit = events::handle_key(&mut model, key(KeyCode::Char('Q')));
63        assert!(quit);
64        assert!(model.should_quit);
65    }
66
67    #[test]
68    fn test_archive_event_search_mode() {
69        let mut model = make_model();
70        events::handle_key(&mut model, key(KeyCode::Char('/')));
71        assert!(model.archive.as_ref().unwrap().search_mode);
72
73        events::handle_key(&mut model, key(KeyCode::Char('a')));
74        assert_eq!(model.archive.as_ref().unwrap().search_query, "a");
75
76        events::handle_key(&mut model, key(KeyCode::Esc));
77        assert!(!model.archive.as_ref().unwrap().search_mode);
78        assert!(model.archive.as_ref().unwrap().search_query.is_empty());
79    }
80
81    #[test]
82    fn test_archive_event_snapshot_popup() {
83        let mut model = make_model();
84        events::handle_key(&mut model, key(KeyCode::Char('S')));
85        assert!(model.archive.as_ref().unwrap().snapshot_popup);
86
87        events::handle_key(&mut model, key(KeyCode::Esc));
88        assert!(!model.archive.as_ref().unwrap().snapshot_popup);
89    }
90
91    #[test]
92    fn test_archive_event_detail_toggle() {
93        let mut model = make_model();
94        assert!(!model.archive.as_ref().unwrap().detail_popup);
95        events::handle_key(&mut model, key(KeyCode::Char('i')));
96        assert!(model.archive.as_ref().unwrap().detail_popup);
97        events::handle_key(&mut model, key(KeyCode::Char('i')));
98        assert!(!model.archive.as_ref().unwrap().detail_popup);
99    }
100
101    #[test]
102    fn test_archive_event_metadata_toggle() {
103        let mut model = make_model();
104        assert!(model.archive.as_ref().unwrap().show_metadata);
105        events::handle_key(&mut model, key(KeyCode::Char('m')));
106        assert!(!model.archive.as_ref().unwrap().show_metadata);
107    }
108
109    #[test]
110    fn test_archive_snapshot_popup_select() {
111        let mut model = make_model();
112        events::handle_key(&mut model, key(KeyCode::Char('s')));
113        assert!(model.archive.as_ref().unwrap().snapshot_popup);
114
115        events::handle_key(&mut model, key(KeyCode::Down));
116        assert_eq!(model.archive.as_ref().unwrap().snapshot_popup_selected, 1);
117
118        events::handle_key(&mut model, key(KeyCode::Enter));
119        assert!(!model.archive.as_ref().unwrap().snapshot_popup);
120        assert_eq!(model.archive.as_ref().unwrap().snapshot_filter, Some("snap1".into()));
121    }
122
123    #[test]
124    fn test_archive_preview_toggle() {
125        let mut model = make_model();
126        assert!(!model.archive.as_ref().unwrap().preview_mode);
127
128        events::handle_key(&mut model, key(KeyCode::Char('v')));
129        assert!(model.archive.as_ref().unwrap().preview_mode);
130
131        events::handle_key(&mut model, key(KeyCode::Char('V')));
132        assert!(!model.archive.as_ref().unwrap().preview_mode);
133        assert!(model.archive.as_ref().unwrap().preview_content_raw.is_none());
134    }
135
136    #[test]
137    fn test_archive_preview_esc_clears() {
138        let mut model = make_model();
139        events::handle_key(&mut model, key(KeyCode::Char('v')));
140        {
141            let a = model.archive.as_mut().unwrap();
142            a.preview_content_raw = Some(vec![1, 2, 3]);
143            a.preview_scroll = 5;
144        }
145        events::handle_key(&mut model, key(KeyCode::Esc));
146        let a = model.archive.as_ref().unwrap();
147        assert!(!a.preview_mode);
148        assert!(a.preview_content_raw.is_none());
149        assert_eq!(a.preview_scroll, 0);
150    }
151
152    #[test]
153    fn test_archive_preview_scroll() {
154        let mut model = make_model();
155        events::handle_key(&mut model, key(KeyCode::Char('v')));
156        assert!(model.archive.as_ref().unwrap().preview_mode);
157
158        events::handle_key(&mut model, key(KeyCode::Down));
159        assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 1);
160        events::handle_key(&mut model, key(KeyCode::Down));
161        assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 2);
162        events::handle_key(&mut model, key(KeyCode::Up));
163        assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 1);
164        events::handle_key(&mut model, key(KeyCode::Up));
165        assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 0);
166        events::handle_key(&mut model, key(KeyCode::Up));
167        assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 0);
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used, clippy::expect_used)]
173mod preview_content_tests {
174    use super::render::{is_text_content, hex_dump};
175
176    #[test]
177    fn test_preview_text_detection() {
178        let bytes = b"Hello, world!\nLine 2\n".to_vec();
179        assert!(is_text_content(&bytes));
180    }
181
182    #[test]
183    fn test_preview_binary_detection() {
184        let bytes = vec![0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01];
185        assert!(!is_text_content(&bytes));
186    }
187
188    #[test]
189    fn test_hex_format() {
190        let bytes: Vec<u8> = (0..16).collect();
191        let lines = hex_dump(&bytes);
192        assert_eq!(lines.len(), 1);
193        let line_str = format!("{:?}", lines[0]);
194        assert!(line_str.contains("00000000"));
195        assert!(line_str.contains("00 01 02 03"));
196    }
197
198    #[test]
199    fn test_hex_dump_truncation() {
200        let bytes: Vec<u8> = (0..=255).cycle().take(512).collect();
201        let lines = hex_dump(&bytes);
202        let last = format!("{:?}", lines.last().unwrap());
203        assert!(last.contains("512 bytes total"));
204    }
205
206    #[test]
207    fn test_text_with_tabs() {
208        let bytes = b"fn main() {\n\tprintln!(\"hi\");\n}\n".to_vec();
209        assert!(is_text_content(&bytes));
210    }
211
212    #[test]
213    fn test_empty_is_text() {
214        assert!(is_text_content(&[]));
215    }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used, clippy::expect_used)]
220mod extract_tests {
221    use super::events;
222    use super::model::{ArchiveModel, BrowserModel, BrowserMode};
223    use super::archive_navigator::ArchiveNavigator;
224    use crate::fxar::{FxarManifest, FxarManifestEntry};
225    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
226    use std::collections::HashMap;
227
228    fn make_manifest_with_files() -> FxarManifest {
229        FxarManifest {
230            version: 2,
231            created: "2026-01-01T00:00:00Z".into(),
232            files: vec![
233                FxarManifestEntry {
234                    path: "snap1/tree/hello.txt".into(),
235                    size: 13,
236                    mode: 0o100644,
237                    mtime: 1700000000,
238                    uid: 1000,
239                    gid: 1000,
240                    blake3: "0".repeat(64),
241                    chunks: vec![0],
242                    xattr: HashMap::new(),
243                },
244            ],
245            snapshots: vec!["snap1".into()],
246        }
247    }
248
249    fn make_model() -> BrowserModel {
250        let manifest = make_manifest_with_files();
251        let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
252        let dummy = std::path::Path::new(".");
253        let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
254        model.archive = Some(ArchiveModel::new(nav));
255        model
256    }
257
258    fn key(code: KeyCode) -> KeyEvent {
259        KeyEvent::new(code, KeyModifiers::NONE)
260    }
261
262    #[test]
263    fn test_extract_prompt_default_dest() {
264        let mut model = make_model();
265        events::handle_key(&mut model, key(KeyCode::Char('E')));
266        let a = model.archive.as_ref().unwrap();
267        assert!(a.extract_prompt);
268        assert!(!a.extract_dest.is_empty());
269    }
270
271    #[test]
272    fn test_extract_prompt_input() {
273        let mut model = make_model();
274        {
275            let a = model.archive.as_mut().unwrap();
276            a.extract_prompt = true;
277            a.extract_dest = "/tmp/test".to_string();
278        }
279
280        events::handle_key(&mut model, key(KeyCode::Char('x')));
281        assert_eq!(model.archive.as_ref().unwrap().extract_dest, "/tmp/testx");
282
283        events::handle_key(&mut model, key(KeyCode::Backspace));
284        assert_eq!(model.archive.as_ref().unwrap().extract_dest, "/tmp/test");
285
286        events::handle_key(&mut model, key(KeyCode::Esc));
287        let a = model.archive.as_ref().unwrap();
288        assert!(!a.extract_prompt);
289        assert!(a.extract_dest.is_empty());
290    }
291
292    #[test]
293    fn test_extract_prompt_enter_sets_pending() {
294        let mut model = make_model();
295        {
296            let a = model.archive.as_mut().unwrap();
297            a.extract_prompt = true;
298            a.extract_dest = "/tmp/output.txt".to_string();
299        }
300
301        events::handle_key(&mut model, key(KeyCode::Enter));
302        let a = model.archive.as_ref().unwrap();
303        assert!(!a.extract_prompt);
304        assert_eq!(a.pending_extract, Some("/tmp/output.txt".to_string()));
305    }
306}
307
308#[cfg(test)]
309#[allow(clippy::unwrap_used, clippy::expect_used)]
310mod vector_search_tests {
311    use super::events;
312    use super::model::{ArchiveModel, BrowserApp, BrowserModel, BrowserMode};
313    use super::archive_navigator::ArchiveNavigator;
314    use crate::fxar::FxarManifest;
315    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
316
317    fn dummy_manifest() -> FxarManifest {
318        FxarManifest {
319            version: 2,
320            created: "2026-01-01T00:00:00Z".into(),
321            files: vec![],
322            snapshots: vec!["snap1".into()],
323        }
324    }
325
326    fn make_browser_app() -> BrowserApp {
327        let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
328        let archive_model = ArchiveModel::new(nav);
329        let dummy = std::path::Path::new(".");
330        let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
331        model.archive = Some(archive_model);
332        BrowserApp {
333            model,
334            fxar_path: None,
335            vector_search_fn: None,
336        }
337    }
338
339    fn make_model() -> BrowserModel {
340        let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
341        let dummy = std::path::Path::new(".");
342        let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
343        model.archive = Some(ArchiveModel::new(nav));
344        model
345    }
346
347    fn key(code: KeyCode) -> KeyEvent {
348        KeyEvent::new(code, KeyModifiers::NONE)
349    }
350
351    fn ctrl_key(code: KeyCode) -> KeyEvent {
352        KeyEvent::new(code, KeyModifiers::CONTROL)
353    }
354
355    #[test]
356    fn test_vector_search_callback_mock() {
357        let mut app = make_browser_app();
358        app.set_vector_search_fn(|_query| {
359            Ok(vec![
360                (0.80, "snap1/tree/lib.rs".to_string()),
361                (0.95, "snap1/tree/main.rs".to_string()),
362            ])
363        });
364
365        if let Some(archive) = &mut app.model.archive {
366            archive.vector_search_pending = true;
367            archive.vector_query = "rust main function".to_string();
368        }
369
370        app.run_vector_search();
371
372        let archive = app.model.archive.as_ref().unwrap();
373        assert_eq!(archive.vector_results.len(), 2);
374        assert!(archive.vector_results[0].0 >= archive.vector_results[1].0);
375        assert_eq!(archive.vector_results[0].1, "snap1/tree/main.rs");
376        assert_eq!(archive.vector_results[1].1, "snap1/tree/lib.rs");
377        assert!(archive.vector_status.as_deref().unwrap().contains("Found 2"));
378    }
379
380    #[test]
381    fn test_vector_search_no_fn() {
382        let mut app = make_browser_app();
383        if let Some(archive) = &mut app.model.archive {
384            archive.vector_search_pending = true;
385            archive.vector_query = "test query".to_string();
386        }
387
388        app.run_vector_search();
389
390        let archive = app.model.archive.as_ref().unwrap();
391        assert!(archive.vector_results.is_empty());
392        assert!(archive.vector_status.as_deref().unwrap().contains("--ai"));
393    }
394
395    #[test]
396    fn test_vector_search_esc_clears() {
397        let mut model = make_model();
398        {
399            let a = model.archive.as_mut().unwrap();
400            a.vector_search_mode = true;
401            a.vector_query = "test".to_string();
402            a.vector_results = vec![(0.9, "file.rs".to_string())];
403            a.vector_status = Some("Found 1 results".to_string());
404        }
405
406        events::handle_key(&mut model, key(KeyCode::Esc));
407
408        let a = model.archive.as_ref().unwrap();
409        assert!(!a.vector_search_mode);
410        assert!(a.vector_query.is_empty());
411        assert!(a.vector_results.is_empty());
412        assert!(a.vector_status.is_none());
413    }
414
415    #[test]
416    fn test_vector_search_ctrl_slash_toggle() {
417        let mut model = make_model();
418        assert!(!model.archive.as_ref().unwrap().vector_search_mode);
419
420        events::handle_key(&mut model, ctrl_key(KeyCode::Char('/')));
421        assert!(model.archive.as_ref().unwrap().vector_search_mode);
422
423        events::handle_key(&mut model, ctrl_key(KeyCode::Char('/')));
424        assert!(!model.archive.as_ref().unwrap().vector_search_mode);
425    }
426
427    #[test]
428    fn test_vector_search_typing() {
429        let mut model = make_model();
430        {
431            let a = model.archive.as_mut().unwrap();
432            a.vector_search_mode = true;
433        }
434
435        events::handle_key(&mut model, key(KeyCode::Char('h')));
436        events::handle_key(&mut model, key(KeyCode::Char('i')));
437        assert_eq!(model.archive.as_ref().unwrap().vector_query, "hi");
438
439        events::handle_key(&mut model, key(KeyCode::Backspace));
440        assert_eq!(model.archive.as_ref().unwrap().vector_query, "h");
441    }
442
443    #[test]
444    fn test_vector_search_enter_sets_pending() {
445        let mut model = make_model();
446        {
447            let a = model.archive.as_mut().unwrap();
448            a.vector_search_mode = true;
449            a.vector_query = "find me".to_string();
450        }
451
452        events::handle_key(&mut model, key(KeyCode::Enter));
453
454        let a = model.archive.as_ref().unwrap();
455        assert!(a.vector_search_pending);
456        assert_eq!(a.vector_status.as_deref(), Some("Searching..."));
457        assert!(a.vector_results.is_empty());
458    }
459
460    #[test]
461    fn test_vector_search_enter_empty_query_no_pending() {
462        let mut model = make_model();
463        {
464            let a = model.archive.as_mut().unwrap();
465            a.vector_search_mode = true;
466        }
467
468        events::handle_key(&mut model, key(KeyCode::Enter));
469
470        let a = model.archive.as_ref().unwrap();
471        assert!(!a.vector_search_pending);
472    }
473
474    #[test]
475    fn test_vector_search_error_handling() {
476        let mut app = make_browser_app();
477        app.set_vector_search_fn(|_query| {
478            Err(std::io::Error::other("model not loaded"))
479        });
480
481        if let Some(archive) = &mut app.model.archive {
482            archive.vector_search_pending = true;
483            archive.vector_query = "test".to_string();
484        }
485
486        app.run_vector_search();
487
488        let archive = app.model.archive.as_ref().unwrap();
489        assert!(archive.vector_results.is_empty());
490        assert!(archive.vector_status.as_deref().unwrap().contains("Search error"));
491    }
492}
493
494#[cfg(test)]
495#[allow(clippy::unwrap_used, clippy::expect_used)]
496mod degradation_tests {
497    use super::model::{ArchiveModel, BrowserApp, BrowserModel, BrowserMode};
498    use super::archive_navigator::ArchiveNavigator;
499    use crate::fxar::{FxarManifest, FxarManifestEntry};
500    use std::collections::HashMap;
501
502    fn make_manifest(files: Vec<FxarManifestEntry>, snapshots: Vec<String>) -> FxarManifest {
503        FxarManifest {
504            version: 2,
505            created: "2026-04-29T00:00:00Z".into(),
506            files,
507            snapshots,
508        }
509    }
510
511    fn make_entry(path: &str) -> FxarManifestEntry {
512        FxarManifestEntry {
513            path: path.into(),
514            size: 42,
515            mode: 0o100644,
516            mtime: 1700000000,
517            uid: 1000,
518            gid: 1000,
519            blake3: "0".repeat(64),
520            chunks: vec![0],
521            xattr: HashMap::new(),
522        }
523    }
524
525    #[test]
526    fn test_empty_archive_model() {
527        let manifest = make_manifest(vec![], vec![]);
528        let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
529        let model = ArchiveModel::new(nav);
530        let items = model.build_tree_items();
531        assert!(items.is_empty(), "Empty archive should yield empty tree items");
532        assert_eq!(model.navigator.entries.len(), 1, "Only root node");
533    }
534
535    #[test]
536    fn test_unicode_filename() {
537        let manifest = make_manifest(
538            vec![make_entry("snap1/tree/日本語ファイル.txt")],
539            vec!["snap1".into()],
540        );
541        let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
542        let file_entry = nav.entries.iter()
543            .find(|e| !e.is_dir && e.name.contains("日本語"));
544        assert!(file_entry.is_some(), "Unicode filename entry should exist");
545        assert_eq!(file_entry.unwrap().name, "日本語ファイル.txt");
546    }
547
548    #[test]
549    fn test_extract_status_on_failure() {
550        let manifest = make_manifest(
551            vec![make_entry("snap1/tree/hello.txt")],
552            vec!["snap1".into()],
553        );
554        let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
555        let dummy = std::path::Path::new(".");
556        let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
557        model.archive = Some(ArchiveModel::new(nav));
558
559        let mut app = BrowserApp {
560            model,
561            fxar_path: None,
562            vector_search_fn: None,
563        };
564
565        {
566            let a = app.model.archive.as_mut().unwrap();
567            a.pending_extract = Some("/nonexistent/nested/path".to_string());
568        }
569
570        app.do_extract("/nonexistent/nested/path");
571
572        let status = app.model.archive.as_ref().unwrap().extract_status.as_deref().unwrap();
573        assert!(
574            status.contains("No archive path") || status.contains("Error"),
575            "Expected error status, got: {}",
576            status
577        );
578    }
579}