Skip to main content

gitkraft_gui/
view.rs

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