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