1use 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#[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
44pub type Result<T> = std::result::Result<T, AppError>;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum AppView {
50 List,
52 Detail,
54 Help,
56}
57
58pub fn run(
64 path: &Utf8Path,
65 max_count: usize,
66 order: SortOrder,
67 vault: std::sync::Arc<void_core::crypto::KeyVault>,
68) -> Result<()> {
69 let repo = VoidRepository::open(path, vault)?;
71
72 let head = repo.head()?;
74 let refs = repo.refs()?;
75
76 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 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(()); }
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 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 let result = run_app(&mut terminal, commits, refs, head);
110
111 disable_raw_mode()?;
113 execute!(
114 terminal.backend_mut(),
115 LeaveAlternateScreen,
116 DisableMouseCapture
117 )?;
118 terminal.show_cursor()?;
119
120 result
121}
122
123fn run_app(
125 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
126 commits: Vec<VoidCommit>,
127 refs: Vec<VoidRef>,
128 head: Option<VoidHead>,
129) -> Result<()> {
130 let theme = ColorTheme::default();
132 let keybind = KeyBind::new(None);
133 let (_tx, rx) = init_events();
134
135 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 let mut viewport_height: usize = 20;
142
143 loop {
145 terminal.draw(|frame| {
147 let area = frame.area();
148 viewport_height = area.height.saturating_sub(2) as usize; 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_view(frame, area, &mut list_state, &theme);
167 render_help_overlay(frame, area, &keybind, &theme);
168 }
169 }
170 })?;
171
172 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 }
185 AppEvent::Quit => break,
186 _ => {}
187 }
188 }
189
190 Ok(())
191}
192
193enum EventResult {
195 Continue,
196 Quit,
197}
198
199fn 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 UserEvent::Quit | UserEvent::ForceQuit => {
210 return EventResult::Quit;
211 }
212
213 UserEvent::HelpToggle => {
215 *view = match *view {
216 AppView::Help => AppView::List,
217 _ => AppView::Help,
218 };
219 }
220
221 UserEvent::Cancel | UserEvent::Close => {
223 if *view != AppView::List {
224 *view = AppView::List;
225 }
226 }
227
228 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 UserEvent::NavigateDown => {
238 list_state.select_next(viewport_height);
239 if *view == AppView::Detail {
240 detail_state.reset(); }
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 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 _ => {}
300 }
301
302 EventResult::Continue
303}
304
305fn 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
316fn 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 let chunks = Layout::default()
327 .direction(Direction::Horizontal)
328 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
329 .split(area);
330
331 let list_widget = CommitList::new(theme);
333 frame.render_stateful_widget(list_widget, chunks[0], list_state);
334
335 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
346fn render_help_overlay(
348 frame: &mut Frame,
349 area: Rect,
350 keybind: &KeyBind,
351 theme: &ColorTheme,
352) {
353 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 frame.render_widget(Clear, popup_area);
362
363 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}