1#![allow(unused_imports)]
8use crate::ui::layout::centered_rect;
15use ratatui::Frame;
16use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{
20 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row,
21 Table, TableState, Wrap,
22};
23
24use crate::app::{DetailSection, Mode};
25use crate::repo::{
26 self, CommitEntry, DiffLine, DiffLineKind, FileEntry, ItemDetail, RemoteInfo, RepoInfo,
27 WorktreeChanges,
28};
29use crate::ui::{
30 ACCENT, CARD_BORDER, DANGER, SUCCESS, WARNING, accent_style, muted_style, primary_style,
31};
32
33const FIELD_INDENT: &str = " ";
34const FIELD_LABEL_WIDTH: usize = 9;
36const FILE_INDENT: &str = " ";
38const FILE_LABEL_WIDTH: usize = 2;
40
41pub fn error_style() -> Style {
42 Style::default().fg(DANGER())
43}
44
45#[derive(Default, Clone, Copy)]
51pub struct DetailAreas {
52 pub commits: Option<Rect>,
54 pub bottom_left: Option<Rect>,
58 pub staged_sub: Option<Rect>,
60 pub unstaged_sub: Option<Rect>,
62 pub conflicts_sub: Option<Rect>,
64 pub bottom_right: Option<Rect>,
66 pub commit_details: Option<Rect>,
68 pub local_branches: Option<Rect>,
70 pub remote_branches: Option<Rect>,
72 pub local_tags: Option<Rect>,
74 pub remote_tags: Option<Rect>,
76 pub tab_bar: Option<Rect>,
78 pub files: Option<Rect>,
80 pub file_content: Option<Rect>,
82 pub remotes: Option<Rect>,
84 pub stashes: Option<Rect>,
86 pub stashed_files: Option<Rect>,
88 pub worktrees: Option<Rect>,
90 pub inspect_horizontal_splitter: Option<Rect>,
92 pub inspect_vertical_splitter: Option<Rect>,
94 pub workspace_main_splitter: Option<Rect>,
96 pub forge_issues: Option<Rect>,
97 pub forge_issue_details: Option<Rect>,
98 pub forge_vertical_splitter: Option<Rect>,
99 pub forge_prs: Option<Rect>,
100 pub forge_pr_details: Option<Rect>,
101 pub forge_pr_vertical_splitter: Option<Rect>,
102 pub files_horizontal_splitter: Option<Rect>,
104 pub branches_horizontal_splitter: Option<Rect>,
106 pub stashes_horizontal_splitter: Option<Rect>,
108 pub stashes_vertical_splitter: Option<Rect>,
110 pub overview_horizontal_splitter: Option<Rect>,
112 pub overview: Option<Rect>,
114 pub stats: Option<Rect>,
116 pub graph: Option<Rect>,
118 pub graph_inner: Option<Rect>,
120 pub commits_inner: Option<Rect>,
122 pub staged_sub_inner: Option<Rect>,
124 pub unstaged_sub_inner: Option<Rect>,
126 pub conflicts_sub_inner: Option<Rect>,
128 pub changed_files_inner: Option<Rect>,
130 pub local_branches_inner: Option<Rect>,
132 pub remote_branches_inner: Option<Rect>,
134 pub local_tags_inner: Option<Rect>,
136 pub remotes_inner: Option<Rect>,
138 pub stashes_inner: Option<Rect>,
140 pub stashed_files_inner: Option<Rect>,
142 pub files_inner: Option<Rect>,
144 pub worktrees_inner: Option<Rect>,
146 pub submodules: Option<Rect>,
148 pub submodules_inner: Option<Rect>,
150 pub reflog: Option<Rect>,
152 pub reflog_inner: Option<Rect>,
154 pub worktrees_offset: usize,
158 pub submodules_offset: usize,
159 pub reflog_offset: usize,
160 pub forge_issues_offset: usize,
161 pub forge_prs_offset: usize,
162 pub commit_popup: Option<Rect>,
164 pub commit_popup_parent: Option<Rect>,
166}
167
168#[allow(clippy::too_many_arguments)]
170pub fn draw(
171 f: &mut Frame,
172 item_name: &str,
173 detail: &ItemDetail,
174 mode: &Mode,
175 focus: &DetailSection,
176 last_staging_focus: DetailSection,
177 commit_selection: usize,
178 commit_search_query: &Option<String>,
179 file_selection: usize,
180 file_diff: &[DiffLine],
181 diff_scroll: usize,
182 staging_file_selection: usize,
183 commit_details_scroll: usize,
184 local_branch_selection: usize,
185 remote_branch_selection: usize,
186 local_tag_selection: usize,
187 remote_selection: usize,
188 remote_picker_selection: usize,
189 stash_selection: usize,
190 stash_file_selection: usize,
191 file_list_selection: usize,
192 file_content_scroll: usize,
193 visible_files: &[crate::app::FileTreeItem],
194 detail_tab: usize,
195 graph_scroll: usize,
196 graph_selection: usize,
197 help_scroll: usize,
198 areas: &mut DetailAreas,
199 input_buffer: &str,
200 commit_editing: bool,
201 branch_action_target: &Option<(String, bool)>,
202 tag_action_target_oid: &Option<String>,
203 tag_delete_target: &Option<(String, bool)>,
204 tag_push_target: &Option<String>,
205 discard_target: &Option<(String, bool)>,
206 stash_apply_delete_after: bool,
207 commit_amend: bool,
208 commit_input_scroll: usize,
209 inspect_horizontal_split_pct: u16,
210 inspect_vertical_split_pct: u16,
211 workspace_main_split_pct: u16,
212 files_horizontal_split_pct: u16,
213 branches_horizontal_split_pct: u16,
214 stashes_horizontal_split_pct: u16,
215 stashes_vertical_split_pct: u16,
216 overview_horizontal_split_pct: u16,
217 app: &crate::app::App,
218 area: Rect,
219) {
220 if app.in_logs_ui
221 && matches!(mode, Mode::Logs | Mode::LogsSearchInput | Mode::SearchColumnPicker)
222 {
223 if let ItemDetail::Repo { info, .. } = detail {
224 let header_rows = Layout::default()
225 .direction(Direction::Vertical)
226 .constraints([
227 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
231 .split(area);
232
233 let header_chunks = Layout::default()
235 .direction(Direction::Horizontal)
236 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
237 .split(header_rows[0]);
238
239 let branch = info.branch.as_ref();
240 let header_left = Paragraph::new(Line::from(vec![
241 Span::styled("▍ ", Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)),
242 Span::styled(
243 format!("Logs - {}", item_name),
244 primary_style().add_modifier(Modifier::BOLD),
245 ),
246 ]));
247 f.render_widget(header_left, header_chunks[0]);
248
249 if let Some(branch_name) = branch {
250 let header_right = Paragraph::new(Line::from(vec![
251 Span::styled(" ", muted_style()),
252 Span::styled(branch_name.as_str(), accent_style()),
253 Span::raw(" "),
254 ]))
255 .alignment(Alignment::Right);
256 f.render_widget(header_right, header_chunks[1]);
257 }
258
259 {
261 let w = area.width as usize;
262 let outer = (w / 10).max(2);
263 let inner = (w / 8).max(3);
264 let centre = w.saturating_sub(outer * 2 + inner * 2);
265 let divider_line = Line::from(vec![
266 Span::styled(" ".repeat(outer), muted_style()),
267 Span::styled("┄".repeat(inner), muted_style().add_modifier(Modifier::DIM)),
268 Span::styled("┈".repeat(centre), muted_style()),
269 Span::styled("┄".repeat(inner), muted_style().add_modifier(Modifier::DIM)),
270 Span::styled(" ".repeat(outer), muted_style()),
271 ]);
272 f.render_widget(Paragraph::new(divider_line), header_rows[1]);
273 }
274
275 areas.commits = Some(header_rows[2]);
276 let inner_rect = crate::components::commit_list::draw_logs_view(
277 f,
278 info,
279 commit_selection,
280 commit_search_query,
281 app,
282 header_rows[2],
283 );
284 areas.commits_inner = Some(inner_rect);
285
286 if matches!(mode, Mode::SearchColumnPicker) {
288 crate::popups::search_columns::draw_search_column_picker(f, app, area);
289 }
290 }
291 return;
292 }
293
294 if mode == &Mode::Inspect {
295 if let ItemDetail::Repo { info, .. } = detail {
296 let dirty = !info.changes.staged.is_empty()
297 || !info.changes.unstaged.is_empty()
298 || !info.changes.untracked.is_empty()
299 || !info.changes.conflicted.is_empty();
300 let is_uncommitted = dirty && commit_selection == 0 && !app.in_logs_ui;
301
302 if is_uncommitted {
303 crate::components::status_list::draw_staging_panels(
304 f,
305 &info.changes,
306 *focus,
307 last_staging_focus,
308 staging_file_selection,
309 file_diff,
310 diff_scroll,
311 areas,
312 inspect_horizontal_split_pct,
313 inspect_vertical_split_pct,
314 app,
315 area,
316 );
317 return;
318 } else {
319 if let Some(commit) = app.get_selected_commit() {
320 crate::components::diff::draw_inspect_window(
321 f,
322 commit,
323 *focus,
324 file_selection,
325 file_diff,
326 diff_scroll,
327 commit_details_scroll,
328 areas,
329 inspect_horizontal_split_pct,
330 inspect_vertical_split_pct,
331 app,
332 area,
333 );
334 return;
335 }
336 }
337 }
338 }
339
340 let branch: Option<String> = match detail {
342 ItemDetail::Repo { info, .. } => info.branch.clone(),
343 _ => None,
344 };
345
346 let is_repo = matches!(detail, ItemDetail::Repo { .. });
347
348 let (header_area, tab_bar_area, body_area) = if is_repo {
349 let chunks = Layout::default()
350 .direction(Direction::Vertical)
351 .constraints([
352 Constraint::Length(2), Constraint::Length(2), Constraint::Min(0), ])
356 .split(area);
357 (chunks[0], Some(chunks[1]), chunks[2])
358 } else {
359 let chunks = Layout::default()
360 .direction(Direction::Vertical)
361 .constraints([
362 Constraint::Length(2), Constraint::Min(0), ])
365 .split(area);
366 (chunks[0], None, chunks[1])
367 };
368
369 let header_rows = Layout::default()
370 .direction(Direction::Vertical)
371 .constraints([Constraint::Length(1), Constraint::Length(1)])
372 .split(header_area);
373
374 let header_chunks = Layout::default()
376 .direction(Direction::Horizontal)
377 .constraints([Constraint::Min(0), Constraint::Length(40)])
378 .split(header_rows[0]);
379
380 let header_left = Paragraph::new(Line::from(vec![
381 Span::raw(FIELD_INDENT),
382 Span::styled("⎇ ", muted_style().add_modifier(Modifier::BOLD)),
383 Span::styled(item_name.to_string(), accent_style()),
384 ]));
385 f.render_widget(header_left, header_chunks[0]);
386
387 if let Some(ref branch_name) = branch {
388 let header_right = Paragraph::new(Line::from(vec![
389 Span::styled(" ", muted_style()),
390 Span::styled(branch_name, accent_style()),
391 Span::raw(" "),
392 ]))
393 .alignment(Alignment::Right);
394 f.render_widget(header_right, header_chunks[1]);
395 }
396
397 {
398 let w = header_area.width as usize;
399 let outer = (w / 10).max(2);
401 let inner = (w / 8).max(3);
402 let centre = w.saturating_sub(outer * 2 + inner * 2);
403
404 let fade_outer = muted_style();
405 let fade_inner = muted_style().add_modifier(Modifier::DIM);
406 let solid = muted_style();
407
408 let divider_line = Line::from(vec![
409 Span::styled(" ".repeat(outer), fade_outer),
410 Span::styled("┄".repeat(inner), fade_inner),
411 Span::styled("┈".repeat(centre), solid),
412 Span::styled("┄".repeat(inner), fade_inner),
413 Span::styled(" ".repeat(outer), fade_outer),
414 ]);
415 f.render_widget(Paragraph::new(divider_line), header_rows[1]);
416 }
417
418 if let Some(tab_area) = tab_bar_area {
419 let all_tabs_data = [
420 ("Workspace", "WS", "W", "1", 0),
421 ("Files", "Fi", "F", "2", 1),
422 ("Graph", "Gr", "G", "3", 2),
423 ("Branches", "Br", "B", "4", 3),
424 ("Tags", "Tg", "T", "5", 4),
425 ("Remotes", "Rm", "R", "6", 5),
426 ("Stashes", "St", "S", "7", 6),
427 ("Worktrees", "WT", "W", "1", 7),
428 ("Submodules", "SM", "S", "2", 8),
429 ("Reflog", "Rf", "R", "3", 9),
430 ("Forge", "Fo", "F", "4", 10),
431 ("PRs", "PR", "P", "5", 11),
432 ];
433
434 let tabs_data: Vec<_> = all_tabs_data
435 .iter()
436 .filter(|&&(_, _, _, _, tab_idx)| {
437 if app.advanced_tabs {
438 (7..=11).contains(&tab_idx)
439 } else {
440 (0..=6).contains(&tab_idx)
441 }
442 })
443 .copied()
444 .collect();
445
446 let width_long: usize = 11 + tabs_data.iter().map(|t| t.0.len() + 8).sum::<usize>();
447 let width_medium: usize = 11 + tabs_data.iter().map(|t| t.1.len() + 8).sum::<usize>();
448
449 let name_format = if tab_area.width as usize >= width_long {
450 0
451 } else if tab_area.width as usize >= width_medium {
452 1
453 } else {
454 2
455 };
456
457 let show_selected_long = if name_format > 0 {
458 let expanded_width = 11
459 + tabs_data
460 .iter()
461 .map(|&(long_name, medium_name, short_name, _, tab_idx)| {
462 let name_len = if tab_idx == detail_tab {
463 long_name.len()
464 } else if name_format == 1 {
465 medium_name.len()
466 } else {
467 short_name.len()
468 };
469 name_len + 8
470 })
471 .sum::<usize>();
472 tab_area.width as usize >= expanded_width
473 } else {
474 false
475 };
476
477 let mut spans = vec![Span::raw(" ")];
478 for (i, &(long_name, medium_name, short_name, display_num, tab_idx)) in
479 tabs_data.iter().enumerate()
480 {
481 if i > 0 {
482 spans.push(Span::raw(" "));
483 }
484 let name = if tab_idx == detail_tab && (name_format == 0 || show_selected_long) {
485 long_name
486 } else {
487 match name_format {
488 0 => long_name,
489 1 => medium_name,
490 _ => short_name,
491 }
492 };
493 let bullet = if detail_tab == tab_idx { "┃" } else { "│" };
494 let style = if detail_tab == tab_idx {
495 Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD)
496 } else {
497 Style::default().add_modifier(Modifier::DIM | Modifier::UNDERLINED)
498 };
499 spans.push(Span::styled(
500 format!("{} {} [{}] {}", bullet, name, display_num, bullet),
501 style,
502 ));
503 }
504
505 if app.advanced_tabs {
506 spans.push(Span::raw(" "));
507 spans.push(Span::styled("← Primary [Z]", Style::default().fg(ACCENT())));
508 } else {
509 spans.push(Span::raw(" "));
510 spans.push(Span::styled("⟨Adv [Z]⟩", muted_style()));
511 }
512
513 let tab_line = Line::from(spans);
514 f.render_widget(Paragraph::new(tab_line), tab_area);
515 areas.tab_bar = Some(tab_area);
516 }
517
518 match detail {
519 ItemDetail::Repo { resolved, info } => {
520 if detail_tab == 0 {
521 let detail_chunks = Layout::default()
523 .direction(Direction::Vertical)
524 .constraints([
525 Constraint::Percentage(workspace_main_split_pct),
526 Constraint::Percentage(100 - workspace_main_split_pct),
527 ])
528 .split(body_area);
529
530 let split_row = body_area.y + detail_chunks[0].height;
532 areas.workspace_main_splitter =
533 Some(Rect::new(body_area.x, split_row.saturating_sub(1), body_area.width, 2));
534
535 let dirty = !info.changes.staged.is_empty()
536 || !info.changes.unstaged.is_empty()
537 || !info.changes.untracked.is_empty()
538 || !info.changes.conflicted.is_empty();
539 let show_dirty = if dirty {
540 if let Some(query) = commit_search_query {
541 "<uncommitted>".contains(&query.to_lowercase())
542 } else {
543 true
544 }
545 } else {
546 false
547 };
548 let is_uncommitted_row = show_dirty && commit_selection == 0;
549
550 crate::components::commit_list::draw_detail_commits(
551 f,
552 info,
553 *focus,
554 commit_selection,
555 commit_search_query,
556 detail_chunks[0],
557 &app.commit_list.table_state,
558 areas,
559 app.commit_list.limit,
560 );
561 areas.commits = Some(detail_chunks[0]);
562
563 if is_uncommitted_row {
564 crate::components::status_list::draw_staging_panels(
566 f,
567 &info.changes,
568 *focus,
569 last_staging_focus,
570 staging_file_selection,
571 file_diff,
572 diff_scroll,
573 areas,
574 inspect_horizontal_split_pct,
575 inspect_vertical_split_pct,
576 app,
577 detail_chunks[1],
578 );
579 } else {
580 match app.get_selected_commit() {
582 Some(commit) => {
583 crate::components::file_tree::draw_commit_files_panel(
584 f,
585 commit,
586 *focus,
587 file_selection,
588 file_diff,
589 diff_scroll,
590 commit_details_scroll,
591 areas,
592 inspect_horizontal_split_pct,
593 inspect_vertical_split_pct,
594 app,
595 detail_chunks[1],
596 );
597 }
598 None => {
599 crate::components::status_list::draw_staging_panels(
601 f,
602 &info.changes,
603 *focus,
604 last_staging_focus,
605 staging_file_selection,
606 file_diff,
607 diff_scroll,
608 areas,
609 inspect_horizontal_split_pct,
610 inspect_vertical_split_pct,
611 app,
612 detail_chunks[1],
613 );
614 }
615 }
616 }
617 } else if detail_tab == 1 {
618 crate::components::file_tree::draw_files_view(
620 f,
621 resolved,
622 info,
623 visible_files,
624 *focus,
625 file_list_selection,
626 file_content_scroll,
627 areas,
628 files_horizontal_split_pct,
629 app,
630 body_area,
631 );
632 } else if detail_tab == 2 {
633 match &info.graph_lines {
635 repo::TabData::NotLoaded | repo::TabData::Loading => {
636 let block = Block::default()
637 .borders(Borders::ALL)
638 .border_type(CARD_BORDER())
639 .border_style(muted_style())
640 .title(Line::from(vec![
641 Span::raw(" "),
642 Span::styled("Branch History Graph", primary_style()),
643 Span::raw(" "),
644 ]))
645 .padding(Padding::uniform(1));
646 let inner = block.inner(body_area);
647 f.render_widget(block, body_area);
648
649 let loading_text = Paragraph::new("⟳ Loading graph...")
650 .style(muted_style())
651 .alignment(ratatui::layout::Alignment::Center);
652 let area = Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1);
653 f.render_widget(loading_text, area);
654 }
655 repo::TabData::Error(err) => {
656 let block = Block::default()
657 .borders(Borders::ALL)
658 .border_type(CARD_BORDER())
659 .border_style(error_style())
660 .title(Line::from(vec![
661 Span::raw(" "),
662 Span::styled("Branch History Graph - Error", error_style()),
663 Span::raw(" "),
664 ]))
665 .padding(Padding::uniform(1));
666 let inner = block.inner(body_area);
667 f.render_widget(block, body_area);
668
669 let error_text = Paragraph::new(format!("Error loading graph: {}", err))
670 .style(error_style())
671 .wrap(Wrap { trim: false });
672 f.render_widget(error_text, inner);
673 }
674 repo::TabData::Loaded(_) => {
675 let block = Block::default()
676 .borders(Borders::ALL)
677 .border_type(CARD_BORDER())
678 .border_style(muted_style())
679 .title(Line::from(vec![
680 Span::raw(" "),
681 Span::styled("Branch History Graph", primary_style()),
682 Span::raw(" "),
683 Span::styled(
684 format!("({})", info.graph_lines.len()),
685 muted_style(),
686 ),
687 Span::raw(" "),
688 ]))
689 .padding(Padding::uniform(1));
690
691 let inner = block.inner(body_area);
692 areas.graph = Some(body_area);
693 areas.graph_inner = Some(inner);
694 f.render_widget(block, body_area);
695
696 let visible_height = inner.height as usize;
697 app.graph_visible_height.set(visible_height);
698 let upper = (graph_scroll + visible_height).min(info.graph_lines.len());
699 let visible_lines = &info.graph_lines.as_slice()[graph_scroll..upper];
700
701 let mut list_items = Vec::new();
702 for (idx, g_line) in visible_lines.iter().enumerate() {
703 let global_idx = graph_scroll + idx;
704 let line = graph_line_spans(g_line);
705 let item = if global_idx == graph_selection {
706 ListItem::new(line)
707 .style(Style::default().add_modifier(Modifier::REVERSED))
708 } else {
709 ListItem::new(line)
710 };
711 list_items.push(item);
712 }
713
714 let list = List::new(list_items);
715 f.render_widget(list, inner);
716 }
717 }
718 } else if detail_tab == 3 {
719 crate::components::branch_list::draw_branches_view(
721 f,
722 info,
723 *focus,
724 local_branch_selection,
725 remote_branch_selection,
726 areas,
727 branches_horizontal_split_pct,
728 app,
729 body_area,
730 );
731 } else if detail_tab == 4 {
732 crate::components::tag_list::draw_tags_view(
734 f,
735 info,
736 *focus,
737 local_tag_selection,
738 info.remote_tags_loaded,
739 areas,
740 app,
741 body_area,
742 );
743 } else if detail_tab == 5 {
744 crate::components::branch_list::draw_remotes_view(
746 f,
747 info,
748 *focus,
749 remote_selection,
750 areas,
751 app,
752 body_area,
753 );
754 } else if detail_tab == 6 {
755 crate::components::stash_list::draw_stashes_view(
757 f,
758 info,
759 *focus,
760 stash_selection,
761 stash_file_selection,
762 file_diff,
763 diff_scroll,
764 areas,
765 stashes_horizontal_split_pct,
766 stashes_vertical_split_pct,
767 app,
768 body_area,
769 );
770 } else if detail_tab == 7 {
771 crate::components::worktree_list::draw_worktrees_view(
773 f,
774 info,
775 *focus,
776 app.worktree_selection,
777 areas,
778 app,
779 body_area,
780 );
781 } else if detail_tab == 8 {
782 crate::components::submodule_list::draw_submodules_view(
784 f,
785 info,
786 *focus,
787 app.submodule_selection,
788 areas,
789 app,
790 body_area,
791 );
792 } else if detail_tab == 9 {
793 crate::components::reflog_list::draw_reflog_view(
795 f,
796 info,
797 *focus,
798 app.reflog_selection,
799 areas,
800 app,
801 body_area,
802 );
803 } else if detail_tab == 10 {
804 crate::components::forge_list::draw_forge_view(
806 f,
807 info,
808 *focus,
809 app.forge_issue_selection,
810 areas,
811 app,
812 body_area,
813 );
814 } else {
815 crate::components::forge_pr_list::draw_forge_prs_view(
817 f,
818 info,
819 *focus,
820 app.forge_pr_selection,
821 areas,
822 app,
823 body_area,
824 );
825 }
826 if matches!(mode, Mode::DetailHelp) {
828 crate::popups::detail_help::draw_detail_help_overlay(
829 f,
830 app,
831 body_area,
832 help_scroll,
833 );
834 }
835 if matches!(mode, Mode::Overview) {
837 draw_overview_tab(
838 f,
839 resolved,
840 info,
841 overview_horizontal_split_pct,
842 areas,
843 app,
844 body_area,
845 );
846 }
847 if matches!(mode, Mode::CommitInput) {
849 crate::popups::commit::draw_commit_popup(
850 f,
851 &app.commit_popup.input_buffer,
852 commit_editing,
853 commit_amend,
854 commit_input_scroll,
855 body_area,
856 app,
857 areas,
858 );
859 }
860 if matches!(mode, Mode::SearchColumnPicker) {
862 crate::popups::search_columns::draw_search_column_picker(f, app, body_area);
863 }
864 if matches!(mode, Mode::BranchCreateInput) {
866 crate::popups::create_branch::draw_branch_create_popup(
867 f,
868 input_buffer,
869 branch.as_deref(),
870 body_area,
871 );
872 }
873 if matches!(
875 mode,
876 Mode::ForgeCommentPathInput
877 | Mode::ForgeCommentLineInput
878 | Mode::ForgeCommentBodyInput
879 ) {
880 crate::popups::forge_comment::draw_forge_comment_popup(
881 f,
882 app,
883 input_buffer,
884 body_area,
885 );
886 }
887 if matches!(mode, Mode::RemoteAddNameInput) {
889 crate::popups::add_remote::draw_remote_add_name_popup(f, input_buffer, body_area);
890 }
891 if matches!(mode, Mode::RemoteAddUrlInput) {
893 crate::popups::add_remote::draw_remote_add_url_popup(
894 f,
895 &app.remote_add_name,
896 input_buffer,
897 body_area,
898 );
899 }
900 if matches!(mode, Mode::RemoteDeleteConfirm) {
902 crate::popups::confirm::draw_remote_delete_popup(
903 f,
904 app.remote_action_target.as_deref().unwrap_or(""),
905 body_area,
906 );
907 }
908 if matches!(mode, Mode::TagCreateInput) {
910 crate::popups::create_tag::draw_tag_create_popup(
911 f,
912 input_buffer,
913 &app.tag_create_message,
914 app.tag_create_focus_message,
915 tag_action_target_oid.as_deref(),
916 body_area,
917 );
918 }
919 if matches!(mode, Mode::StashCreateInput) {
921 crate::popups::stash_msg::draw_stash_create_popup(f, input_buffer, body_area, app);
922 }
923 if matches!(mode, Mode::StashingUI) {
925 crate::popups::stashing_ui::draw_stashing_ui(f, info, app, body_area);
926 }
927 if matches!(mode, Mode::BranchDeleteConfirm) {
929 crate::popups::confirm::draw_branch_delete_popup(
930 f,
931 branch_action_target,
932 body_area,
933 );
934 }
935 if matches!(mode, Mode::WorktreeAddBranchInput) {
937 crate::popups::worktree::draw_worktree_add_branch_popup(f, input_buffer, body_area);
938 }
939 if matches!(mode, Mode::WorktreeAddPathInput) {
941 crate::popups::worktree::draw_worktree_add_path_popup(
942 f,
943 input_buffer,
944 &app.worktree_add_branch,
945 body_area,
946 );
947 }
948 if matches!(mode, Mode::WorktreeLockReasonInput) {
950 let wt_name = if let repo::TabData::Loaded(wts) = &info.worktrees {
951 wts.get(app.worktree_selection).map(|w| w.name.as_str()).unwrap_or("")
952 } else {
953 ""
954 };
955 crate::popups::worktree::draw_worktree_lock_reason_popup(
956 f,
957 input_buffer,
958 wt_name,
959 body_area,
960 );
961 }
962 if matches!(mode, Mode::SubmoduleAddUrlInput) {
964 crate::popups::submodule::draw_submodule_add_url_popup(f, input_buffer, body_area);
965 }
966 if matches!(mode, Mode::SubmoduleAddPathInput) {
968 crate::popups::submodule::draw_submodule_add_path_popup(
969 f,
970 input_buffer,
971 &app.submodule_add_url,
972 body_area,
973 );
974 }
975 if matches!(mode, Mode::SubmoduleDeleteConfirm) {
977 if let Some(ref sub) = app.submodule_delete_target {
978 crate::popups::submodule::draw_submodule_delete_popup(f, sub, body_area);
979 }
980 }
981 if matches!(mode, Mode::SubmoduleUpdateConfirm) {
982 if let Some(ref target) = app.submodule_update_target {
983 crate::popups::submodule::draw_submodule_update_popup(f, target, body_area);
984 }
985 }
986 if matches!(mode, Mode::BranchPushConfirm) {
988 crate::popups::confirm::draw_branch_push_popup(f, branch_action_target, body_area);
989 }
990 if matches!(mode, Mode::BranchMergeConfirm) {
992 crate::popups::confirm::draw_branch_merge_popup(
993 f,
994 branch_action_target,
995 branch.as_deref(),
996 body_area,
997 );
998 }
999 if matches!(mode, Mode::BranchMergeIntoConfirm) {
1001 crate::popups::confirm::draw_branch_merge_into_popup(
1002 f,
1003 branch_action_target,
1004 branch.as_deref(),
1005 body_area,
1006 );
1007 }
1008 if matches!(mode, Mode::MergeAbortConfirm) {
1010 crate::popups::confirm::draw_merge_abort_confirm_popup(f, body_area);
1011 }
1012 if matches!(mode, Mode::MergeContinueConfirm) {
1014 crate::popups::confirm::draw_merge_continue_confirm_popup(f, body_area);
1015 }
1016 if matches!(mode, Mode::BranchRebaseConfirm) {
1018 crate::popups::confirm::draw_branch_rebase_popup(
1019 f,
1020 branch_action_target,
1021 branch.as_deref(),
1022 body_area,
1023 );
1024 }
1025 if matches!(mode, Mode::BranchInteractiveRebaseConfirm) {
1027 crate::popups::confirm::draw_branch_interactive_rebase_popup(
1028 f,
1029 branch_action_target,
1030 branch.as_deref(),
1031 body_area,
1032 );
1033 }
1034 if matches!(mode, Mode::TagDeleteConfirm) {
1036 crate::popups::confirm::draw_tag_delete_popup(f, tag_delete_target, body_area);
1037 }
1038 if matches!(mode, Mode::TagOverwriteConfirm) {
1039 crate::popups::confirm::draw_tag_overwrite_popup(
1040 f,
1041 &app.tag_overwrite_target,
1042 body_area,
1043 );
1044 }
1045 if matches!(mode, Mode::TagPushConfirm) {
1047 crate::popups::confirm::draw_tag_push_popup(f, tag_push_target, body_area);
1048 }
1049 if matches!(mode, Mode::TagPushAllConfirm) {
1051 crate::popups::confirm::draw_tag_push_all_popup(
1052 f,
1053 app.remote_action_target.as_deref(),
1054 body_area,
1055 );
1056 }
1057 if matches!(mode, Mode::CherryPickConfirm) {
1059 crate::popups::confirm::draw_cherry_pick_popup(
1060 f,
1061 &app.cherry_pick_target,
1062 branch.as_deref(),
1063 app,
1064 body_area,
1065 );
1066 }
1067 if matches!(mode, Mode::RevertConfirm) {
1069 crate::popups::confirm::draw_revert_popup(
1070 f,
1071 &app.revert_target,
1072 branch.as_deref(),
1073 body_area,
1074 );
1075 }
1076 if matches!(mode, Mode::StashDeleteConfirm) {
1078 let stash_name = match detail {
1079 ItemDetail::Repo { info, .. } => info
1080 .stashes
1081 .get(stash_selection)
1082 .map(|s| format!("stash@{{{}}}: {}", s.index, s.message)),
1083 _ => None,
1084 };
1085 crate::popups::confirm::draw_stash_delete_popup(f, &stash_name, body_area);
1086 }
1087 if matches!(mode, Mode::StashApplyConfirm) {
1089 let stash_name = match detail {
1090 ItemDetail::Repo { info, .. } => info
1091 .stashes
1092 .get(stash_selection)
1093 .map(|s| format!("stash@{{{}}}: {}", s.index, s.message)),
1094 _ => None,
1095 };
1096 crate::popups::confirm::draw_stash_apply_popup(
1097 f,
1098 &stash_name,
1099 stash_apply_delete_after,
1100 body_area,
1101 );
1102 }
1103 if matches!(mode, Mode::RemotePicker) {
1105 if let ItemDetail::Repo { info, .. } = detail {
1106 crate::popups::remote_picker::draw_remote_picker_popup(
1107 f,
1108 info.remotes.as_slice(),
1109 remote_picker_selection,
1110 body_area,
1111 );
1112 }
1113 }
1114 if matches!(mode, Mode::CommitHistoryPicker) {
1116 crate::popups::commit_history::draw_commit_history_popup(
1117 f,
1118 &app.commit_history_items,
1119 app.commit_history_selection,
1120 body_area,
1121 );
1122 }
1123 if matches!(mode, Mode::DiscardChangesConfirm) {
1125 crate::popups::confirm::draw_discard_changes_popup(f, discard_target, body_area);
1126 }
1127 if matches!(mode, Mode::BranchCheckoutConfirm) {
1129 crate::popups::confirm::draw_branch_checkout_popup(
1130 f,
1131 branch_action_target,
1132 body_area,
1133 );
1134 }
1135 if matches!(mode, Mode::TagCheckoutConfirm) {
1137 crate::popups::confirm::draw_tag_checkout_popup(
1138 f,
1139 &app.tag_checkout_target,
1140 body_area,
1141 );
1142 }
1143 if matches!(mode, Mode::CommitCheckoutConfirm) {
1145 crate::popups::confirm::draw_commit_checkout_popup(
1146 f,
1147 &app.commit_action_target_oid,
1148 body_area,
1149 );
1150 }
1151 if matches!(mode, Mode::ForgeCheckoutConfirm) {
1152 if let Some(target) = &app.forge_checkout_target {
1153 crate::popups::confirm::draw_forge_checkout_popup(f, target, body_area);
1154 }
1155 }
1156 }
1157 _ => {
1158 let body_lines = build_body(app, detail);
1159 let body = Paragraph::new(body_lines)
1160 .block(Block::default().padding(Padding::ZERO))
1161 .wrap(Wrap { trim: false });
1162 f.render_widget(body, body_area);
1163 }
1164 }
1165}
1166
1167fn draw_overview_tab(
1184 f: &mut Frame,
1185 resolved: &std::path::Path,
1186 info: &RepoInfo,
1187 overview_horizontal_split_pct: u16,
1188 areas: &mut DetailAreas,
1189 app: &crate::app::App,
1190 area: Rect,
1191) {
1192 f.render_widget(Clear, area);
1193
1194 let chunks = Layout::default()
1195 .direction(Direction::Horizontal)
1196 .constraints([
1197 Constraint::Percentage(overview_horizontal_split_pct),
1198 Constraint::Percentage(100 - overview_horizontal_split_pct),
1199 ])
1200 .split(area);
1201
1202 let split_col = area.x + chunks[0].width;
1204 areas.overview_horizontal_splitter =
1205 Some(Rect::new(split_col.saturating_sub(1), area.y, 2, area.height));
1206 areas.overview = Some(chunks[0]);
1207 areas.stats = Some(chunks[1]);
1208
1209 let left_focused = app.overview_focus == crate::app::OverviewFocus::Overview;
1210 let left_border_style =
1211 if left_focused { Style::default().fg(ACCENT()) } else { muted_style() };
1212
1213 let left_block = Block::default()
1214 .borders(Borders::ALL)
1215 .border_type(CARD_BORDER())
1216 .border_style(left_border_style)
1217 .title(Line::from(vec![
1218 Span::raw(" "),
1219 Span::styled("Overview", primary_style()),
1220 Span::raw(" "),
1221 ]))
1222 .padding(Padding::horizontal(1));
1223
1224 let left_inner_width = left_block.inner(chunks[0]).width as usize;
1225 let body_lines = build_repo_body(resolved, info, left_inner_width);
1226 let left_inner_height = left_block.inner(chunks[0]).height as usize;
1227 let max_overview_scroll = body_lines.len().saturating_sub(left_inner_height);
1228 let left_scroll = app.overview_scroll.min(max_overview_scroll);
1229
1230 let body = Paragraph::new(body_lines).block(left_block).scroll((left_scroll as u16, 0));
1231 f.render_widget(body, chunks[0]);
1232
1233 let right_title =
1234 if info.committer_stats_limit_reached { "Stats (last 10k commits)" } else { "Stats" };
1235
1236 let right_focused = app.overview_focus == crate::app::OverviewFocus::Stats;
1237 let right_border_style =
1238 if right_focused { Style::default().fg(ACCENT()) } else { muted_style() };
1239
1240 let right_block = Block::default()
1241 .borders(Borders::ALL)
1242 .border_type(CARD_BORDER())
1243 .border_style(right_border_style)
1244 .title(Line::from(vec![
1245 Span::raw(" "),
1246 Span::styled(right_title, primary_style()),
1247 Span::raw(" "),
1248 ]))
1249 .padding(Padding::horizontal(1));
1250
1251 match &info.committer_stats {
1252 repo::TabData::NotLoaded | repo::TabData::Loading => {
1253 let inner = right_block.inner(chunks[1]);
1254 f.render_widget(right_block, chunks[1]);
1255 let loading_text = Paragraph::new("⟳ Loading stats...")
1256 .style(muted_style())
1257 .alignment(ratatui::layout::Alignment::Center);
1258 let center_area = Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1);
1259 f.render_widget(loading_text, center_area);
1260 }
1261 repo::TabData::Error(err) => {
1262 let right_block = right_block.border_style(error_style());
1263 let inner = right_block.inner(chunks[1]);
1264 f.render_widget(right_block, chunks[1]);
1265 let error_text = Paragraph::new(format!("Error loading stats: {}", err))
1266 .style(error_style())
1267 .wrap(Wrap { trim: false });
1268 f.render_widget(error_text, inner);
1269 }
1270 repo::TabData::Loaded(_) => {
1271 let stats_lines = build_committer_stats_lines(info);
1272 let right_inner_height = right_block.inner(chunks[1]).height as usize;
1273 let max_stats_scroll = stats_lines.len().saturating_sub(right_inner_height);
1274 let right_scroll = app.stats_scroll.min(max_stats_scroll);
1275
1276 let stats_body = Paragraph::new(stats_lines)
1277 .block(right_block)
1278 .wrap(Wrap { trim: false })
1279 .scroll((right_scroll as u16, 0));
1280 f.render_widget(stats_body, chunks[1]);
1281 }
1282 }
1283}
1284
1285fn build_committer_stats_lines(info: &RepoInfo) -> Vec<Line<'static>> {
1286 let mut lines: Vec<Line<'static>> = vec![];
1287
1288 push_section_header(&mut lines, "Committer Statistics");
1289
1290 if info.committer_stats.is_empty() {
1291 lines.push(Line::from(vec![
1292 Span::raw(FIELD_INDENT),
1293 Span::styled("(no commits / unborn branch)", muted_style()),
1294 ]));
1295 } else {
1296 for stat in info.committer_stats.iter() {
1297 let mut stat_spans = vec![
1298 Span::raw(FIELD_INDENT),
1299 Span::styled("● ", Style::default().fg(ACCENT())),
1300 Span::styled(stat.name.clone(), primary_style()),
1301 ];
1302
1303 if stat.email != "?" && !stat.email.is_empty() {
1304 stat_spans.push(Span::styled(format!(" <{}>", stat.email), muted_style()));
1305 }
1306
1307 stat_spans.push(Span::styled(" ➔ ", muted_style()));
1308 stat_spans.push(Span::styled(
1309 format!("{} commit{}", stat.count, if stat.count == 1 { "" } else { "s" }),
1310 Style::default().fg(SUCCESS()),
1311 ));
1312
1313 lines.push(Line::from(stat_spans));
1314 }
1315 }
1316
1317 lines
1318}
1319
1320fn build_body(app: &crate::app::App, detail: &ItemDetail) -> Vec<Line<'static>> {
1329 match detail {
1330 ItemDetail::Missing { resolved } => {
1331 let mut lines = vec![];
1332 push_section_header(&mut lines, "Overview");
1333 lines.push(kind_line(
1334 app.sym("close"),
1335 DANGER(),
1336 "Not a directory",
1337 "(path does not exist or isn't accessible)",
1338 ));
1339 lines.push(field_line("Path", Span::raw(resolved.display().to_string())));
1340 lines
1341 }
1342 ItemDetail::Directory { resolved } => {
1343 let mut lines = vec![];
1344 push_section_header(&mut lines, "Overview");
1345 lines.push(kind_line(
1346 app.sym("bullet_empty"),
1347 WARNING(),
1348 "Plain directory",
1349 "(exists, but no .git entry was found)",
1350 ));
1351 lines.push(field_line("Path", Span::raw(resolved.display().to_string())));
1352 lines
1353 }
1354 ItemDetail::Error { resolved, message } => {
1355 let mut lines = vec![];
1356 push_section_header(&mut lines, "Overview");
1357 lines.push(kind_line(
1358 app.sym("warning").trim(),
1359 WARNING(),
1360 "Could not read repository",
1361 "(libgit2 reported an error — see below)",
1362 ));
1363 lines.push(field_line("Path", Span::raw(resolved.display().to_string())));
1364 lines.push(Line::from(""));
1365 lines.push(Line::from(vec![
1366 Span::raw(FIELD_INDENT),
1367 Span::styled(message.clone(), Style::default().fg(DANGER())),
1368 ]));
1369 lines
1370 }
1371 ItemDetail::Repo { resolved, info } => build_repo_body(resolved, info, 80),
1372 }
1373}
1374
1375fn build_repo_body(
1376 resolved: &std::path::Path,
1377 info: &RepoInfo,
1378 max_width: usize,
1379) -> Vec<Line<'static>> {
1380 let mut lines: Vec<Line<'static>> = vec![];
1381
1382 push_section_header(&mut lines, "General");
1384 lines.push(kind_line("●", SUCCESS(), "Git Repository", "(inspectable libgit2)"));
1385
1386 let path_str = resolved.display().to_string();
1388 let wrap_w = max_width.saturating_sub(12).max(10);
1389 let wrapped_paths = crate::ui::draw::wrap_str(&path_str, wrap_w);
1390 for (i, p) in wrapped_paths.iter().enumerate() {
1391 if i == 0 {
1392 lines.push(field_line("Path", Span::raw(p.clone())));
1393 } else {
1394 lines.push(Line::from(vec![Span::raw(" ".repeat(12)), Span::raw(p.clone())]));
1395 }
1396 }
1397
1398 let branch = info.branch.clone().unwrap_or_else(|| "(detached HEAD)".to_string());
1399 lines.push(field_line("Branch", Span::styled(branch, accent_style())));
1400
1401 push_section_header(&mut lines, "HEAD Commit");
1403 if let Some(head) = &info.head {
1404 lines.push(field_line(
1405 "Hash",
1406 Span::styled(head.short_id.clone(), Style::default().fg(WARNING())),
1407 ));
1408
1409 let msg = head.summary.clone();
1411 let wrapped_msgs = crate::ui::draw::wrap_str(&msg, wrap_w);
1412 for (i, m) in wrapped_msgs.iter().enumerate() {
1413 if i == 0 {
1414 lines.push(field_line("Message", Span::styled(m.clone(), primary_style())));
1415 } else {
1416 lines.push(Line::from(vec![
1417 Span::raw(" ".repeat(12)),
1418 Span::styled(m.clone(), primary_style()),
1419 ]));
1420 }
1421 }
1422
1423 lines.push(field_line("Author", Span::raw(head.author.clone())));
1424 lines.push(field_line("Date", Span::raw(head.when.clone())));
1425 } else {
1426 lines.push(field_line("HEAD", Span::styled("(empty repository)", muted_style())));
1427 }
1428
1429 push_section_header(&mut lines, "Sync");
1431 append_sync(&mut lines, info, max_width);
1432
1433 lines
1434}
1435
1436fn push_section_header(lines: &mut Vec<Line<'static>>, title: &'static str) {
1440 lines.push(Line::from(""));
1441 lines.push(Line::from(vec![
1442 Span::raw(FIELD_INDENT),
1443 Span::styled("▍ ", Style::default().fg(ACCENT())),
1444 Span::styled(title, primary_style()),
1445 ]));
1446 lines.push(Line::from(""));
1447}
1448
1449fn append_sync(lines: &mut Vec<Line<'static>>, info: &RepoInfo, max_width: usize) {
1452 match &info.upstream {
1453 None => {
1454 lines.push(field_line("Upstream", Span::styled("(not configured)", muted_style())));
1455 lines.push(field_line("Sync", Span::styled("—", muted_style())));
1456 }
1457 Some(name) => {
1458 lines.push(field_line(
1459 "Upstream",
1460 Span::styled(name.clone(), Style::default().fg(ACCENT())),
1461 ));
1462 let s = &info.summary;
1463 if s.is_synced() {
1464 lines.push(field_line(
1465 "Sync",
1466 Span::styled("in sync", Style::default().fg(SUCCESS())),
1467 ));
1468 } else {
1469 let mut spans =
1470 vec![Span::raw(FIELD_INDENT), Span::styled(field_label("Sync"), muted_style())];
1471 if s.ahead > 0 {
1472 spans.push(Span::styled(format!("{} ahead", s.ahead), primary_style()));
1473 }
1474 if s.behind > 0 {
1475 if s.ahead > 0 {
1476 spans.push(Span::raw(", "));
1477 }
1478 spans.push(Span::styled(
1479 format!("{} behind", s.behind),
1480 Style::default().fg(WARNING()),
1481 ));
1482 }
1483 lines.push(Line::from(spans));
1484 }
1485 }
1486 }
1487
1488 if info.remotes.is_empty() {
1490 lines.push(field_line("Remotes", Span::styled("(none)", muted_style())));
1491 } else {
1492 lines.push(Line::from(""));
1493 for r in info.remotes.iter() {
1494 for rl in remote_lines(r, max_width) {
1495 lines.push(rl);
1496 }
1497 }
1498 }
1499}
1500
1501fn remote_lines(remote: &RemoteInfo, max_width: usize) -> Vec<Line<'static>> {
1502 let wrap_w = max_width.saturating_sub(14).max(10);
1503 let wrapped_urls = crate::ui::draw::wrap_str(&remote.url, wrap_w);
1504 let mut lines = Vec::new();
1505 for (i, url) in wrapped_urls.iter().enumerate() {
1506 if i == 0 {
1507 lines.push(Line::from(vec![
1508 Span::raw(" "),
1509 Span::styled(format!("{:<8}", remote.name), Style::default().fg(ACCENT())),
1510 Span::raw(" "),
1511 Span::raw(url.clone()),
1512 ]));
1513 } else {
1514 lines.push(Line::from(vec![Span::raw(" ".repeat(14)), Span::raw(url.clone())]));
1515 }
1516 }
1517 lines
1518}
1519
1520pub fn shorten_path(path: &str, max_width: usize) -> String {
1521 if path.len() <= max_width {
1522 return path.to_string();
1523 }
1524
1525 let components: Vec<&str> = path.split(['/', '\\']).collect();
1526 if components.len() <= 1 {
1527 return path.to_string();
1528 }
1529
1530 let file_name = components.last().unwrap_or(&"");
1531 let dirs = &components[..components.len() - 1];
1532
1533 let mut shortened_dirs = Vec::new();
1534 for dir in dirs {
1535 if let Some(first_char) = dir.chars().next() {
1536 shortened_dirs.push(first_char.to_string());
1537 } else {
1538 shortened_dirs.push(String::new());
1539 }
1540 }
1541
1542 let separator = if path.contains('\\') { "\\" } else { "/" };
1543 let mut shortened_path = shortened_dirs.join(separator);
1544 if !shortened_path.is_empty() {
1545 shortened_path.push_str(separator);
1546 }
1547 shortened_path.push_str(file_name);
1548
1549 shortened_path
1550}
1551
1552pub fn file_entry_line(
1553 entry: &FileEntry,
1554 is_lfs: bool,
1555 available_width: Option<usize>,
1556) -> Line<'static> {
1557 let label_style = match entry.label {
1558 "N" => Style::default().fg(SUCCESS()),
1559 "D" => Style::default().fg(DANGER()),
1560 "C" => Style::default().fg(DANGER()).add_modifier(Modifier::BOLD),
1561 "R" | "T" => Style::default().fg(ACCENT()),
1562 "?" => muted_style(),
1563 _ => Style::default().fg(WARNING()), };
1565
1566 let non_path_len = FILE_INDENT.len() + FILE_LABEL_WIDTH + if is_lfs { 6 } else { 0 };
1567 let path_str = if let Some(w) = available_width {
1568 let max_path_width = w.saturating_sub(non_path_len);
1569 shorten_path(&entry.path, max_path_width)
1570 } else {
1571 entry.path.clone()
1572 };
1573
1574 let mut spans = vec![
1575 Span::raw(FILE_INDENT),
1576 Span::styled(format!("{:<FILE_LABEL_WIDTH$}", entry.label), label_style),
1577 Span::styled(path_str, muted_style()),
1578 ];
1579 if is_lfs {
1580 spans.push(Span::raw(" "));
1581 spans.push(Span::styled("[LFS]", accent_style()));
1582 }
1583 Line::from(spans)
1584}
1585
1586fn field_label(name: &str) -> String {
1590 let mut s = format!("{}:", name);
1591 while s.chars().count() < FIELD_LABEL_WIDTH {
1592 s.push(' ');
1593 }
1594 s
1595}
1596
1597fn field_line(name: &'static str, value: Span<'static>) -> Line<'static> {
1598 Line::from(vec![Span::raw(FIELD_INDENT), Span::styled(field_label(name), muted_style()), value])
1599}
1600
1601fn kind_line(
1602 symbol: &'static str,
1603 color: ratatui::style::Color,
1604 title: &'static str,
1605 sub: &'static str,
1606) -> Line<'static> {
1607 Line::from(vec![
1608 Span::raw(FIELD_INDENT),
1609 Span::styled(symbol, Style::default().fg(color)),
1610 Span::raw(" "),
1611 Span::styled(title, primary_style()),
1612 Span::raw(" "),
1613 Span::styled(sub, muted_style()),
1614 ])
1615}
1616
1617#[allow(clippy::too_many_arguments)]
1619
1620fn graph_line_spans(line: &crate::repo::GraphLine) -> Line<'static> {
1621 let mut spans = Vec::new();
1622
1623 spans.push(Span::styled(line.graph.clone(), muted_style()));
1625
1626 if let Some(ref c) = line.commit {
1627 let short_hash = if c.oid.len() >= 7 { &c.oid[0..7] } else { &c.oid };
1629 spans.push(Span::styled(format!("{} ", short_hash), accent_style()));
1630
1631 if !c.signature_status.is_empty() && c.signature_status != "N" {
1633 let (sig_char, sig_style) = match c.signature_status.as_str() {
1634 "G" => ("✓ ", Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD)),
1635 "B" => ("✗ ", Style::default().fg(DANGER()).add_modifier(Modifier::BOLD)),
1636 "U" | "X" | "Y" | "R" => ("✓ ", Style::default().fg(WARNING())),
1637 _ => ("? ", muted_style()),
1638 };
1639 spans.push(Span::styled(sig_char, sig_style));
1640 }
1641
1642 if !c.decoration.is_empty() {
1644 let dec = c.decoration.trim();
1645 let dec_content = if dec.starts_with('(') && dec.ends_with(')') {
1646 &dec[1..dec.len() - 1]
1647 } else {
1648 dec
1649 };
1650
1651 spans.push(Span::styled("(", muted_style()));
1652 let mut first = true;
1653 for ref_item in dec_content.split(", ") {
1654 if !first {
1655 spans.push(Span::styled(", ", muted_style()));
1656 }
1657 first = false;
1658
1659 if let Some(stripped) = ref_item.strip_prefix("HEAD -> ") {
1660 spans.push(Span::styled(
1661 "HEAD -> ",
1662 Style::default().fg(SUCCESS()).add_modifier(Modifier::BOLD),
1663 ));
1664 spans.push(Span::styled(
1665 stripped.to_string(),
1666 Style::default().fg(ACCENT()).add_modifier(Modifier::BOLD),
1667 ));
1668 } else if let Some(stripped) = ref_item.strip_prefix("tag: ") {
1669 spans.push(Span::styled("tag: ", Style::default().fg(WARNING())));
1670 spans.push(Span::styled(
1671 stripped.to_string(),
1672 Style::default().fg(WARNING()).add_modifier(Modifier::BOLD),
1673 ));
1674 } else if ref_item.contains('/') {
1675 spans.push(Span::styled(ref_item.to_string(), Style::default().fg(DANGER())));
1676 } else {
1677 spans.push(Span::styled(ref_item.to_string(), Style::default().fg(SUCCESS())));
1678 }
1679 }
1680 spans.push(Span::styled(") ", muted_style()));
1681 }
1682
1683 spans.push(Span::styled(c.summary.clone(), primary_style()));
1685
1686 spans.push(Span::styled(" - ", muted_style()));
1688 spans.push(Span::styled(c.author.clone(), muted_style()));
1689 spans.push(Span::styled(format!(" ({})", c.date), muted_style()));
1690 }
1691
1692 Line::from(spans)
1693}
1694
1695#[allow(clippy::too_many_arguments)]
1696
1697pub fn read_file_content(path: &std::path::Path) -> Result<String, std::io::Error> {
1698 use std::io::Read;
1699 let file = std::fs::File::open(path)?;
1700 let mut buffer = Vec::new();
1701 file.take(100_000).read_to_end(&mut buffer)?;
1702 let content = String::from_utf8(buffer)
1703 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1704 Ok(content)
1705}
1706
1707#[cfg(test)]
1708mod tests {
1709 use super::*;
1710 use crate::repo::{FileEntry, GraphLine, RepoInfo, TabData};
1711 use ratatui::text::Span;
1712
1713 #[test]
1714 fn test_ui_detail_functions_coverage_boost() {
1715 let _ = error_style();
1717
1718 let _ = field_label("name");
1720 let _ = field_line("test", Span::raw("val"));
1721 let _ = kind_line("staged", ratatui::style::Color::Red, "red", "sub");
1722
1723 let entry = FileEntry { path: "test_path.txt".to_string(), label: "M" };
1725 let _ = file_entry_line(&entry, false, None);
1726 let _ = file_entry_line(&entry, true, None);
1727 let _ = file_entry_line(&entry, false, Some(10));
1728
1729 assert_eq!(shorten_path("src/app/actions.rs", 30), "src/app/actions.rs");
1731 assert_eq!(shorten_path("src/app/actions.rs", 15), "s/a/actions.rs");
1732 assert_eq!(shorten_path("README.md", 5), "README.md");
1733 assert_eq!(shorten_path("gitwig-core\\src\\lib.rs", 15), "g\\s\\lib.rs");
1734
1735 let gline = GraphLine {
1737 graph: "*".to_string(),
1738 commit: Some(crate::repo::GraphCommit {
1739 oid: "oid".to_string(),
1740 decoration: "dec".to_string(),
1741 summary: "msg".to_string(),
1742 author: "Author".to_string(),
1743 date: "date".to_string(),
1744 signature_status: "G".to_string(),
1745 }),
1746 };
1747 let _ = graph_line_spans(&gline);
1748
1749 let info = RepoInfo {
1751 committer_stats: TabData::Loaded(vec![crate::repo::CommitterStat {
1752 name: "Author".to_string(),
1753 email: "author@example.com".to_string(),
1754 count: 15,
1755 }]),
1756 ..RepoInfo::default()
1757 };
1758 let _ = build_committer_stats_lines(&info);
1759
1760 let temp_file = std::env::temp_dir().join("twig_ui_detail_test_file.txt");
1762 let _ = std::fs::write(&temp_file, "hello world");
1763 let content = read_file_content(&temp_file).expect("file content should be read");
1764 assert_eq!(content, "hello world");
1765 let _ = std::fs::remove_file(&temp_file);
1766 }
1767}