1use 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#[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
51pub type Result<T> = std::result::Result<T, AppError>;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum AppView {
57 List,
59 Help,
61}
62
63pub fn run(ctx: VoidContext) -> Result<()> {
67 let store = ctx.open_store()?;
68
69 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 eprintln!("Building index...");
78 let start = Instant::now();
79
80 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 let ctx = Arc::new(ctx);
88 let store = Arc::new(store);
89 let index = Arc::new(index);
90
91 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 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 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 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 let result = run_app_with_cache(&mut terminal, objects, audit_cache);
151
152 disable_raw_mode()?;
154 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
155 terminal.show_cursor()?;
156
157 result
158}
159
160fn 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 let theme = ColorTheme::default();
168 let keybind = KeyBind::new();
169 let (_tx, rx) = init_events();
170
171 let mut list_state = ObjectListState::new(objects);
173 let mut detail_state = AuditDetailState::new();
174 let mut app_view = AppView::List;
175
176 let mut viewport_height: usize = 20;
178
179 loop {
181 terminal.draw(|frame| {
183 let area = frame.area();
184 viewport_height = area.height.saturating_sub(4) as usize; 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(
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 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 }
233 AppEvent::Quit => break,
234 }
235 }
236
237 Ok(())
238}
239
240enum EventResult {
242 Continue,
243 Quit,
244}
245
246fn 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 UserEvent::Quit | UserEvent::ForceQuit => {
257 return EventResult::Quit;
258 }
259
260 UserEvent::HelpToggle => {
262 *view = match *view {
263 AppView::Help => AppView::List,
264 _ => AppView::Help,
265 };
266 }
267
268 UserEvent::Cancel | UserEvent::Close => {
270 if *view != AppView::List {
271 *view = AppView::List;
272 }
273 }
274
275 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 UserEvent::ScrollDown => {
311 detail_state.scroll_down();
312 }
313 UserEvent::ScrollUp => {
314 detail_state.scroll_up();
315 }
316
317 UserEvent::Confirm => {
319 }
321 }
322
323 EventResult::Continue
324}
325
326fn 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 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 let chunks = Layout::default()
347 .direction(Direction::Horizontal)
348 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
349 .split(content_area);
350
351 let list_widget = ObjectList::new(theme);
353 frame.render_stateful_widget(list_widget, chunks[0], list_state);
354
355 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(frame, status_area, _keybind, theme);
368}
369
370fn 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
406fn render_help_overlay(frame: &mut Frame, area: Rect, keybind: &KeyBind, theme: &ColorTheme) {
408 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 frame.render_widget(Clear, popup_area);
417
418 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}