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