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 short = gitkraft_core::utils::short_oid_str(oid);
849            let msg_text = tab
850                .commits
851                .get(*index)
852                .map(|c| c.message.clone())
853                .unwrap_or_default();
854
855            let header =
856                view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
857
858            column![
859                header,
860                menu_item(
861                    "Checkout (detached HEAD)",
862                    Message::CheckoutCommitDetached(oid.clone()),
863                ),
864                menu_item(
865                    "Rebase current branch onto this",
866                    Message::RebaseOntoCommit(oid.clone()),
867                ),
868                menu_item("Revert commit", Message::RevertCommit(oid.clone())),
869                menu_item(
870                    "Reset here — soft (keep staged)",
871                    Message::ResetSoft(oid.clone())
872                ),
873                menu_item(
874                    "Reset here — mixed (keep files)",
875                    Message::ResetMixed(oid.clone())
876                ),
877                menu_item(
878                    "Reset here — hard (discard all)",
879                    Message::ResetHard(oid.clone())
880                ),
881                menu_item("Copy commit SHA", Message::CopyText(oid.clone())),
882                menu_item("Copy commit message", Message::CopyText(msg_text)),
883            ]
884            .into()
885        }
886
887        Some(crate::state::ContextMenu::Stash { index }) => {
888            let index = *index;
889            let header =
890                view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
891
892            column![
893                header,
894                menu_item("View diff", Message::ViewStashDiff(index)),
895                menu_item("Apply (keep stash)", Message::StashApply(index)),
896                menu_item("Pop (apply + remove)", Message::StashPop(index)),
897                view_utils::context_menu_separator::<Message>(),
898                menu_item("Drop (delete)", Message::StashDrop(index)),
899            ]
900            .into()
901        }
902
903        Some(crate::state::ContextMenu::UnstagedFile { path }) => {
904            let selected_count = state.active_tab().selected_unstaged.len();
905            let is_multi = selected_count > 1;
906
907            let header_text = if is_multi {
908                format!("{} files selected", selected_count)
909            } else {
910                format!("Unstaged: {}", path.rsplit('/').next().unwrap_or(path))
911            };
912            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
913
914            let mut col = column![header];
915
916            if is_multi {
917                // Batch operations for multi-select
918                col = col.push(menu_item(
919                    &format!("Stage {} file(s)", selected_count),
920                    Message::StageSelected,
921                ));
922                col = col.push(view_utils::context_menu_separator::<Message>());
923                col = col.push(menu_item(
924                    &format!("Discard {} file(s)", selected_count),
925                    Message::DiscardSelected,
926                ));
927            } else {
928                // Single file operations
929                let diff = state
930                    .active_tab()
931                    .unstaged_changes
932                    .iter()
933                    .find(|d| d.display_path() == path.as_str())
934                    .cloned()
935                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
936                        old_file: String::new(),
937                        new_file: path.clone(),
938                        status: gitkraft_core::FileStatus::Modified,
939                        hunks: Vec::new(),
940                    });
941
942                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
943                col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
944                col = col.push(view_utils::context_menu_separator::<Message>());
945                col = col.push(menu_item(
946                    "Discard changes",
947                    Message::DiscardFile(path.clone()),
948                ));
949            }
950
951            col = col.push(view_utils::context_menu_separator::<Message>());
952            col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
953            col = col.push(menu_item(
954                "Open in editor",
955                Message::OpenInEditor(path.clone()),
956            ));
957            col = col.push(menu_item(
958                "Open in default program",
959                Message::OpenInDefaultProgram(path.clone()),
960            ));
961            col = col.push(menu_item(
962                "Show in folder",
963                Message::ShowInFolder(path.clone()),
964            ));
965
966            col.into()
967        }
968
969        Some(crate::state::ContextMenu::StagedFile { path }) => {
970            let selected_count = state.active_tab().selected_staged.len();
971            let is_multi = selected_count > 1;
972
973            let header_text = if is_multi {
974                format!("{} files selected", selected_count)
975            } else {
976                format!("Staged: {}", path.rsplit('/').next().unwrap_or(path))
977            };
978            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
979
980            let mut col = column![header];
981
982            if is_multi {
983                col = col.push(menu_item(
984                    &format!("Unstage {} file(s)", selected_count),
985                    Message::UnstageSelected,
986                ));
987                col = col.push(view_utils::context_menu_separator::<Message>());
988                col = col.push(menu_item(
989                    &format!("Discard {} file(s)", selected_count),
990                    Message::DiscardSelected,
991                ));
992            } else {
993                let diff = state
994                    .active_tab()
995                    .staged_changes
996                    .iter()
997                    .find(|d| d.display_path() == path.as_str())
998                    .cloned()
999                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
1000                        old_file: String::new(),
1001                        new_file: path.clone(),
1002                        status: gitkraft_core::FileStatus::Modified,
1003                        hunks: Vec::new(),
1004                    });
1005
1006                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1007                col = col.push(menu_item(
1008                    "Unstage file",
1009                    Message::UnstageFile(path.clone()),
1010                ));
1011                col = col.push(view_utils::context_menu_separator::<Message>());
1012                col = col.push(menu_item(
1013                    "Discard changes",
1014                    Message::DiscardStagedFile(path.clone()),
1015                ));
1016            }
1017
1018            col = col.push(view_utils::context_menu_separator::<Message>());
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::CommitFile { oid, file_path }) => {
1037            let file_name = file_path.rsplit('/').next().unwrap_or(file_path);
1038            let header =
1039                view_utils::context_menu_header::<Message>(format!("File: {}", file_name), c.muted);
1040
1041            column![
1042                header,
1043                menu_item(
1044                    "Diff with working tree",
1045                    Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
1046                ),
1047                view_utils::context_menu_separator::<Message>(),
1048                menu_item("Copy file path", Message::CopyText(file_path.clone()),),
1049                menu_item("Copy commit SHA", Message::CopyText(oid.clone()),),
1050                menu_item("Open in editor", Message::OpenInEditor(file_path.clone()),),
1051                menu_item("Show in folder", Message::ShowInFolder(file_path.clone()),),
1052            ]
1053            .into()
1054        }
1055
1056        None => Space::new().into(),
1057    };
1058
1059    container(content)
1060        .width(280)
1061        .style(theme::context_menu_style)
1062        .into()
1063}