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