Skip to main content

void_audit_tui/
app.rs

1//! Main application for void-audit-tui.
2//!
3//! Provides an interactive TUI for auditing repository objects.
4
5use std::io::{self, Stdout};
6use std::sync::Arc;
7use std::time::Instant;
8
9use crossterm::{
10    execute,
11    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
12};
13use rayon::prelude::*;
14use ratatui::{
15    backend::CrosstermBackend,
16    layout::{Constraint, Direction, Layout, Rect},
17    style::{Modifier, Style},
18    text::{Line, Span},
19    widgets::{Block, Borders, Clear, Paragraph},
20    Frame, Terminal,
21};
22use thiserror::Error;
23use void_core::VoidContext;
24
25use crate::{
26    color::ColorTheme,
27    event::{init as init_events, AppEvent, UserEvent},
28    keybind::KeyBind,
29    void_backend::{
30        self, AuditResult, Format, ObjectInfo, ObjectType,
31    },
32    widget::{
33        audit_detail::{AuditDetail, AuditDetailState, AuditLoading},
34        object_list::{ObjectList, ObjectListState},
35    },
36};
37
38/// Error type for application operations.
39#[derive(Debug, Error)]
40pub enum AppError {
41    #[error("io error: {0}")]
42    Io(#[from] io::Error),
43
44    #[error("backend error: {0}")]
45    Backend(#[from] crate::void_backend::VoidBackendError),
46
47    #[error("void error: {0}")]
48    Void(#[from] void_core::VoidError),
49}
50
51/// Result type for application operations.
52pub type Result<T> = std::result::Result<T, AppError>;
53
54/// Current view state of the application.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum AppView {
57    /// Main object list view
58    List,
59    /// Help overlay
60    Help,
61}
62
63/// Run the void-audit-tui application.
64///
65/// Takes a fully constructed `VoidContext` from the CLI caller.
66pub fn run(ctx: VoidContext) -> Result<()> {
67    let store = ctx.open_store()?;
68
69    // Enumerate all objects
70    let cids = void_backend::list_all_objects(&ctx);
71    if cids.is_empty() {
72        eprintln!("No objects found in repository");
73        return Ok(());
74    }
75
76    // Show loading message
77    eprintln!("Building index...");
78    let start = Instant::now();
79
80    // Build index by walking commit history once
81    let index = void_backend::build_index(&ctx, &store, 1000)?;
82
83    let index_time = start.elapsed();
84    eprintln!("Index built in {:.2}s", index_time.as_secs_f64());
85
86    // Wrap shared state in Arc for parallel access
87    let ctx = Arc::new(ctx);
88    let store = Arc::new(store);
89    let index = Arc::new(index);
90
91    // Now audit all objects in parallel using the index
92    eprintln!("Categorizing {} objects...", cids.len());
93    let audit_start = Instant::now();
94
95    let audit_results: Vec<(ObjectInfo, AuditResult)> = cids
96        .par_iter()
97        .map(|cid| {
98            let mut info = void_backend::categorize_object(&store, &index, cid);
99            let audit = void_backend::audit_object_indexed(&ctx, &store, &index, cid);
100            // Update type and format from audit result
101            info.object_type = match &audit {
102                AuditResult::Commit(_) => ObjectType::Commit,
103                AuditResult::Metadata(_) => ObjectType::Metadata,
104                AuditResult::Manifest(_) => ObjectType::Manifest,
105                AuditResult::RepoManifest(_) => ObjectType::RepoManifest,
106                AuditResult::Shard(_) => ObjectType::Shard,
107                AuditResult::Error(_) => ObjectType::Unknown,
108            };
109            info.format = match &audit {
110                AuditResult::Commit(_) => Format::CommitV1,
111                AuditResult::Metadata(_) => Format::MetadataV1,
112                AuditResult::Manifest(_) => Format::ManifestV1,
113                AuditResult::RepoManifest(_) => Format::RepoManifestV1,
114                AuditResult::Shard(_) => Format::ShardV1,
115                AuditResult::Error(_) => Format::Unknown,
116            };
117            (info, audit)
118        })
119        .collect();
120
121    let audit_time = audit_start.elapsed();
122    let total_time = start.elapsed();
123    eprintln!(
124        "Categorized {} objects in {:.2}s ({:.0} objects/sec)",
125        cids.len(),
126        audit_time.as_secs_f64(),
127        cids.len() as f64 / audit_time.as_secs_f64()
128    );
129    eprintln!("Total startup: {:.2}s", total_time.as_secs_f64());
130
131    // Split into objects and pre-populated cache
132    let mut objects = Vec::with_capacity(audit_results.len());
133    let mut audit_cache: rustc_hash::FxHashMap<String, AuditResult> =
134        rustc_hash::FxHashMap::default();
135
136    for (info, audit) in audit_results {
137        audit_cache.insert(info.cid.clone(), audit);
138        objects.push(info);
139    }
140
141    // Setup terminal
142    enable_raw_mode()?;
143    let mut stdout = io::stdout();
144    execute!(stdout, EnterAlternateScreen)?;
145    let backend = CrosstermBackend::new(stdout);
146    let mut terminal = Terminal::new(backend)?;
147    terminal.clear()?;
148
149    // Run the app and capture result
150    let result = run_app_with_cache(&mut terminal, objects, audit_cache);
151
152    // Cleanup terminal (always, even on error)
153    disable_raw_mode()?;
154    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
155    terminal.show_cursor()?;
156
157    result
158}
159
160/// Main application loop with pre-populated audit cache.
161fn run_app_with_cache(
162    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
163    objects: Vec<ObjectInfo>,
164    audit_cache: rustc_hash::FxHashMap<String, AuditResult>,
165) -> Result<()> {
166    // Initialize resources
167    let theme = ColorTheme::default();
168    let keybind = KeyBind::new();
169    let (_tx, rx) = init_events();
170
171    // Initialize state
172    let mut list_state = ObjectListState::new(objects);
173    let mut detail_state = AuditDetailState::new();
174    let mut app_view = AppView::List;
175
176    // Track viewport height for navigation
177    let mut viewport_height: usize = 20;
178
179    // Main loop
180    loop {
181        // Render
182        terminal.draw(|frame| {
183            let area = frame.area();
184            viewport_height = area.height.saturating_sub(4) as usize; // Account for borders + status
185
186            match app_view {
187                AppView::List => {
188                    render_main_view(
189                        frame,
190                        area,
191                        &mut list_state,
192                        &mut detail_state,
193                        &audit_cache,
194                        &theme,
195                        &keybind,
196                    );
197                }
198                AppView::Help => {
199                    // Render main view in background, then help overlay
200                    render_main_view(
201                        frame,
202                        area,
203                        &mut list_state,
204                        &mut detail_state,
205                        &audit_cache,
206                        &theme,
207                        &keybind,
208                    );
209                    render_help_overlay(frame, area, &keybind, &theme);
210                }
211            }
212        })?;
213
214        // Handle events
215        match rx.recv() {
216            AppEvent::Key(key) => {
217                if let Some(user_event) = keybind.get(&key) {
218                    match handle_event(
219                        *user_event,
220                        &mut app_view,
221                        &mut list_state,
222                        &mut detail_state,
223                        viewport_height,
224                    ) {
225                        EventResult::Continue => {}
226                        EventResult::Quit => break,
227                    }
228                }
229            }
230            AppEvent::Resize(_, _) => {
231                // Terminal will handle resize automatically
232            }
233            AppEvent::Quit => break,
234        }
235    }
236
237    Ok(())
238}
239
240/// Result of handling an event.
241enum EventResult {
242    Continue,
243    Quit,
244}
245
246/// Handle a user event and return the result.
247fn handle_event(
248    event: UserEvent,
249    view: &mut AppView,
250    list_state: &mut ObjectListState,
251    detail_state: &mut AuditDetailState,
252    viewport_height: usize,
253) -> EventResult {
254    match event {
255        // Quit events
256        UserEvent::Quit | UserEvent::ForceQuit => {
257            return EventResult::Quit;
258        }
259
260        // Help toggle
261        UserEvent::HelpToggle => {
262            *view = match *view {
263                AppView::Help => AppView::List,
264                _ => AppView::Help,
265            };
266        }
267
268        // Cancel/close - return to list from help
269        UserEvent::Cancel | UserEvent::Close => {
270            if *view != AppView::List {
271                *view = AppView::List;
272            }
273        }
274
275        // Navigation events
276        UserEvent::NavigateDown => {
277            list_state.select_next(viewport_height);
278            detail_state.reset();
279        }
280        UserEvent::NavigateUp => {
281            list_state.select_prev();
282            detail_state.reset();
283        }
284        UserEvent::HalfPageDown => {
285            list_state.scroll_down_half(viewport_height);
286            detail_state.reset();
287        }
288        UserEvent::HalfPageUp => {
289            list_state.scroll_up_half(viewport_height);
290            detail_state.reset();
291        }
292        UserEvent::PageDown => {
293            list_state.scroll_down_page(viewport_height);
294            detail_state.reset();
295        }
296        UserEvent::PageUp => {
297            list_state.scroll_up_page(viewport_height);
298            detail_state.reset();
299        }
300        UserEvent::GoToTop => {
301            list_state.select_first();
302            detail_state.reset();
303        }
304        UserEvent::GoToBottom => {
305            list_state.select_last(viewport_height);
306            detail_state.reset();
307        }
308
309        // Scroll detail view content
310        UserEvent::ScrollDown => {
311            detail_state.scroll_down();
312        }
313        UserEvent::ScrollUp => {
314            detail_state.scroll_up();
315        }
316
317        // Confirm - could be used for future expansion (e.g., copy CID)
318        UserEvent::Confirm => {
319            // Currently no action
320        }
321    }
322
323    EventResult::Continue
324}
325
326/// Render the main split-pane view.
327fn render_main_view(
328    frame: &mut Frame,
329    area: Rect,
330    list_state: &mut ObjectListState,
331    detail_state: &mut AuditDetailState,
332    audit_cache: &rustc_hash::FxHashMap<String, AuditResult>,
333    theme: &ColorTheme,
334    _keybind: &KeyBind,
335) {
336    // Split into three areas: list, detail, and status bar
337    let main_chunks = Layout::default()
338        .direction(Direction::Vertical)
339        .constraints([Constraint::Min(5), Constraint::Length(1)])
340        .split(area);
341
342    let content_area = main_chunks[0];
343    let status_area = main_chunks[1];
344
345    // Split content into left (list) and right (detail)
346    let chunks = Layout::default()
347        .direction(Direction::Horizontal)
348        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
349        .split(content_area);
350
351    // Render object list
352    let list_widget = ObjectList::new(theme);
353    frame.render_stateful_widget(list_widget, chunks[0], list_state);
354
355    // Render detail panel
356    if let Some(obj) = list_state.selected_object() {
357        if let Some(audit) = audit_cache.get(&obj.cid) {
358            let detail_widget = AuditDetail::new(obj, audit, theme);
359            frame.render_stateful_widget(detail_widget, chunks[1], detail_state);
360        } else {
361            let loading_widget = AuditLoading::new(obj, theme);
362            frame.render_widget(loading_widget, chunks[1]);
363        }
364    }
365
366    // Render status bar
367    render_status_bar(frame, status_area, _keybind, theme);
368}
369
370/// Render the status bar with key hints.
371fn render_status_bar(frame: &mut Frame, area: Rect, _keybind: &KeyBind, theme: &ColorTheme) {
372    let hints = [
373        ("j/k", "navigate"),
374        ("Ctrl-d/u", "scroll"),
375        ("g/G", "top/bottom"),
376        ("?", "help"),
377        ("q", "quit"),
378    ];
379
380    let spans: Vec<Span> = hints
381        .iter()
382        .enumerate()
383        .flat_map(|(i, (key, desc))| {
384            let mut result = vec![
385                Span::styled(
386                    *key,
387                    Style::default()
388                        .fg(theme.help_key_fg)
389                        .add_modifier(Modifier::BOLD),
390                ),
391                Span::raw(": "),
392                Span::raw(*desc),
393            ];
394            if i < hints.len() - 1 {
395                result.push(Span::raw("  "));
396            }
397            result
398        })
399        .collect();
400
401    let line = Line::from(spans);
402    let paragraph = Paragraph::new(line);
403    frame.render_widget(paragraph, area);
404}
405
406/// Render the help overlay.
407fn render_help_overlay(frame: &mut Frame, area: Rect, keybind: &KeyBind, theme: &ColorTheme) {
408    // Calculate centered popup area
409    let popup_width = 50.min(area.width.saturating_sub(4));
410    let popup_height = 16.min(area.height.saturating_sub(4));
411    let popup_x = (area.width.saturating_sub(popup_width)) / 2;
412    let popup_y = (area.height.saturating_sub(popup_height)) / 2;
413    let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
414
415    // Clear the popup area
416    frame.render_widget(Clear, popup_area);
417
418    // Build help content
419    let help_items = [
420        (UserEvent::NavigateDown, "Move down"),
421        (UserEvent::NavigateUp, "Move up"),
422        (UserEvent::HalfPageDown, "Half page down"),
423        (UserEvent::HalfPageUp, "Half page up"),
424        (UserEvent::PageDown, "Page down"),
425        (UserEvent::PageUp, "Page up"),
426        (UserEvent::GoToTop, "Go to top"),
427        (UserEvent::GoToBottom, "Go to bottom"),
428        (UserEvent::ScrollDown, "Scroll detail down"),
429        (UserEvent::ScrollUp, "Scroll detail up"),
430        (UserEvent::HelpToggle, "Toggle help"),
431        (UserEvent::Quit, "Quit"),
432    ];
433
434    let mut lines: Vec<Line> = Vec::new();
435    for (event, description) in help_items {
436        let keys = keybind.keys_for_event(event);
437        let key_str = if keys.is_empty() {
438            "(unbound)".to_string()
439        } else {
440            keys.join(", ")
441        };
442
443        lines.push(Line::from(vec![
444            Span::styled(
445                format!("{:>15}", key_str),
446                Style::default()
447                    .fg(theme.help_key_fg)
448                    .add_modifier(Modifier::BOLD),
449            ),
450            Span::raw("  "),
451            Span::raw(description),
452        ]));
453    }
454
455    let help_paragraph = Paragraph::new(lines)
456        .block(
457            Block::default()
458                .borders(Borders::ALL)
459                .title(" Help ")
460                .title_style(Style::default().fg(theme.help_block_title_fg)),
461        )
462        .style(Style::default().fg(theme.fg).bg(theme.bg));
463
464    frame.render_widget(help_paragraph, popup_area);
465}