Skip to main content

gitkraft_gui/
view.rs

1//! Main view entry point — builds the top-level layout shell and delegates to
2//! feature views for each region of the UI.
3//!
4//! Layout (when a repo is open):
5//! ```text
6//! ┌──────────────────────────────────────────┐
7//! │  header toolbar                          │
8//! ├────────┬─────────────────┬───────────────┤
9//! │        │                 │               │
10//! │ side-  │  commit log     │  diff viewer  │
11//! │ bar    │                 │               │
12//! │        │                 │               │
13//! ├────────┴─────────────────┴───────────────┤
14//! │  staging area (unstaged | staged | msg)  │
15//! ├──────────────────────────────────────────┤
16//! │  status bar                              │
17//! └──────────────────────────────────────────┘
18//! ```
19//!
20//! All vertical and horizontal dividers between panes are **draggable** — the
21//! user can resize the sidebar, commit-log, diff-viewer, and staging area by
22//! grabbing the thin divider lines and dragging.
23//!
24//! The outer-most widget is a `mouse_area` that captures `on_release` events
25//! unconditionally, and `on_move` events **only while a drag is in progress**.
26//! This avoids firing `PaneDragMove → update() → view()` on every cursor
27//! movement when no resize drag is active.
28
29use iced::widget::{column, container, mouse_area, row, text, Space};
30use iced::{Alignment, Element, Length};
31
32use crate::features;
33use crate::icons;
34use crate::message::Message;
35use crate::state::{DragTarget, DragTargetH, GitKraft};
36use crate::theme;
37use crate::theme::ThemeColors;
38use crate::view_utils;
39use crate::widgets;
40
41impl GitKraft {
42    /// Top-level view — called by the Iced runtime on every frame.
43    pub fn view(&self) -> Element<'_, Message> {
44        let c = self.colors();
45
46        // ── Tab bar (always visible) ──────────────────────────────────────
47        let tab_bar = widgets::tab_bar::view(self);
48
49        if !self.has_repo() {
50            // Show the tab bar above the welcome screen so users can
51            // switch between tabs even when the active one has no repo.
52            let welcome = features::repo::view::welcome_view(self);
53            let tab = self.active_tab();
54            let mut outer = column![tab_bar];
55            // Error banner and status bar are shown even without a repo so
56            // messages like "Settings opened in …" are always visible.
57            if let Some(ref err) = tab.error_message {
58                outer = outer.push(error_banner(err, &c));
59            }
60            outer = outer.push(welcome);
61            if tab.status_message.is_some() {
62                outer = outer.push(status_bar_view(self));
63            }
64            return container(outer)
65                .width(Length::Fill)
66                .height(Length::Fill)
67                .style(theme::bg_style)
68                .into();
69        }
70
71        let tab = self.active_tab();
72
73        // ── Header toolbar ────────────────────────────────────────────────
74        let header = widgets::header::view(self);
75
76        // ── Sidebar (branches + stash + remotes) ──────────────────────
77        let sidebar: Element<'_, Message> = if self.sidebar_expanded {
78            let branches = features::branches::view::view(self);
79            let stash = features::stash::view::view(self);
80            let remotes = features::remotes::view::view(self);
81
82            let branches_divider =
83                widgets::divider::horizontal_divider(DragTargetH::SidebarBranchesBottom, &c);
84            let stash_divider =
85                widgets::divider::horizontal_divider(DragTargetH::SidebarStashBottom, &c);
86
87            // Three sections share the sidebar height using ratios.
88            let branches_pct = (self.sidebar_branches_ratio * 100.0) as u16;
89            let stash_pct = (self.sidebar_stash_ratio * 100.0) as u16;
90            let remotes_pct = 100u16
91                .saturating_sub(branches_pct)
92                .saturating_sub(stash_pct)
93                .max(5);
94
95            let sidebar_content = container(
96                column![
97                    container(branches)
98                        .width(Length::Fill)
99                        .height(Length::FillPortion(branches_pct)),
100                    branches_divider,
101                    container(stash)
102                        .width(Length::Fill)
103                        .height(Length::FillPortion(stash_pct)),
104                    stash_divider,
105                    container(remotes)
106                        .width(Length::Fill)
107                        .height(Length::FillPortion(remotes_pct)),
108                ]
109                .width(Length::Fill)
110                .height(Length::Fill),
111            )
112            .width(Length::Fixed(self.sidebar_width))
113            .height(Length::Fill)
114            .style(theme::sidebar_style);
115
116            let divider = widgets::divider::vertical_divider(DragTarget::SidebarRight, &c);
117
118            row![sidebar_content, divider].height(Length::Fill).into()
119        } else {
120            Space::new().into()
121        };
122
123        // ── Commit log ────────────────────────────────────────────────────
124        let commit_log_content = container(features::commits::view::view(self))
125            .width(Length::Fixed(self.commit_log_width))
126            .height(Length::Fill);
127
128        let commit_divider = widgets::divider::vertical_divider(DragTarget::CommitLogRight, &c);
129
130        let commit_log: Element<'_, Message> = row![commit_log_content, commit_divider]
131            .height(Length::Fill)
132            .into();
133
134        // ── Diff viewer (fills all remaining horizontal space) ────────────
135        let diff_panel_content: Element<'_, Message> = {
136            let tab = self.active_tab();
137            if tab.file_history_path.is_some() {
138                features::diff::view::file_history_view(self)
139            } else if tab.blame_path.is_some() {
140                features::diff::view::blame_view(self)
141            } else {
142                features::diff::view::view(self)
143            }
144        };
145        let diff_viewer = container(diff_panel_content)
146            .width(Length::Fill)
147            .height(Length::Fill);
148
149        // ── Middle row: sidebar | divider | commit log | divider | diff ───
150        let middle = row![sidebar, commit_log, diff_viewer]
151            .height(Length::Fill)
152            .width(Length::Fill);
153
154        // ── Horizontal divider between middle and staging ─────────────────
155        let h_divider = widgets::divider::horizontal_divider(DragTargetH::StagingTop, &c);
156
157        // ── Staging area ──────────────────────────────────────────────────
158        let staging = container(features::staging::view::view(self))
159            .width(Length::Fill)
160            .height(Length::Fixed(self.staging_height));
161
162        // ── Status bar ────────────────────────────────────────────────────
163        let status_bar = status_bar_view(self);
164
165        // ── Error banner (if any) ─────────────────────────────────────────
166        let mut main_col = column![].width(Length::Fill).height(Length::Fill);
167
168        main_col = main_col.push(tab_bar);
169
170        if let Some(ref err) = tab.error_message {
171            main_col = main_col.push(error_banner(err, &c));
172        }
173
174        if let Some(ref path) = tab.pending_delete_file {
175            let file_name = gitkraft_core::path_basename(path);
176            main_col = main_col.push(delete_confirmation_banner(file_name, &c));
177        }
178
179        main_col = main_col
180            .push(header)
181            .push(middle)
182            .push(h_divider)
183            .push(staging)
184            .push(status_bar);
185
186        let body = container(main_col)
187            .width(Length::Fill)
188            .height(Length::Fill)
189            .style(theme::bg_style);
190
191        // on_move is always active so cursor_pos stays current for context
192        // menus.  Virtual scrolling keeps the per-frame rebuild cost low
193        // (~66 commit rows instead of 500) so this is acceptable.
194        let ma: Element<'_, Message> = mouse_area(body)
195            .on_move(|p| Message::PaneDragMove(p.x, p.y))
196            .on_release(Message::PaneDragEnd)
197            .into();
198
199        // ── Search overlay ────────────────────────────────────────────────
200        let ma: Element<'_, Message> = if self.search_visible {
201            let search_panel = search_overlay(self, &c);
202            iced::widget::stack![ma, search_panel].into()
203        } else {
204            ma
205        };
206
207        // ── Context menu overlay ──────────────────────────────────────────
208        if self.active_tab().context_menu.is_some() {
209            // Transparent full-screen backdrop — clicking it dismisses the menu.
210            let backdrop = mouse_area(
211                container(Space::new().width(Length::Fill).height(Length::Fill))
212                    .style(theme::backdrop_style),
213            )
214            .on_press(Message::CloseContextMenu)
215            .on_right_press(Message::CloseContextMenu);
216
217            let (menu_x, menu_y) = context_menu_position(self);
218            let menu_panel = context_menu_panel(self, &c);
219
220            let positioned = column![
221                Space::new().height(menu_y),
222                row![Space::new().width(menu_x), menu_panel,],
223            ]
224            .width(Length::Fill)
225            .height(Length::Fill);
226
227            iced::widget::stack![ma, backdrop, positioned].into()
228        } else {
229            ma
230        }
231    }
232}
233
234/// Render the status bar at the very bottom of the window.
235fn status_bar_view(state: &GitKraft) -> Element<'_, Message> {
236    let tab = state.active_tab();
237    let c = state.colors();
238
239    let status_text = if tab.is_loading {
240        tab.status_message
241            .as_deref()
242            .unwrap_or("Loading…")
243            .to_string()
244    } else {
245        tab.status_message.as_deref().unwrap_or("Ready").to_string()
246    };
247
248    let status_label = text(status_text).size(12).color(c.text_secondary);
249
250    let branch_info: Element<'_, Message> = if let Some(ref branch) = tab.current_branch {
251        let icon = icon!(icons::GIT_BRANCH, 12, c.accent);
252        let label = text(branch.as_str()).size(12).color(c.text_primary);
253        row![icon, Space::new().width(4), label]
254            .align_y(Alignment::Center)
255            .into()
256    } else {
257        Space::new().into()
258    };
259
260    let repo_state_info: Element<'_, Message> = if let Some(ref info) = tab.repo_info {
261        let state_str = format!("{}", info.state);
262        if state_str != "Clean" {
263            text(state_str).size(12).color(c.yellow).into()
264        } else {
265            Space::new().into()
266        }
267    } else {
268        Space::new().into()
269    };
270
271    let changes_summary = {
272        let unstaged_count = tab.unstaged_changes.len();
273        let staged_count = tab.staged_changes.len();
274        if unstaged_count > 0 || staged_count > 0 {
275            text(format!("{unstaged_count} unstaged, {staged_count} staged"))
276                .size(12)
277                .color(c.muted)
278        } else {
279            text("Working tree clean").size(12).color(c.muted)
280        }
281    };
282
283    let zoom_label: Element<'_, Message> = if (state.ui_scale - 1.0).abs() > 0.01 {
284        text(format!("{}%", (state.ui_scale * 100.0).round() as u32))
285            .size(11)
286            .color(c.muted)
287            .into()
288    } else {
289        Space::new().into()
290    };
291
292    let bar = row![
293        status_label,
294        Space::new().width(Length::Fill),
295        changes_summary,
296        Space::new().width(16),
297        zoom_label,
298        Space::new().width(16),
299        repo_state_info,
300        Space::new().width(16),
301        branch_info,
302    ]
303    .align_y(Alignment::Center)
304    .padding([4, 10])
305    .width(Length::Fill);
306
307    container(bar)
308        .width(Length::Fill)
309        .style(theme::header_style)
310        .into()
311}
312
313fn delete_confirmation_banner<'a>(file_name: &str, c: &ThemeColors) -> Element<'a, Message> {
314    use iced::widget::{button, row, text, Space};
315
316    let label = text(format!("Delete '{file_name}' permanently?"))
317        .size(13)
318        .color(c.text_primary);
319
320    let confirm_btn = button(text("Delete").size(12).color(c.red))
321        .padding([3, 12])
322        .style(theme::toolbar_button)
323        .on_press(Message::ConfirmDeleteFile);
324
325    let cancel_btn = button(text("Cancel").size(12).color(c.text_secondary))
326        .padding([3, 12])
327        .style(theme::toolbar_button)
328        .on_press(Message::CancelDeleteFile);
329
330    container(
331        row![
332            label,
333            Space::new().width(12),
334            confirm_btn,
335            Space::new().width(6),
336            cancel_btn
337        ]
338        .align_y(iced::Alignment::Center)
339        .padding([6, 12]),
340    )
341    .width(Length::Fill)
342    .style(|theme| {
343        let palette = theme.palette();
344        iced::widget::container::Style {
345            background: Some(iced::Background::Color(iced::Color {
346                r: 0.5,
347                g: 0.1,
348                b: 0.1,
349                a: 0.5,
350            })),
351            border: iced::Border {
352                color: palette.danger,
353                width: 0.0,
354                radius: 0.0.into(),
355            },
356            ..Default::default()
357        }
358    })
359    .into()
360}
361
362/// Render an error banner at the top of the window with a dismiss button.
363fn error_banner<'a>(message: &str, c: &ThemeColors) -> Element<'a, Message> {
364    let icon = icon!(icons::EXCLAMATION_TRIANGLE, 14, c.red);
365
366    let msg = text(message.to_string()).size(13).color(c.text_primary);
367
368    let dismiss = iced::widget::button(icon!(icons::X_CIRCLE, 14, c.text_secondary))
369        .padding([2, 6])
370        .on_press(Message::DismissError);
371
372    let banner_row = row![
373        icon,
374        Space::new().width(8),
375        msg,
376        Space::new().width(Length::Fill),
377        dismiss,
378    ]
379    .align_y(Alignment::Center)
380    .padding([6, 12])
381    .width(Length::Fill);
382
383    container(banner_row)
384        .width(Length::Fill)
385        .style(theme::error_banner_style)
386        .into()
387}
388
389/// Approximate pixel position of the context menu based on what was right-clicked.
390fn context_menu_position(state: &GitKraft) -> (f32, f32) {
391    // Use the position that was frozen when the menu opened, not the live
392    // cursor_pos — otherwise the panel would follow the mouse.
393    // Nudge right/down by 2 px so the pointer tip sits just inside the panel.
394    let (x, y) = state.active_tab().context_menu_pos;
395    ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
396}
397
398/// Render the search overlay — a centered panel with an input and results list.
399/// When a commit is selected, the panel expands to show changed files and diffs.
400fn search_overlay<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
401    use iced::widget::{
402        button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space,
403    };
404    use iced::{Alignment, Length};
405
406    let has_diff_files = !state.search_diff_files.is_empty();
407    let has_diff_content = !state.search_diff_content.is_empty();
408
409    // ── Close button ──────────────────────────────────────────────────────
410    let close_btn = button(text("\u{2715}").size(14).color(c.text_secondary))
411        .padding([4, 8])
412        .style(theme::ghost_button)
413        .on_press(Message::ToggleSearch);
414
415    // ── Left panel: search input + commit results ─────────────────────────
416    let input = text_input("Search commits…", &state.search_query)
417        .on_input(Message::SearchQueryChanged)
418        .on_submit(Message::ConfirmSearchResult)
419        .padding(10)
420        .size(16);
421
422    let mut results_col = column![].spacing(2).width(Length::Fill);
423
424    if state.search_results.is_empty() && state.search_query.len() >= 2 {
425        results_col = results_col.push(
426            container(text("No results found").size(13).color(c.muted))
427                .padding([12, 8])
428                .width(Length::Fill)
429                .center_x(Length::Fill),
430        );
431    }
432
433    for (i, commit) in state.search_results.iter().take(50).enumerate() {
434        let is_selected = state.search_selected == Some(i);
435        let is_diffed = state
436            .search_diff_oid
437            .as_ref()
438            .is_some_and(|oid| *oid == commit.oid);
439        let bg_style = if is_diffed {
440            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
441        } else if is_selected {
442            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
443        } else {
444            theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
445        };
446
447        let oid_label = text(&commit.short_oid)
448            .size(12)
449            .color(c.accent)
450            .font(iced::Font::MONOSPACE);
451
452        let summary_label = text(&commit.summary).size(13).color(c.text_primary);
453
454        let author_label = text(&commit.author_name).size(11).color(c.text_secondary);
455
456        let time_label = text(commit.relative_time()).size(11).color(c.muted);
457
458        let row_content = row![
459            oid_label,
460            Space::new().width(8),
461            summary_label,
462            Space::new().width(Length::Fill),
463            author_label,
464            Space::new().width(8),
465            time_label,
466        ]
467        .align_y(Alignment::Center)
468        .padding([6, 10]);
469
470        let result_btn = button(row_content)
471            .padding(0)
472            .width(Length::Fill)
473            .style(theme::ghost_button)
474            .on_press(Message::ConfirmSearchResult);
475
476        let result_row: Element<'a, Message> =
477            mouse_area(container(result_btn).width(Length::Fill).style(bg_style))
478                .on_press(Message::SelectSearchResult(i))
479                .on_right_press(Message::OpenSearchResultContextMenu(i))
480                .into();
481
482        results_col = results_col.push(result_row);
483    }
484
485    let result_count = if !state.search_results.is_empty() {
486        text(format!("{} result(s)", state.search_results.len()))
487            .size(11)
488            .color(c.muted)
489    } else {
490        text("").size(1)
491    };
492
493    let left_header = row![
494        icon!(icons::CLOCK_HISTORY, 16, c.accent),
495        Space::new().width(8),
496        text("Search Commits").size(16).color(c.text_primary),
497        Space::new().width(Length::Fill),
498        result_count,
499        Space::new().width(8),
500        close_btn,
501    ]
502    .align_y(Alignment::Center)
503    .padding([8, 12]);
504
505    let scrollable_results = scrollable(results_col)
506        .height(Length::Fill)
507        .direction(crate::view_utils::thin_scrollbar())
508        .style(crate::theme::overlay_scrollbar);
509
510    let left_panel = column![left_header, input, scrollable_results]
511        .width(Length::Fill)
512        .height(Length::Fill)
513        .spacing(4);
514
515    // ── Right panel: file list + diff (only when a commit is selected) ────
516    let panel: Element<'a, Message> = if has_diff_content {
517        // Show combined diff content for the selected file(s)
518        let file_count = state.search_diff_content.len();
519        let title_label = if file_count == 1 {
520            state.search_diff_content[0].display_path().to_string()
521        } else {
522            format!("{file_count} file(s)")
523        };
524
525        let back_btn = button(
526            row![
527                text("← ").size(14).color(c.accent),
528                text("Back to file list").size(13).color(c.text_primary),
529            ]
530            .align_y(Alignment::Center),
531        )
532        .padding([6, 12])
533        .style(theme::ghost_button)
534        .on_press(Message::SearchDiffBack);
535
536        let close_btn2 = button(text("\u{2715}").size(14).color(c.text_secondary))
537            .padding([4, 8])
538            .style(theme::ghost_button)
539            .on_press(Message::ToggleSearch);
540
541        let diff_header = row![
542            back_btn,
543            Space::new().width(Length::Fill),
544            text(title_label).size(13).color(c.accent),
545            Space::new().width(8),
546            close_btn2,
547        ]
548        .align_y(Alignment::Center)
549        .padding([4, 8]);
550
551        let mut diff_lines_col = column![].spacing(0).width(Length::Fill);
552        for diff in &state.search_diff_content {
553            // File separator header
554            let status_color = match diff.status.color_category() {
555                gitkraft_core::StatusColorCategory::Added => c.green,
556                gitkraft_core::StatusColorCategory::Modified => c.yellow,
557                gitkraft_core::StatusColorCategory::Deleted => c.red,
558                gitkraft_core::StatusColorCategory::Renamed => c.accent,
559            };
560            if file_count > 1 {
561                diff_lines_col = diff_lines_col.push(
562                    container(
563                        row![
564                            text(format!("{}", diff.status))
565                                .size(12)
566                                .color(status_color)
567                                .font(iced::Font::MONOSPACE),
568                            Space::new().width(8),
569                            text(diff.display_path()).size(13).color(c.text_primary),
570                        ]
571                        .align_y(Alignment::Center),
572                    )
573                    .padding([6, 8])
574                    .width(Length::Fill)
575                    .style(theme::surface_style),
576                );
577            }
578            for hunk in &diff.hunks {
579                for line in &hunk.lines {
580                    let (prefix, content, color) = match line {
581                        gitkraft_core::DiffLine::Context(s) => (" ", s.as_str(), c.diff_context),
582                        gitkraft_core::DiffLine::Addition(s) => ("+", s.as_str(), c.green),
583                        gitkraft_core::DiffLine::Deletion(s) => ("-", s.as_str(), c.red),
584                        gitkraft_core::DiffLine::HunkHeader(s) => ("@@", s.as_str(), c.accent),
585                    };
586                    diff_lines_col = diff_lines_col.push(
587                        text(format!("{prefix} {content}"))
588                            .size(12)
589                            .color(color)
590                            .font(iced::Font::MONOSPACE),
591                    );
592                }
593            }
594        }
595
596        let scrollable_diff = scrollable(
597            container(diff_lines_col)
598                .padding([4, 8])
599                .width(Length::Fill),
600        )
601        .height(Length::Fill)
602        .direction(crate::view_utils::thin_scrollbar())
603        .style(crate::theme::overlay_scrollbar);
604
605        let right_panel = column![diff_header, scrollable_diff]
606            .width(Length::Fill)
607            .height(Length::Fill)
608            .spacing(4);
609
610        let content = row![
611            container(left_panel).width(Length::FillPortion(2)),
612            container(right_panel).width(Length::FillPortion(3)),
613        ]
614        .spacing(4)
615        .width(Length::Fill)
616        .height(Length::Fill);
617
618        container(content)
619            .width(1100)
620            .height(600)
621            .style(theme::context_menu_style)
622            .padding(8)
623            .into()
624    } else if has_diff_files {
625        // Show file list for the selected commit
626        let oid_short = state
627            .search_diff_oid
628            .as_ref()
629            .map(|o| &o[..7.min(o.len())])
630            .unwrap_or("???");
631
632        let file_count = state.search_diff_files.len();
633        let selected_count = state.search_diff_selected.len();
634
635        let select_all_label = if selected_count == file_count {
636            "Deselect All"
637        } else {
638            "Select All"
639        };
640
641        let select_all_btn = button(text(select_all_label).size(12).color(c.accent))
642            .padding([4, 8])
643            .style(theme::ghost_button)
644            .on_press(Message::ToggleSearchDiffSelectAll);
645
646        let diff_selected_btn: Element<'a, Message> = if selected_count > 0 {
647            button(
648                text(format!("Diff Selected ({selected_count})"))
649                    .size(12)
650                    .color(c.green),
651            )
652            .padding([4, 8])
653            .style(theme::ghost_button)
654            .on_press(Message::DiffSelectedFiles)
655            .into()
656        } else {
657            Space::new().width(0).into()
658        };
659
660        let close_btn3 = button(text("\u{2715}").size(14).color(c.text_secondary))
661            .padding([4, 8])
662            .style(theme::ghost_button)
663            .on_press(Message::ToggleSearch);
664
665        let right_header = row![
666            text(format!("Files changed vs working tree ({oid_short})"))
667                .size(14)
668                .color(c.text_primary),
669            Space::new().width(Length::Fill),
670            text(format!("{file_count} file(s)"))
671                .size(11)
672                .color(c.muted),
673            Space::new().width(8),
674            diff_selected_btn,
675            Space::new().width(4),
676            select_all_btn,
677            Space::new().width(4),
678            close_btn3,
679        ]
680        .align_y(Alignment::Center)
681        .padding([8, 12]);
682
683        let mut files_col = column![].spacing(2).width(Length::Fill);
684
685        for (i, file) in state.search_diff_files.iter().enumerate() {
686            let is_checked = state.search_diff_selected.contains(&i);
687            let status_str = format!("{}", file.status);
688            let status_color = match file.status.color_category() {
689                gitkraft_core::StatusColorCategory::Added => c.green,
690                gitkraft_core::StatusColorCategory::Modified => c.yellow,
691                gitkraft_core::StatusColorCategory::Deleted => c.red,
692                gitkraft_core::StatusColorCategory::Renamed => c.accent,
693            };
694
695            let file_row = button(
696                row![
697                    checkbox(is_checked).on_toggle(move |_| Message::ToggleSearchDiffFile(i)),
698                    Space::new().width(4),
699                    text(status_str)
700                        .size(12)
701                        .color(status_color)
702                        .font(iced::Font::MONOSPACE),
703                    Space::new().width(8),
704                    text(file.display_path()).size(13).color(c.text_primary),
705                    Space::new().width(Length::Fill),
706                ]
707                .align_y(Alignment::Center)
708                .padding([4, 8]),
709            )
710            .padding(0)
711            .width(Length::Fill)
712            .style(theme::ghost_button)
713            .on_press(Message::ViewSearchDiffFile(i));
714
715            files_col = files_col.push(file_row);
716        }
717
718        let scrollable_files = scrollable(files_col)
719            .height(Length::Fill)
720            .direction(crate::view_utils::thin_scrollbar())
721            .style(crate::theme::overlay_scrollbar);
722
723        let right_panel = column![right_header, scrollable_files]
724            .width(Length::Fill)
725            .height(Length::Fill)
726            .spacing(4);
727
728        let content = row![
729            container(left_panel).width(Length::FillPortion(2)),
730            container(right_panel).width(Length::FillPortion(3)),
731        ]
732        .spacing(4)
733        .width(Length::Fill)
734        .height(Length::Fill);
735
736        container(content)
737            .width(1100)
738            .height(600)
739            .style(theme::context_menu_style)
740            .padding(8)
741            .into()
742    } else {
743        // No commit selected yet — just show the search panel
744        container(left_panel)
745            .width(700)
746            .height(500)
747            .style(theme::context_menu_style)
748            .padding(8)
749            .into()
750    };
751
752    // Center the panel on screen with a backdrop
753    let backdrop = mouse_area(
754        container(Space::new().width(Length::Fill).height(Length::Fill))
755            .style(theme::backdrop_style),
756    )
757    .on_press(Message::ToggleSearch);
758
759    // Wrap the panel in a mouse_area that swallows clicks so they don't
760    // bubble up to the backdrop and dismiss the dialog.
761    let panel_intercepted = mouse_area(panel).on_press(Message::Noop);
762
763    let centered = container(panel_intercepted)
764        .width(Length::Fill)
765        .height(Length::Fill)
766        .center_x(Length::Fill)
767        .center_y(Length::Fill);
768
769    iced::widget::stack![backdrop, centered].into()
770}
771
772/// Build the context menu panel widget for the currently active menu.
773fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
774    use iced::widget::{button, column, container, row, text, Space};
775    use iced::{Alignment, Length};
776
777    let text_primary = c.text_primary;
778    let menu_item = move |label: &str, msg: Message| {
779        button(
780            row![
781                Space::new().width(4),
782                text(label.to_string()).size(13).color(text_primary),
783            ]
784            .align_y(Alignment::Center),
785        )
786        .padding([7, 12])
787        .width(Length::Fill)
788        .style(theme::context_menu_item)
789        .on_press(msg)
790    };
791
792    let content: Element<'a, Message> = match &state.active_tab().context_menu {
793        Some(crate::state::ContextMenu::Branch {
794            name, is_current, ..
795        }) => {
796            let tab = state.active_tab();
797            let remote = tab
798                .remotes
799                .first()
800                .map(|r| r.name.clone())
801                .unwrap_or_else(|| "origin".to_string());
802
803            // Look up the branch tip OID for SHA copy and tag creation.
804            let tip_oid: Option<String> = tab
805                .branches
806                .iter()
807                .find(|b| &b.name == name)
808                .and_then(|b| b.target_oid.clone());
809
810            let header =
811                view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
812
813            let mut col = column![header];
814
815            // Group 1: Checkout (when not on this branch)
816            if !is_current {
817                col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
818            }
819
820            // Group 2: Remote sync
821            let push_label = format!("Push to {remote}");
822            let pull_label = format!("Pull from {remote} (rebase)");
823            col = col
824                .push(menu_item(&push_label, Message::PushBranch(name.clone())))
825                .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
826
827            // Group 3: Rebase / merge
828            col = col.push(view_utils::context_menu_separator::<Message>());
829            let rebase_label = format!("Rebase current onto '{name}'");
830            col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
831            if !is_current {
832                col = col.push(menu_item(
833                    "Merge into current branch",
834                    Message::MergeBranch(name.clone()),
835                ));
836            }
837
838            // Group 4: Branch management
839            col = col.push(view_utils::context_menu_separator::<Message>());
840            col = col
841                .push(menu_item(
842                    "Rename\u{2026}",
843                    Message::BeginRenameBranch(name.clone()),
844                ))
845                .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
846
847            // Group 5: Copy info
848            col = col.push(view_utils::context_menu_separator::<Message>());
849            col = col.push(menu_item(
850                "Copy branch name",
851                Message::CopyText(name.clone()),
852            ));
853            if let Some(ref oid) = tip_oid {
854                col = col.push(menu_item(
855                    "Copy tip commit SHA",
856                    Message::CopyText(oid.clone()),
857                ));
858            }
859
860            // Group 6: Tag creation
861            if tip_oid.is_some() {
862                col = col.push(view_utils::context_menu_separator::<Message>());
863                let oid = tip_oid.clone().unwrap();
864                col = col
865                    .push(menu_item(
866                        "Create tag here",
867                        Message::BeginCreateTag(oid.clone(), false),
868                    ))
869                    .push(menu_item(
870                        "Create annotated tag here\u{2026}",
871                        Message::BeginCreateTag(oid, true),
872                    ));
873            }
874
875            col.into()
876        }
877
878        Some(crate::state::ContextMenu::RemoteBranch { name }) => {
879            // Extract remote and branch parts for display
880            let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
881
882            let header =
883                view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
884
885            // Check if a local branch with the same short name already exists
886            let local_exists =
887                state.active_tab().branches.iter().any(|b| {
888                    b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
889                });
890
891            let mut col = column![header];
892
893            // Checkout (only if no local branch with same name exists)
894            if !local_exists {
895                col = col.push(menu_item(
896                    &format!("Checkout as '{short_name}'"),
897                    Message::CheckoutRemoteBranch(name.clone()),
898                ));
899            }
900
901            // Delete from remote
902            col = col.push(view_utils::context_menu_separator::<Message>());
903            col = col.push(menu_item(
904                &format!("Delete from {remote}"),
905                Message::DeleteRemoteBranch(name.clone()),
906            ));
907
908            // Copy info
909            col = col.push(view_utils::context_menu_separator::<Message>());
910            col = col.push(menu_item(
911                "Copy branch name",
912                Message::CopyText(name.clone()),
913            ));
914            col = col.push(menu_item(
915                &format!("Copy short name '{short_name}'"),
916                Message::CopyText(short_name.to_string()),
917            ));
918
919            // Look up tip OID
920            let tip_oid: Option<String> = state
921                .active_tab()
922                .branches
923                .iter()
924                .find(|b| &b.name == name)
925                .and_then(|b| b.target_oid.clone());
926
927            if let Some(ref oid) = tip_oid {
928                col = col.push(menu_item(
929                    "Copy tip commit SHA",
930                    Message::CopyText(oid.clone()),
931                ));
932            }
933
934            col.into()
935        }
936
937        Some(crate::state::ContextMenu::Commit { index, oid }) => {
938            let tab = state.active_tab();
939            let multi_count = tab.selected_commits.len();
940
941            if multi_count > 1 {
942                // ── Multi-commit ─────────────────────────────────────────────────
943                let header = view_utils::context_menu_header::<Message>(
944                    format!("{} commits selected", multi_count),
945                    c.accent,
946                );
947
948                // Collect OIDs for the selected commits in selection order
949                let oids: Vec<String> = tab
950                    .selected_commits
951                    .iter()
952                    .filter_map(|&i| tab.commits.get(i).map(|c| c.oid.clone()))
953                    .collect();
954
955                let shas_joined = oids
956                    .iter()
957                    .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
958                    .map(|c| c.short_oid.clone())
959                    .collect::<Vec<_>>()
960                    .join("\n");
961
962                let messages_joined = oids
963                    .iter()
964                    .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
965                    .map(|c| c.message.trim().to_string())
966                    .collect::<Vec<_>>()
967                    .join("\n\n");
968
969                let mut col = column![header];
970                col = col.push(menu_item(
971                    &format!("Cherry-pick {} commits", multi_count),
972                    Message::CherryPickCommits(oids.clone()),
973                ));
974                col = col.push(menu_item(
975                    &format!("Revert {} commits", multi_count),
976                    Message::RevertCommits(oids),
977                ));
978                col = col.push(view_utils::context_menu_separator::<Message>());
979                col = col.push(menu_item(
980                    "Copy commit SHAs",
981                    Message::CopyText(shas_joined),
982                ));
983                col = col.push(menu_item(
984                    "Copy commit messages",
985                    Message::CopyText(messages_joined),
986                ));
987                col.into()
988            } else {
989                // ── Single commit ────────────────────────────────────────────
990                let short = gitkraft_core::utils::short_oid_str(oid);
991                let msg_text = tab
992                    .commits
993                    .get(*index)
994                    .map(|c| c.message.clone())
995                    .unwrap_or_default();
996
997                let header =
998                    view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
999
1000                let mut col = column![header];
1001
1002                for (group_idx, group) in gitkraft_core::COMMIT_MENU_GROUPS.iter().enumerate() {
1003                    if group_idx > 0 {
1004                        col = col.push(view_utils::context_menu_separator::<Message>());
1005                    }
1006                    for &kind in *group {
1007                        let msg = match kind.as_simple_action() {
1008                            // No input needed — dispatch directly
1009                            Some(action) => Message::ExecuteCommitAction(oid.clone(), action),
1010                            // Needs input — use the existing Begin* messages
1011                            None => match kind {
1012                                gitkraft_core::CommitActionKind::CreateBranchHere => {
1013                                    Message::BeginCreateBranchAtCommit(oid.clone())
1014                                }
1015                                gitkraft_core::CommitActionKind::CreateTag => {
1016                                    Message::BeginCreateTag(oid.clone(), false)
1017                                }
1018                                gitkraft_core::CommitActionKind::CreateAnnotatedTag => {
1019                                    Message::BeginCreateTag(oid.clone(), true)
1020                                }
1021                                _ => Message::Noop,
1022                            },
1023                        };
1024                        col = col.push(menu_item(kind.label(), msg));
1025                    }
1026                }
1027
1028                // Copy group — metadata, not a git operation
1029                col = col.push(view_utils::context_menu_separator::<Message>());
1030                col = col
1031                    .push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())))
1032                    .push(menu_item(
1033                        "Copy commit message",
1034                        Message::CopyText(msg_text),
1035                    ));
1036
1037                col.into()
1038            }
1039        }
1040
1041        Some(crate::state::ContextMenu::Stash { index }) => {
1042            let index = *index;
1043            let header =
1044                view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
1045
1046            column![
1047                header,
1048                menu_item("View diff", Message::ViewStashDiff(index)),
1049                menu_item("Apply (keep stash)", Message::StashApply(index)),
1050                menu_item("Pop (apply + remove)", Message::StashPop(index)),
1051                view_utils::context_menu_separator::<Message>(),
1052                menu_item("Drop (delete)", Message::StashDrop(index)),
1053            ]
1054            .into()
1055        }
1056
1057        Some(crate::state::ContextMenu::UnstagedFile { path }) => {
1058            let selected_count = state.active_tab().selected_unstaged.len();
1059            let is_multi = selected_count > 1;
1060
1061            let header_text = if is_multi {
1062                format!("{} files selected", selected_count)
1063            } else {
1064                format!("Unstaged: {}", gitkraft_core::path_basename(path))
1065            };
1066            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
1067
1068            let mut col = column![header];
1069
1070            if is_multi {
1071                // Collect selected file paths
1072                let selected_paths: Vec<String> = state
1073                    .active_tab()
1074                    .unstaged_changes
1075                    .iter()
1076                    .filter(|d| {
1077                        state
1078                            .active_tab()
1079                            .selected_unstaged
1080                            .contains(d.display_path())
1081                    })
1082                    .map(|d| d.display_path().to_string())
1083                    .collect();
1084
1085                // Batch operations for multi-select
1086                col = col.push(menu_item(
1087                    &format!("Stage {} file(s)", selected_count),
1088                    Message::StageSelected,
1089                ));
1090                col = col.push(view_utils::context_menu_separator::<Message>());
1091                col = col.push(menu_item(
1092                    &format!("Open {} file(s) in editor", selected_count),
1093                    Message::OpenFilesInEditor(selected_paths.clone()),
1094                ));
1095                col = col.push(menu_item(
1096                    &format!("Open {} file(s)", selected_count),
1097                    Message::OpenFilesInDefaultProgram(selected_paths),
1098                ));
1099                col = col.push(view_utils::context_menu_separator::<Message>());
1100                col = col.push(menu_item(
1101                    &format!("Discard {} file(s)", selected_count),
1102                    Message::DiscardSelected,
1103                ));
1104            } else {
1105                // Single file operations
1106                let diff = state
1107                    .active_tab()
1108                    .unstaged_changes
1109                    .iter()
1110                    .find(|d| d.display_path() == path.as_str())
1111                    .cloned()
1112                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
1113                        old_file: String::new(),
1114                        new_file: path.clone(),
1115                        status: gitkraft_core::FileStatus::Modified,
1116                        hunks: Vec::new(),
1117                    });
1118
1119                col = col.push(menu_item(
1120                    "File History",
1121                    Message::ViewFileHistory(path.clone()),
1122                ));
1123                col = col.push(menu_item(
1124                    "File Blame",
1125                    Message::ViewFileBlame(path.clone()),
1126                ));
1127                col = col.push(view_utils::context_menu_separator::<Message>());
1128                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1129                col = col.push(menu_item(
1130                    "Preview file",
1131                    Message::PreviewFile(path.clone()),
1132                ));
1133                col = col.push(menu_item(
1134                    "Open in editor",
1135                    Message::OpenInEditor(path.clone()),
1136                ));
1137                col = col.push(menu_item(
1138                    "Open file",
1139                    Message::OpenInDefaultProgram(path.clone()),
1140                ));
1141                col = col.push(view_utils::context_menu_separator::<Message>());
1142                col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
1143                col = col.push(view_utils::context_menu_separator::<Message>());
1144                col = col.push(menu_item(
1145                    "Discard changes",
1146                    Message::DiscardFile(path.clone()),
1147                ));
1148            }
1149
1150            col = col.push(view_utils::context_menu_separator::<Message>());
1151            col = col.push(menu_item(
1152                "Copy filename",
1153                Message::CopyText(gitkraft_core::path_basename(path).to_string()),
1154            ));
1155            col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1156            col = col.push(menu_item(
1157                "Show in folder",
1158                Message::ShowInFolder(path.clone()),
1159            ));
1160            col = col.push(view_utils::context_menu_separator::<Message>());
1161            col = col.push(menu_item("Delete file", Message::DeleteFile(path.clone())));
1162
1163            col.into()
1164        }
1165
1166        Some(crate::state::ContextMenu::StagedFile { path }) => {
1167            let selected_count = state.active_tab().selected_staged.len();
1168            let is_multi = selected_count > 1;
1169
1170            let header_text = if is_multi {
1171                format!("{} files selected", selected_count)
1172            } else {
1173                format!("Staged: {}", gitkraft_core::path_basename(path))
1174            };
1175            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
1176
1177            let mut col = column![header];
1178
1179            if is_multi {
1180                let selected_paths: Vec<String> = state
1181                    .active_tab()
1182                    .staged_changes
1183                    .iter()
1184                    .filter(|d| {
1185                        state
1186                            .active_tab()
1187                            .selected_staged
1188                            .contains(d.display_path())
1189                    })
1190                    .map(|d| d.display_path().to_string())
1191                    .collect();
1192
1193                col = col.push(menu_item(
1194                    &format!("Unstage {} file(s)", selected_count),
1195                    Message::UnstageSelected,
1196                ));
1197                col = col.push(view_utils::context_menu_separator::<Message>());
1198                col = col.push(menu_item(
1199                    &format!("Open {} file(s) in editor", selected_count),
1200                    Message::OpenFilesInEditor(selected_paths.clone()),
1201                ));
1202                col = col.push(menu_item(
1203                    &format!("Open {} file(s)", selected_count),
1204                    Message::OpenFilesInDefaultProgram(selected_paths),
1205                ));
1206                col = col.push(view_utils::context_menu_separator::<Message>());
1207                col = col.push(menu_item(
1208                    &format!("Discard {} file(s)", selected_count),
1209                    Message::DiscardSelected,
1210                ));
1211            } else {
1212                let diff = state
1213                    .active_tab()
1214                    .staged_changes
1215                    .iter()
1216                    .find(|d| d.display_path() == path.as_str())
1217                    .cloned()
1218                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
1219                        old_file: String::new(),
1220                        new_file: path.clone(),
1221                        status: gitkraft_core::FileStatus::Modified,
1222                        hunks: Vec::new(),
1223                    });
1224
1225                col = col.push(menu_item(
1226                    "File History",
1227                    Message::ViewFileHistory(path.clone()),
1228                ));
1229                col = col.push(menu_item(
1230                    "File Blame",
1231                    Message::ViewFileBlame(path.clone()),
1232                ));
1233                col = col.push(view_utils::context_menu_separator::<Message>());
1234                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1235                col = col.push(menu_item(
1236                    "Preview file",
1237                    Message::PreviewFile(path.clone()),
1238                ));
1239                col = col.push(menu_item(
1240                    "Open in editor",
1241                    Message::OpenInEditor(path.clone()),
1242                ));
1243                col = col.push(menu_item(
1244                    "Open file",
1245                    Message::OpenInDefaultProgram(path.clone()),
1246                ));
1247                col = col.push(view_utils::context_menu_separator::<Message>());
1248                col = col.push(menu_item(
1249                    "Unstage file",
1250                    Message::UnstageFile(path.clone()),
1251                ));
1252                col = col.push(view_utils::context_menu_separator::<Message>());
1253                col = col.push(menu_item(
1254                    "Discard changes",
1255                    Message::DiscardStagedFile(path.clone()),
1256                ));
1257            }
1258
1259            col = col.push(view_utils::context_menu_separator::<Message>());
1260            col = col.push(menu_item(
1261                "Copy filename",
1262                Message::CopyText(gitkraft_core::path_basename(path).to_string()),
1263            ));
1264            col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1265            col = col.push(menu_item(
1266                "Show in folder",
1267                Message::ShowInFolder(path.clone()),
1268            ));
1269
1270            col.into()
1271        }
1272
1273        Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
1274            let tab = state.active_tab();
1275            let multi_count = tab.selected_commit_file_indices.len();
1276
1277            if multi_count > 1 {
1278                // ── Multi-file ────────────────────────────────────────────────────
1279                let header = view_utils::context_menu_header::<Message>(
1280                    format!("{} files selected", multi_count),
1281                    c.accent,
1282                );
1283
1284                // Collect file paths in selection order
1285                let file_paths: Vec<String> = tab
1286                    .selected_commit_file_indices
1287                    .iter()
1288                    .filter_map(|&i| {
1289                        tab.commit_files
1290                            .get(i)
1291                            .map(|f| f.display_path().to_string())
1292                    })
1293                    .collect();
1294
1295                let paths_joined = file_paths.join("\n");
1296
1297                let mut col = column![header];
1298
1299                // Group 1: actions
1300                col = col.push(menu_item(
1301                    &format!("Diff {} files with working tree", multi_count),
1302                    Message::DiffMultiWithWorkingTree(oid.clone(), file_paths.clone()),
1303                ));
1304                col = col.push(menu_item(
1305                    &format!("Checkout {} files from this commit", multi_count),
1306                    Message::CheckoutMultiFilesAtCommit(oid.clone(), file_paths),
1307                ));
1308
1309                // Group 2: copy
1310                col = col.push(view_utils::context_menu_separator::<Message>());
1311                col = col.push(menu_item(
1312                    "Copy file paths",
1313                    Message::CopyText(paths_joined),
1314                ));
1315                col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1316
1317                col.into()
1318            } else {
1319                // ── Single file ───────────────────────────────────────────────────
1320                let file_name = gitkraft_core::path_basename(file_path);
1321                let header = view_utils::context_menu_header::<Message>(
1322                    format!("File: {}", file_name),
1323                    c.muted,
1324                );
1325
1326                // Group 0: file analysis
1327                let mut col = column![header];
1328                col = col.push(menu_item(
1329                    "File History",
1330                    Message::ViewFileHistory(file_path.clone()),
1331                ));
1332                col = col.push(menu_item(
1333                    "File Blame",
1334                    Message::ViewFileBlame(file_path.clone()),
1335                ));
1336                col = col.push(view_utils::context_menu_separator::<Message>());
1337
1338                // Group 1: file actions
1339                col = col.push(menu_item(
1340                    "Diff with working tree",
1341                    Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
1342                ));
1343                col = col.push(menu_item(
1344                    "Checkout file from this commit",
1345                    Message::CheckoutFileAtCommit(oid.clone(), file_path.clone()),
1346                ));
1347
1348                // Group 2: copy info
1349                col = col.push(view_utils::context_menu_separator::<Message>());
1350                col = col.push(menu_item(
1351                    "Copy filename",
1352                    Message::CopyText(file_name.to_string()),
1353                ));
1354                col = col.push(menu_item(
1355                    "Copy file path",
1356                    Message::CopyText(file_path.clone()),
1357                ));
1358                col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1359
1360                // Group 3: open
1361                col = col.push(view_utils::context_menu_separator::<Message>());
1362                col = col.push(menu_item(
1363                    "Open in editor",
1364                    Message::OpenInEditor(file_path.clone()),
1365                ));
1366                col = col.push(menu_item(
1367                    "Open in default program",
1368                    Message::OpenInDefaultProgram(file_path.clone()),
1369                ));
1370                col = col.push(menu_item(
1371                    "Show in folder",
1372                    Message::ShowInFolder(file_path.clone()),
1373                ));
1374
1375                col.into()
1376            }
1377        }
1378
1379        None => Space::new().into(),
1380    };
1381
1382    container(content)
1383        .width(280)
1384        .style(theme::context_menu_style)
1385        .into()
1386}