use std::io::{self, Stdout};
use std::sync::Arc;
use std::time::Instant;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use rayon::prelude::*;
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use thiserror::Error;
use void_core::VoidContext;
use crate::{
color::ColorTheme,
event::{init as init_events, AppEvent, UserEvent},
keybind::KeyBind,
void_backend::{
self, AuditResult, Format, ObjectInfo, ObjectType,
},
widget::{
audit_detail::{AuditDetail, AuditDetailState, AuditLoading},
object_list::{ObjectList, ObjectListState},
},
};
#[derive(Debug, Error)]
pub enum AppError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("backend error: {0}")]
Backend(#[from] crate::void_backend::VoidBackendError),
#[error("void error: {0}")]
Void(#[from] void_core::VoidError),
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppView {
List,
Help,
}
pub fn run(ctx: VoidContext) -> Result<()> {
let store = ctx.open_store()?;
let cids = void_backend::list_all_objects(&ctx);
if cids.is_empty() {
eprintln!("No objects found in repository");
return Ok(());
}
eprintln!("Building index...");
let start = Instant::now();
let index = void_backend::build_index(&ctx, &store, 1000)?;
let index_time = start.elapsed();
eprintln!("Index built in {:.2}s", index_time.as_secs_f64());
let ctx = Arc::new(ctx);
let store = Arc::new(store);
let index = Arc::new(index);
eprintln!("Categorizing {} objects...", cids.len());
let audit_start = Instant::now();
let audit_results: Vec<(ObjectInfo, AuditResult)> = cids
.par_iter()
.map(|cid| {
let mut info = void_backend::categorize_object(&store, &index, cid);
let audit = void_backend::audit_object_indexed(&ctx, &store, &index, cid);
info.object_type = match &audit {
AuditResult::Commit(_) => ObjectType::Commit,
AuditResult::Metadata(_) => ObjectType::Metadata,
AuditResult::Manifest(_) => ObjectType::Manifest,
AuditResult::RepoManifest(_) => ObjectType::RepoManifest,
AuditResult::Shard(_) => ObjectType::Shard,
AuditResult::Error(_) => ObjectType::Unknown,
};
info.format = match &audit {
AuditResult::Commit(_) => Format::CommitV1,
AuditResult::Metadata(_) => Format::MetadataV1,
AuditResult::Manifest(_) => Format::ManifestV1,
AuditResult::RepoManifest(_) => Format::RepoManifestV1,
AuditResult::Shard(_) => Format::ShardV1,
AuditResult::Error(_) => Format::Unknown,
};
(info, audit)
})
.collect();
let audit_time = audit_start.elapsed();
let total_time = start.elapsed();
eprintln!(
"Categorized {} objects in {:.2}s ({:.0} objects/sec)",
cids.len(),
audit_time.as_secs_f64(),
cids.len() as f64 / audit_time.as_secs_f64()
);
eprintln!("Total startup: {:.2}s", total_time.as_secs_f64());
let mut objects = Vec::with_capacity(audit_results.len());
let mut audit_cache: rustc_hash::FxHashMap<String, AuditResult> =
rustc_hash::FxHashMap::default();
for (info, audit) in audit_results {
audit_cache.insert(info.cid.clone(), audit);
objects.push(info);
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let result = run_app_with_cache(&mut terminal, objects, audit_cache);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_app_with_cache(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
objects: Vec<ObjectInfo>,
audit_cache: rustc_hash::FxHashMap<String, AuditResult>,
) -> Result<()> {
let theme = ColorTheme::default();
let keybind = KeyBind::new();
let (_tx, rx) = init_events();
let mut list_state = ObjectListState::new(objects);
let mut detail_state = AuditDetailState::new();
let mut app_view = AppView::List;
let mut viewport_height: usize = 20;
loop {
terminal.draw(|frame| {
let area = frame.area();
viewport_height = area.height.saturating_sub(4) as usize;
match app_view {
AppView::List => {
render_main_view(
frame,
area,
&mut list_state,
&mut detail_state,
&audit_cache,
&theme,
&keybind,
);
}
AppView::Help => {
render_main_view(
frame,
area,
&mut list_state,
&mut detail_state,
&audit_cache,
&theme,
&keybind,
);
render_help_overlay(frame, area, &keybind, &theme);
}
}
})?;
match rx.recv() {
AppEvent::Key(key) => {
if let Some(user_event) = keybind.get(&key) {
match handle_event(
*user_event,
&mut app_view,
&mut list_state,
&mut detail_state,
viewport_height,
) {
EventResult::Continue => {}
EventResult::Quit => break,
}
}
}
AppEvent::Resize(_, _) => {
}
AppEvent::Quit => break,
}
}
Ok(())
}
enum EventResult {
Continue,
Quit,
}
fn handle_event(
event: UserEvent,
view: &mut AppView,
list_state: &mut ObjectListState,
detail_state: &mut AuditDetailState,
viewport_height: usize,
) -> EventResult {
match event {
UserEvent::Quit | UserEvent::ForceQuit => {
return EventResult::Quit;
}
UserEvent::HelpToggle => {
*view = match *view {
AppView::Help => AppView::List,
_ => AppView::Help,
};
}
UserEvent::Cancel | UserEvent::Close => {
if *view != AppView::List {
*view = AppView::List;
}
}
UserEvent::NavigateDown => {
list_state.select_next(viewport_height);
detail_state.reset();
}
UserEvent::NavigateUp => {
list_state.select_prev();
detail_state.reset();
}
UserEvent::HalfPageDown => {
list_state.scroll_down_half(viewport_height);
detail_state.reset();
}
UserEvent::HalfPageUp => {
list_state.scroll_up_half(viewport_height);
detail_state.reset();
}
UserEvent::PageDown => {
list_state.scroll_down_page(viewport_height);
detail_state.reset();
}
UserEvent::PageUp => {
list_state.scroll_up_page(viewport_height);
detail_state.reset();
}
UserEvent::GoToTop => {
list_state.select_first();
detail_state.reset();
}
UserEvent::GoToBottom => {
list_state.select_last(viewport_height);
detail_state.reset();
}
UserEvent::ScrollDown => {
detail_state.scroll_down();
}
UserEvent::ScrollUp => {
detail_state.scroll_up();
}
UserEvent::Confirm => {
}
}
EventResult::Continue
}
fn render_main_view(
frame: &mut Frame,
area: Rect,
list_state: &mut ObjectListState,
detail_state: &mut AuditDetailState,
audit_cache: &rustc_hash::FxHashMap<String, AuditResult>,
theme: &ColorTheme,
_keybind: &KeyBind,
) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(1)])
.split(area);
let content_area = main_chunks[0];
let status_area = main_chunks[1];
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(content_area);
let list_widget = ObjectList::new(theme);
frame.render_stateful_widget(list_widget, chunks[0], list_state);
if let Some(obj) = list_state.selected_object() {
if let Some(audit) = audit_cache.get(&obj.cid) {
let detail_widget = AuditDetail::new(obj, audit, theme);
frame.render_stateful_widget(detail_widget, chunks[1], detail_state);
} else {
let loading_widget = AuditLoading::new(obj, theme);
frame.render_widget(loading_widget, chunks[1]);
}
}
render_status_bar(frame, status_area, _keybind, theme);
}
fn render_status_bar(frame: &mut Frame, area: Rect, _keybind: &KeyBind, theme: &ColorTheme) {
let hints = [
("j/k", "navigate"),
("Ctrl-d/u", "scroll"),
("g/G", "top/bottom"),
("?", "help"),
("q", "quit"),
];
let spans: Vec<Span> = hints
.iter()
.enumerate()
.flat_map(|(i, (key, desc))| {
let mut result = vec![
Span::styled(
*key,
Style::default()
.fg(theme.help_key_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(": "),
Span::raw(*desc),
];
if i < hints.len() - 1 {
result.push(Span::raw(" "));
}
result
})
.collect();
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
fn render_help_overlay(frame: &mut Frame, area: Rect, keybind: &KeyBind, theme: &ColorTheme) {
let popup_width = 50.min(area.width.saturating_sub(4));
let popup_height = 16.min(area.height.saturating_sub(4));
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let help_items = [
(UserEvent::NavigateDown, "Move down"),
(UserEvent::NavigateUp, "Move up"),
(UserEvent::HalfPageDown, "Half page down"),
(UserEvent::HalfPageUp, "Half page up"),
(UserEvent::PageDown, "Page down"),
(UserEvent::PageUp, "Page up"),
(UserEvent::GoToTop, "Go to top"),
(UserEvent::GoToBottom, "Go to bottom"),
(UserEvent::ScrollDown, "Scroll detail down"),
(UserEvent::ScrollUp, "Scroll detail up"),
(UserEvent::HelpToggle, "Toggle help"),
(UserEvent::Quit, "Quit"),
];
let mut lines: Vec<Line> = Vec::new();
for (event, description) in help_items {
let keys = keybind.keys_for_event(event);
let key_str = if keys.is_empty() {
"(unbound)".to_string()
} else {
keys.join(", ")
};
lines.push(Line::from(vec![
Span::styled(
format!("{:>15}", key_str),
Style::default()
.fg(theme.help_key_fg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(description),
]));
}
let help_paragraph = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help ")
.title_style(Style::default().fg(theme.help_block_title_fg)),
)
.style(Style::default().fg(theme.fg).bg(theme.bg));
frame.render_widget(help_paragraph, popup_area);
}