Skip to main content

gitwig/ui/
ui_detail.rs

1//! Full-screen detail view for the currently-selected item.
2//!
3//! Reads a snapshot prepared by `repo::inspect_detail` and renders it as a
4//! padded paragraph in the body of the outer Gitwig frame. Drawing is pure —
5//! all git2 work happens once in `App::open_detail`, not per frame.
6//!
7#![allow(unused_imports)]
8//! The Detail View rendering module.
9//! The view is divided into labelled sections (Overview, Repository, Sync,
10//! Working Tree) separated by blank lines and introduced by an accent-coloured
11//! `▍ Title` header line. Within "Working Tree", changed files are listed
12//! under Staged / Unstaged / Untracked / Conflicts sub-headers.
13
14use 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 = "  ";
34/// Column width for the left-side field label — wide enough for "Upstream:".
35const FIELD_LABEL_WIDTH: usize = 9;
36/// Indent for file entries inside a working-tree sub-section.
37const FILE_INDENT: &str = "      ";
38/// Column width for the file-status label ("T" = 1 char).
39const FILE_LABEL_WIDTH: usize = 2;
40
41pub fn error_style() -> Style {
42    Style::default().fg(DANGER())
43}
44
45// ── Hit-test areas ─────────────────────────────────────────────────────────
46
47/// Bounding boxes of the interactive panels in the detail view.
48/// Recorded each frame so the mouse handler can do hit-testing without
49/// duplicating the layout logic.
50#[derive(Default, Clone, Copy)]
51pub struct DetailAreas {
52    /// Commits table (top panel).
53    pub commits: Option<Rect>,
54    /// Left side of the bottom panel.
55    /// In the uncommitted view this is the outer Staging Area block;
56    /// for a real commit it is the Changed Files block.
57    pub bottom_left: Option<Rect>,
58    /// Staged sub-panel (only in the uncommitted / staging view).
59    pub staged_sub: Option<Rect>,
60    /// Unstaged sub-panel (only in the uncommitted / staging view).
61    pub unstaged_sub: Option<Rect>,
62    /// Conflicts sub-panel (only in the uncommitted / staging view).
63    pub conflicts_sub: Option<Rect>,
64    /// Diff / Staging Details panel (right side of the bottom panel).
65    pub bottom_right: Option<Rect>,
66    /// Commit details panel (bottom-left bottom-half panel).
67    pub commit_details: Option<Rect>,
68    /// Local branches panel in Branches tab.
69    pub local_branches: Option<Rect>,
70    /// Remote branches panel in Branches tab.
71    pub remote_branches: Option<Rect>,
72    /// Local tags panel in Tags tab.
73    pub local_tags: Option<Rect>,
74    /// Remote tags panel in Tags tab.
75    pub remote_tags: Option<Rect>,
76    /// Bounding box of the tab bar itself.
77    pub tab_bar: Option<Rect>,
78    /// Bounding box of the files list.
79    pub files: Option<Rect>,
80    /// Bounding box of the file content preview.
81    pub file_content: Option<Rect>,
82    /// Bounding box of the remotes panel.
83    pub remotes: Option<Rect>,
84    /// Bounding box of the stashes panel.
85    pub stashes: Option<Rect>,
86    /// Bounding box of the stashed files panel.
87    pub stashed_files: Option<Rect>,
88    /// Bounding box of the worktrees panel.
89    pub worktrees: Option<Rect>,
90    /// Bounding box of the horizontal splitter in inspect view.
91    pub inspect_horizontal_splitter: Option<Rect>,
92    /// Bounding box of the vertical splitter in left panel of inspect view.
93    pub inspect_vertical_splitter: Option<Rect>,
94    /// Bounding box of the main vertical splitter in workspace tab.
95    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    /// Bounding box of the horizontal splitter in files tab.
103    pub files_horizontal_splitter: Option<Rect>,
104    /// Bounding box of the horizontal splitter in branches tab.
105    pub branches_horizontal_splitter: Option<Rect>,
106    /// Bounding box of the horizontal splitter in stashes tab.
107    pub stashes_horizontal_splitter: Option<Rect>,
108    /// Bounding box of the vertical splitter in left panel of stashes tab.
109    pub stashes_vertical_splitter: Option<Rect>,
110    /// Bounding box of the horizontal splitter in overview tab.
111    pub overview_horizontal_splitter: Option<Rect>,
112    /// Bounding box of the overview panel in Overview mode.
113    pub overview: Option<Rect>,
114    /// Bounding box of the stats panel in Overview mode.
115    pub stats: Option<Rect>,
116    /// Bounding box of the graph panel.
117    pub graph: Option<Rect>,
118    /// Inner area of the graph list.
119    pub graph_inner: Option<Rect>,
120    /// Inner area of commits list.
121    pub commits_inner: Option<Rect>,
122    /// Inner area of staged files list.
123    pub staged_sub_inner: Option<Rect>,
124    /// Inner area of unstaged files list.
125    pub unstaged_sub_inner: Option<Rect>,
126    /// Inner area of conflicts files list.
127    pub conflicts_sub_inner: Option<Rect>,
128    /// Inner area of changed files list.
129    pub changed_files_inner: Option<Rect>,
130    /// Inner area of local branches list.
131    pub local_branches_inner: Option<Rect>,
132    /// Inner area of remote branches list.
133    pub remote_branches_inner: Option<Rect>,
134    /// Inner area of local tags list.
135    pub local_tags_inner: Option<Rect>,
136    /// Inner area of remotes list.
137    pub remotes_inner: Option<Rect>,
138    /// Inner area of stashes list.
139    pub stashes_inner: Option<Rect>,
140    /// Inner area of stashed files list.
141    pub stashed_files_inner: Option<Rect>,
142    /// Inner area of files list in Files tab.
143    pub files_inner: Option<Rect>,
144    /// Inner area of worktrees list.
145    pub worktrees_inner: Option<Rect>,
146    /// Bounding box of submodules list.
147    pub submodules: Option<Rect>,
148    /// Inner area of submodules list.
149    pub submodules_inner: Option<Rect>,
150    /// Bounding box of the reflog list.
151    pub reflog: Option<Rect>,
152    /// Inner area of the reflog list.
153    pub reflog_inner: Option<Rect>,
154    /// The first row each of these lists drew (its scroll offset), recorded
155    /// after drawing so a mouse click maps to the row it hit, not to the row
156    /// at that height in an unscrolled list.
157    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    /// Bounding box of the commit message popup.
163    pub commit_popup: Option<Rect>,
164    /// Bounding box of the parent area the commit popup was centered inside.
165    pub commit_popup_parent: Option<Rect>,
166}
167
168/// Renders the detail view into `area` and records panel bounds in `areas`.
169#[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), // Title
228                    Constraint::Length(1), // Divider
229                    Constraint::Min(0),    // Commits list
230                ])
231                .split(area);
232
233            // Header: title left, branch right
234            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            // Divider
260            {
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            // Draw SearchColumnPicker overlay if in that mode
287            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    // Extract branch name if this is a repo detail.
341    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), // Header
353                Constraint::Length(2), // Tab Bar
354                Constraint::Min(0),    // Body Content
355            ])
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), // Header
363                Constraint::Min(0),    // Body Content
364            ])
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    // Split header into left (Item label) and right (branch name).
375    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        // Zone widths: outer fade → inner fade → solid centre (mirrored)
400        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                // Split body: top = recent commits, bottom = staging panels
522                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                // Record main vertical splitter boundary in workspace tab
531                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                    // <uncommitted> row selected — show working-tree staging panels.
565                    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                    // Real commit selected — show its changed files.
581                    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                            // Fallback: selection out of range, show staging panels.
600                            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                // Render Files view (tab 2, index 1)
619                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                // Render Graph view (tab 3, index 2)
634                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                // Render Branches view (tab 4, index 3)
720                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                // Render Tags view (tab 5, index 4)
733                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                // Render Remotes view (tab 6, index 5)
745                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                // Render Stashes view (tab 7, index 6)
756                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                // Render Worktrees view (tab 8, index 7)
772                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                // Render Submodules view (tab 9, index 8)
783                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                // Render Reflog view (tab 10, index 9)
794                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                // Render Forge view (tab 11, index 10)
805                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                // Render PRs view (tab 12, index 11)
816                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            // Draw detail help overlay on top when requested.
827            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            // Draw overview overlay on top when requested.
836            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            // Draw commit popup on top when requested.
848            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            // Draw search column picker popup on top when requested.
861            if matches!(mode, Mode::SearchColumnPicker) {
862                crate::popups::search_columns::draw_search_column_picker(f, app, body_area);
863            }
864            // Draw branch create popup on top when requested.
865            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            // Draw PR line comment popups on top when requested.
874            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            // Draw remote add name popup on top when requested.
888            if matches!(mode, Mode::RemoteAddNameInput) {
889                crate::popups::add_remote::draw_remote_add_name_popup(f, input_buffer, body_area);
890            }
891            // Draw remote add url popup on top when requested.
892            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            // Draw remote delete popup on top when requested.
901            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            // Draw tag create popup on top when requested.
909            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            // Draw stash create popup on top when requested.
920            if matches!(mode, Mode::StashCreateInput) {
921                crate::popups::stash_msg::draw_stash_create_popup(f, input_buffer, body_area, app);
922            }
923            // Draw stashing UI overlay on top when requested.
924            if matches!(mode, Mode::StashingUI) {
925                crate::popups::stashing_ui::draw_stashing_ui(f, info, app, body_area);
926            }
927            // Draw branch delete popup on top when requested.
928            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            // Draw worktree add branch name popup
936            if matches!(mode, Mode::WorktreeAddBranchInput) {
937                crate::popups::worktree::draw_worktree_add_branch_popup(f, input_buffer, body_area);
938            }
939            // Draw worktree add path popup
940            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            // Draw worktree lock reason popup
949            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            // Draw submodule add URL popup
963            if matches!(mode, Mode::SubmoduleAddUrlInput) {
964                crate::popups::submodule::draw_submodule_add_url_popup(f, input_buffer, body_area);
965            }
966            // Draw submodule add Path popup
967            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            // Draw submodule delete confirm popup
976            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            // Draw branch push popup on top when requested.
987            if matches!(mode, Mode::BranchPushConfirm) {
988                crate::popups::confirm::draw_branch_push_popup(f, branch_action_target, body_area);
989            }
990            // Draw branch merge popup on top when requested.
991            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            // Draw checkout-and-merge-into popup on top when requested.
1000            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            // Draw merge abort popup on top when requested.
1009            if matches!(mode, Mode::MergeAbortConfirm) {
1010                crate::popups::confirm::draw_merge_abort_confirm_popup(f, body_area);
1011            }
1012            // Draw merge continue popup on top when requested.
1013            if matches!(mode, Mode::MergeContinueConfirm) {
1014                crate::popups::confirm::draw_merge_continue_confirm_popup(f, body_area);
1015            }
1016            // Draw branch rebase popup on top when requested.
1017            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            // Draw branch interactive rebase popup on top when requested.
1026            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            // Draw tag delete popup on top when requested.
1035            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            // Draw tag push popup on top when requested.
1046            if matches!(mode, Mode::TagPushConfirm) {
1047                crate::popups::confirm::draw_tag_push_popup(f, tag_push_target, body_area);
1048            }
1049            // Draw tag push all popup on top when requested.
1050            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            // Draw cherry-pick popup on top when requested.
1058            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            // Draw revert popup on top when requested.
1068            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            // Draw stash delete popup on top when requested.
1077            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            // Draw stash apply popup on top when requested.
1088            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            // Draw remote picker popup on top when requested.
1104            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            // Draw commit history picker popup on top when requested.
1115            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            // Draw discard changes popup on top when requested.
1124            if matches!(mode, Mode::DiscardChangesConfirm) {
1125                crate::popups::confirm::draw_discard_changes_popup(f, discard_target, body_area);
1126            }
1127            // Draw branch checkout popup on top when requested.
1128            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            // Draw tag checkout popup on top when requested.
1136            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            // Draw commit checkout popup on top when requested.
1144            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
1167// ── Commit files panel ─────────────────────────────────────────────────────
1168
1169/// Renders the bottom panel for a selected real commit:
1170/// left = changed-file list (with selection), right = diff panel.
1171
1172// ── Staging panels ─────────────────────────────────────────────────────────
1173
1174/// Renders two side-by-side panels: \"Staging Area\" (left, split into Staged/Unstaged)
1175/// and \"Staging Details\" (right, diff of selected file).
1176
1177/// Renders a titled sub-panel listing `files`, or a centred placeholder if empty.
1178/// `selection` — when `Some(idx)` the panel is focused and the file at `idx` is highlighted.
1179
1180// ── Overview popup ─────────────────────────────────────────────────────────
1181
1182/// Renders the repo overview as a floating popup centred over `area`.
1183fn 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    // Record horizontal splitter boundary in overview tab
1203    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
1320/// Returns a [`Rect`] that is `percent_x` wide and `percent_y` tall, centred in `r`.
1321
1322// Detail help overlay configuration is now dynamically generated in detail_help.rs
1323
1324/// Renders a floating shortcut reference overlay centred over `area`.
1325
1326// ── Body builder ───────────────────────────────────────────────────────────
1327
1328fn 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    // ── General ───────────────────────────────────────────────────────────
1383    push_section_header(&mut lines, "General");
1384    lines.push(kind_line("●", SUCCESS(), "Git Repository", "(inspectable libgit2)"));
1385
1386    // Wrap Path field
1387    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    // ── HEAD Commit ───────────────────────────────────────────────────────
1402    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        // Wrap HEAD Commit Message
1410        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    // ── Sync ──────────────────────────────────────────────────────────────
1430    push_section_header(&mut lines, "Sync");
1431    append_sync(&mut lines, info, max_width);
1432
1433    lines
1434}
1435
1436// ── Section rendering ──────────────────────────────────────────────────────
1437
1438/// Emits a blank line then `  ▍ Title` in accent + bold, then another blank.
1439fn 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
1449// ── Sync section ──────────────────────────────────────────────────────────
1450
1451fn 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    // Remotes listed under Sync
1489    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()), // "M"
1564    };
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
1586// ── Low-level line builders ────────────────────────────────────────────────
1587
1588/// `"Field:   "` padded to `FIELD_LABEL_WIDTH`.
1589fn 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/// Renders a commit confirmation/input popup centered over `area`.
1618#[allow(clippy::too_many_arguments)]
1619
1620fn graph_line_spans(line: &crate::repo::GraphLine) -> Line<'static> {
1621    let mut spans = Vec::new();
1622
1623    // 1. Graph characters
1624    spans.push(Span::styled(line.graph.clone(), muted_style()));
1625
1626    if let Some(ref c) = line.commit {
1627        // 2. Commit OID (short hash)
1628        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        // Verification signature status badge
1632        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        // 3. Decorations (refs)
1643        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        // 4. Commit Summary
1684        spans.push(Span::styled(c.summary.clone(), primary_style()));
1685
1686        // 5. Author and Date
1687        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        // 1. error_style
1716        let _ = error_style();
1717
1718        // 2. field_label / field_line / kind_line
1719        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        // 3. file_entry_line
1724        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        // Test path shortening logic
1730        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        // 4. graph_line_spans
1736        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        // 5. build_committer_stats_lines
1750        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        // 6. read_file_content
1761        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}