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