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