Skip to main content

void_graph/
app.rs

1//! Main application for void-graph TUI.
2//!
3//! Adapted from [Serie](https://github.com/lusingander/serie) by lusingander.
4
5use std::io::{self, Stdout};
6
7use camino::Utf8Path;
8use crossterm::{
9    event::{DisableMouseCapture, EnableMouseCapture},
10    execute,
11    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
12};
13use ratatui::{
14    backend::CrosstermBackend,
15    layout::{Constraint, Direction, Layout, Rect},
16    style::{Modifier, Style},
17    text::{Line, Span},
18    widgets::{Block, Borders, Clear, Paragraph},
19    Frame, Terminal,
20};
21use thiserror::Error;
22
23use crate::{
24    color::ColorTheme,
25    event::{init as init_events, AppEvent, UserEvent},
26    keybind::KeyBind,
27    void_backend::{SortOrder, VoidCommit, VoidHead, VoidRef, VoidRepository},
28    widget::{
29        commit_detail::{CommitDetail, CommitDetailState},
30        commit_list::{CommitList, CommitListState},
31    },
32};
33
34/// Error type for application operations.
35#[derive(Debug, Error)]
36pub enum AppError {
37    #[error("io error: {0}")]
38    Io(#[from] io::Error),
39
40    #[error("backend error: {0}")]
41    Backend(#[from] crate::void_backend::VoidBackendError),
42}
43
44/// Result type for application operations.
45pub type Result<T> = std::result::Result<T, AppError>;
46
47/// Current view state of the application.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum AppView {
50    /// Main commit list view
51    List,
52    /// Commit detail view
53    Detail,
54    /// Help overlay
55    Help,
56}
57
58/// Run the void-graph application.
59///
60/// Opens the repository at `path` with a pre-loaded vault, loads commits,
61/// and starts the TUI. The vault is constructed by the CLI caller which
62/// handles identity loading and PIN prompting.
63pub fn run(
64    path: &Utf8Path,
65    max_count: usize,
66    order: SortOrder,
67    vault: std::sync::Arc<void_core::crypto::KeyVault>,
68) -> Result<()> {
69    // Open repository
70    let repo = VoidRepository::open(path, vault)?;
71
72    // Load data
73    let head = repo.head()?;
74    let refs = repo.refs()?;
75
76    // Start walk from ALL branch tips to see divergent branches
77    let mut start_cids: Vec<_> = refs
78        .iter()
79        .filter(|r| r.kind == crate::void_backend::RefKind::Branch)
80        .map(|r| r.target.clone())
81        .collect();
82
83    // Also include HEAD if detached
84    if let Some(head_cid) = repo.resolve_head()? {
85        if !start_cids.iter().any(|c| c == &head_cid) {
86            start_cids.push(head_cid);
87        }
88    }
89
90    if start_cids.is_empty() {
91        return Ok(()); // Empty repo
92    }
93
94    let commits = repo.walk_commits(&start_cids, order, Some(max_count))?;
95
96    if commits.is_empty() {
97        return Ok(());
98    }
99
100    // Setup terminal
101    enable_raw_mode()?;
102    let mut stdout = io::stdout();
103    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
104    let backend = CrosstermBackend::new(stdout);
105    let mut terminal = Terminal::new(backend)?;
106    terminal.clear()?;
107
108    // Run the app and capture result
109    let result = run_app(&mut terminal, commits, refs, head);
110
111    // Cleanup terminal (always, even on error)
112    disable_raw_mode()?;
113    execute!(
114        terminal.backend_mut(),
115        LeaveAlternateScreen,
116        DisableMouseCapture
117    )?;
118    terminal.show_cursor()?;
119
120    result
121}
122
123/// Main application loop.
124fn run_app(
125    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
126    commits: Vec<VoidCommit>,
127    refs: Vec<VoidRef>,
128    head: Option<VoidHead>,
129) -> Result<()> {
130    // Initialize resources
131    let theme = ColorTheme::default();
132    let keybind = KeyBind::new(None);
133    let (_tx, rx) = init_events();
134
135    // Initialize state
136    let mut list_state = CommitListState::new(commits.clone(), refs.clone(), head.clone());
137    let mut detail_state = CommitDetailState::new();
138    let mut app_view = AppView::List;
139
140    // Track viewport height for navigation
141    let mut viewport_height: usize = 20;
142
143    // Main loop
144    loop {
145        // Render
146        terminal.draw(|frame| {
147            let area = frame.area();
148            viewport_height = area.height.saturating_sub(2) as usize; // Account for borders
149
150            match app_view {
151                AppView::List => {
152                    render_list_view(frame, area, &mut list_state, &theme);
153                }
154                AppView::Detail => {
155                    render_detail_view(
156                        frame,
157                        area,
158                        &mut list_state,
159                        &mut detail_state,
160                        &refs,
161                        &theme,
162                    );
163                }
164                AppView::Help => {
165                    // Render list in background, then help overlay
166                    render_list_view(frame, area, &mut list_state, &theme);
167                    render_help_overlay(frame, area, &keybind, &theme);
168                }
169            }
170        })?;
171
172        // Handle events
173        match rx.recv() {
174            AppEvent::Key(key) => {
175                if let Some(user_event) = keybind.get(&key) {
176                    match handle_event(*user_event, &mut app_view, &mut list_state, &mut detail_state, viewport_height) {
177                        EventResult::Continue => {}
178                        EventResult::Quit => break,
179                    }
180                }
181            }
182            AppEvent::Resize(_, _) => {
183                // Terminal will handle resize automatically
184            }
185            AppEvent::Quit => break,
186            _ => {}
187        }
188    }
189
190    Ok(())
191}
192
193/// Result of handling an event.
194enum EventResult {
195    Continue,
196    Quit,
197}
198
199/// Handle a user event and return the result.
200fn handle_event(
201    event: UserEvent,
202    view: &mut AppView,
203    list_state: &mut CommitListState,
204    detail_state: &mut CommitDetailState,
205    viewport_height: usize,
206) -> EventResult {
207    match event {
208        // Quit events
209        UserEvent::Quit | UserEvent::ForceQuit => {
210            return EventResult::Quit;
211        }
212
213        // Help toggle
214        UserEvent::HelpToggle => {
215            *view = match *view {
216                AppView::Help => AppView::List,
217                _ => AppView::Help,
218            };
219        }
220
221        // Cancel/close - return to list from detail or help
222        UserEvent::Cancel | UserEvent::Close => {
223            if *view != AppView::List {
224                *view = AppView::List;
225            }
226        }
227
228        // Confirm - open detail view
229        UserEvent::Confirm => {
230            if *view == AppView::List && list_state.selected_commit().is_some() {
231                detail_state.reset();
232                *view = AppView::Detail;
233            }
234        }
235
236        // Navigation events - always navigate list, detail view updates automatically
237        UserEvent::NavigateDown => {
238            list_state.select_next(viewport_height);
239            if *view == AppView::Detail {
240                detail_state.reset(); // Reset scroll for new commit
241            }
242        }
243        UserEvent::NavigateUp => {
244            list_state.select_prev();
245            if *view == AppView::Detail {
246                detail_state.reset();
247            }
248        }
249        UserEvent::HalfPageDown => {
250            list_state.scroll_down_half(viewport_height);
251            if *view == AppView::Detail {
252                detail_state.reset();
253            }
254        }
255        UserEvent::HalfPageUp => {
256            list_state.scroll_up_half(viewport_height);
257            if *view == AppView::Detail {
258                detail_state.reset();
259            }
260        }
261        UserEvent::PageDown => {
262            list_state.scroll_down_page(viewport_height);
263            if *view == AppView::Detail {
264                detail_state.reset();
265            }
266        }
267        UserEvent::PageUp => {
268            list_state.scroll_up_page(viewport_height);
269            if *view == AppView::Detail {
270                detail_state.reset();
271            }
272        }
273        UserEvent::GoToTop => {
274            list_state.select_first();
275            if *view == AppView::Detail {
276                detail_state.reset();
277            }
278        }
279        UserEvent::GoToBottom => {
280            list_state.select_last(viewport_height);
281            if *view == AppView::Detail {
282                detail_state.reset();
283            }
284        }
285
286        // Scroll detail view content (only in detail view)
287        UserEvent::ScrollDown => {
288            if *view == AppView::Detail {
289                detail_state.scroll_down();
290            }
291        }
292        UserEvent::ScrollUp => {
293            if *view == AppView::Detail {
294                detail_state.scroll_up();
295            }
296        }
297
298        // Unhandled events
299        _ => {}
300    }
301
302    EventResult::Continue
303}
304
305/// Render the commit list view.
306fn render_list_view(
307    frame: &mut Frame,
308    area: Rect,
309    state: &mut CommitListState,
310    theme: &ColorTheme,
311) {
312    let widget = CommitList::new(theme);
313    frame.render_stateful_widget(widget, area, state);
314}
315
316/// Render the commit detail view.
317fn render_detail_view(
318    frame: &mut Frame,
319    area: Rect,
320    list_state: &mut CommitListState,
321    detail_state: &mut CommitDetailState,
322    refs: &[VoidRef],
323    theme: &ColorTheme,
324) {
325    // Layout: split into list (left) and detail (right)
326    let chunks = Layout::default()
327        .direction(Direction::Horizontal)
328        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
329        .split(area);
330
331    // Render list on the left
332    let list_widget = CommitList::new(theme);
333    frame.render_stateful_widget(list_widget, chunks[0], list_state);
334
335    // Render detail on the right
336    if let Some(commit) = list_state.selected_commit() {
337        let commit_refs: Vec<&VoidRef> = refs
338            .iter()
339            .filter(|r| r.target == commit.cid)
340            .collect();
341        let detail_widget = CommitDetail::new(commit, commit_refs, theme);
342        frame.render_stateful_widget(detail_widget, chunks[1], detail_state);
343    }
344}
345
346/// Render the help overlay.
347fn render_help_overlay(
348    frame: &mut Frame,
349    area: Rect,
350    keybind: &KeyBind,
351    theme: &ColorTheme,
352) {
353    // Calculate centered popup area
354    let popup_width = 60.min(area.width.saturating_sub(4));
355    let popup_height = 20.min(area.height.saturating_sub(4));
356    let popup_x = (area.width.saturating_sub(popup_width)) / 2;
357    let popup_y = (area.height.saturating_sub(popup_height)) / 2;
358    let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
359
360    // Clear the popup area
361    frame.render_widget(Clear, popup_area);
362
363    // Build help content
364    let help_items = [
365        (UserEvent::NavigateDown, "Move down"),
366        (UserEvent::NavigateUp, "Move up"),
367        (UserEvent::HalfPageDown, "Half page down"),
368        (UserEvent::HalfPageUp, "Half page up"),
369        (UserEvent::PageDown, "Page down"),
370        (UserEvent::PageUp, "Page up"),
371        (UserEvent::GoToTop, "Go to top"),
372        (UserEvent::GoToBottom, "Go to bottom"),
373        (UserEvent::Confirm, "View details"),
374        (UserEvent::Cancel, "Close/back"),
375        (UserEvent::HelpToggle, "Toggle help"),
376        (UserEvent::Quit, "Quit"),
377    ];
378
379    let mut lines: Vec<Line> = Vec::new();
380    for (event, description) in help_items {
381        let keys = keybind.keys_for_event(event);
382        let key_str = if keys.is_empty() {
383            "(unbound)".to_string()
384        } else {
385            keys.join(", ")
386        };
387
388        lines.push(Line::from(vec![
389            Span::styled(
390                format!("{:>15}", key_str),
391                Style::default()
392                    .fg(theme.help_key_fg)
393                    .add_modifier(Modifier::BOLD),
394            ),
395            Span::raw("  "),
396            Span::raw(description),
397        ]));
398    }
399
400    let help_paragraph = Paragraph::new(lines)
401        .block(
402            Block::default()
403                .borders(Borders::ALL)
404                .title(" Help ")
405                .title_style(Style::default().fg(theme.help_block_title_fg)),
406        )
407        .style(Style::default().fg(theme.fg).bg(theme.bg));
408
409    frame.render_widget(help_paragraph, popup_area);
410}