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.
308fn search_overlay<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
309    use iced::widget::{
310        button, column, container, mouse_area, row, scrollable, text, text_input, Space,
311    };
312    use iced::{Alignment, Length};
313
314    let input = text_input("Search commits…", &state.search_query)
315        .on_input(Message::SearchQueryChanged)
316        .on_submit(Message::ConfirmSearchResult)
317        .padding(10)
318        .size(16);
319
320    let mut results_col = column![].spacing(2).width(Length::Fill);
321
322    if state.search_results.is_empty() && state.search_query.len() >= 2 {
323        results_col = results_col.push(
324            container(text("No results found").size(13).color(c.muted))
325                .padding([12, 8])
326                .width(Length::Fill)
327                .center_x(Length::Fill),
328        );
329    }
330
331    for (i, commit) in state.search_results.iter().take(50).enumerate() {
332        let is_selected = state.search_selected == Some(i);
333        let bg_style = if is_selected {
334            theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
335        } else {
336            theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
337        };
338
339        let oid_label = text(&commit.short_oid)
340            .size(12)
341            .color(c.accent)
342            .font(iced::Font::MONOSPACE);
343
344        let summary_label = text(&commit.summary).size(13).color(c.text_primary);
345
346        let author_label = text(&commit.author_name).size(11).color(c.text_secondary);
347
348        let time_label = text(commit.relative_time()).size(11).color(c.muted);
349
350        let row_content = row![
351            oid_label,
352            Space::new().width(8),
353            summary_label,
354            Space::new().width(Length::Fill),
355            author_label,
356            Space::new().width(8),
357            time_label,
358        ]
359        .align_y(Alignment::Center)
360        .padding([6, 10]);
361
362        let result_btn = button(row_content)
363            .padding(0)
364            .width(Length::Fill)
365            .style(theme::ghost_button)
366            .on_press(Message::ConfirmSearchResult);
367
368        let result_row: Element<'a, Message> =
369            mouse_area(container(result_btn).width(Length::Fill).style(bg_style))
370                .on_press(Message::SelectSearchResult(i))
371                .on_right_press(Message::OpenSearchResultContextMenu(i))
372                .into();
373
374        results_col = results_col.push(result_row);
375    }
376
377    let result_count = if !state.search_results.is_empty() {
378        text(format!("{} result(s)", state.search_results.len()))
379            .size(11)
380            .color(c.muted)
381    } else {
382        text("").size(1)
383    };
384
385    let close_btn = button(text("\u{2715}").size(14).color(c.text_secondary))
386        .padding([4, 8])
387        .style(theme::ghost_button)
388        .on_press(Message::ToggleSearch);
389
390    let header = row![
391        icon!(icons::CLOCK_HISTORY, 16, c.accent),
392        Space::new().width(8),
393        text("Search Commits").size(16).color(c.text_primary),
394        Space::new().width(Length::Fill),
395        result_count,
396        Space::new().width(8),
397        close_btn,
398    ]
399    .align_y(Alignment::Center)
400    .padding([8, 12]);
401
402    let scrollable_results = scrollable(results_col)
403        .height(Length::Fill)
404        .direction(crate::view_utils::thin_scrollbar())
405        .style(crate::theme::overlay_scrollbar);
406
407    let panel = container(
408        column![header, input, scrollable_results,]
409            .width(Length::Fill)
410            .height(Length::Fill)
411            .spacing(4),
412    )
413    .width(700)
414    .height(500)
415    .style(theme::context_menu_style)
416    .padding(8);
417
418    // Center the panel on screen with a backdrop
419    let backdrop = mouse_area(
420        container(Space::new().width(Length::Fill).height(Length::Fill))
421            .style(theme::backdrop_style),
422    )
423    .on_press(Message::ToggleSearch);
424
425    let centered = container(panel)
426        .width(Length::Fill)
427        .height(Length::Fill)
428        .center_x(Length::Fill)
429        .center_y(Length::Fill);
430
431    iced::widget::stack![backdrop, centered].into()
432}
433
434/// Build the context menu panel widget for the currently active menu.
435fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
436    use iced::widget::{button, column, container, row, text, Space};
437    use iced::{Alignment, Length};
438
439    let text_primary = c.text_primary;
440    let menu_item = move |label: &str, msg: Message| {
441        button(
442            row![
443                Space::new().width(4),
444                text(label.to_string()).size(13).color(text_primary),
445            ]
446            .align_y(Alignment::Center),
447        )
448        .padding([7, 12])
449        .width(Length::Fill)
450        .style(theme::context_menu_item)
451        .on_press(msg)
452    };
453
454    let content: Element<'a, Message> = match &state.active_tab().context_menu {
455        Some(crate::state::ContextMenu::Branch {
456            name, is_current, ..
457        }) => {
458            let tab = state.active_tab();
459            let remote = tab
460                .remotes
461                .first()
462                .map(|r| r.name.clone())
463                .unwrap_or_else(|| "origin".to_string());
464
465            // Look up the branch tip OID for SHA copy and tag creation.
466            let tip_oid: Option<String> = tab
467                .branches
468                .iter()
469                .find(|b| &b.name == name)
470                .and_then(|b| b.target_oid.clone());
471
472            let header =
473                view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
474
475            let mut col = column![header];
476
477            // Group 1: Checkout (when not on this branch)
478            if !is_current {
479                col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
480            }
481
482            // Group 2: Remote sync
483            let push_label = format!("Push to {remote}");
484            let pull_label = format!("Pull from {remote} (rebase)");
485            col = col
486                .push(menu_item(&push_label, Message::PushBranch(name.clone())))
487                .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
488
489            // Group 3: Rebase / merge
490            col = col.push(view_utils::context_menu_separator::<Message>());
491            let rebase_label = format!("Rebase current onto '{name}'");
492            col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
493            if !is_current {
494                col = col.push(menu_item(
495                    "Merge into current branch",
496                    Message::MergeBranch(name.clone()),
497                ));
498            }
499
500            // Group 4: Branch management
501            col = col.push(view_utils::context_menu_separator::<Message>());
502            col = col
503                .push(menu_item(
504                    "Rename\u{2026}",
505                    Message::BeginRenameBranch(name.clone()),
506                ))
507                .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
508
509            // Group 5: Copy info
510            col = col.push(view_utils::context_menu_separator::<Message>());
511            col = col.push(menu_item(
512                "Copy branch name",
513                Message::CopyText(name.clone()),
514            ));
515            if let Some(ref oid) = tip_oid {
516                col = col.push(menu_item(
517                    "Copy tip commit SHA",
518                    Message::CopyText(oid.clone()),
519                ));
520            }
521
522            // Group 6: Tag creation
523            if tip_oid.is_some() {
524                col = col.push(view_utils::context_menu_separator::<Message>());
525                let oid = tip_oid.clone().unwrap();
526                col = col
527                    .push(menu_item(
528                        "Create tag here",
529                        Message::BeginCreateTag(oid.clone(), false),
530                    ))
531                    .push(menu_item(
532                        "Create annotated tag here\u{2026}",
533                        Message::BeginCreateTag(oid, true),
534                    ));
535            }
536
537            col.into()
538        }
539
540        Some(crate::state::ContextMenu::RemoteBranch { name }) => {
541            // Extract remote and branch parts for display
542            let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
543
544            let header =
545                view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
546
547            // Check if a local branch with the same short name already exists
548            let local_exists =
549                state.active_tab().branches.iter().any(|b| {
550                    b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
551                });
552
553            let mut col = column![header];
554
555            // Checkout (only if no local branch with same name exists)
556            if !local_exists {
557                col = col.push(menu_item(
558                    &format!("Checkout as '{short_name}'"),
559                    Message::CheckoutRemoteBranch(name.clone()),
560                ));
561            }
562
563            // Delete from remote
564            col = col.push(view_utils::context_menu_separator::<Message>());
565            col = col.push(menu_item(
566                &format!("Delete from {remote}"),
567                Message::DeleteRemoteBranch(name.clone()),
568            ));
569
570            // Copy info
571            col = col.push(view_utils::context_menu_separator::<Message>());
572            col = col.push(menu_item(
573                "Copy branch name",
574                Message::CopyText(name.clone()),
575            ));
576            col = col.push(menu_item(
577                &format!("Copy short name '{short_name}'"),
578                Message::CopyText(short_name.to_string()),
579            ));
580
581            // Look up tip OID
582            let tip_oid: Option<String> = state
583                .active_tab()
584                .branches
585                .iter()
586                .find(|b| &b.name == name)
587                .and_then(|b| b.target_oid.clone());
588
589            if let Some(ref oid) = tip_oid {
590                col = col.push(menu_item(
591                    "Copy tip commit SHA",
592                    Message::CopyText(oid.clone()),
593                ));
594            }
595
596            col.into()
597        }
598
599        Some(crate::state::ContextMenu::Commit { index, oid }) => {
600            let tab = state.active_tab();
601            let short = gitkraft_core::utils::short_oid_str(oid);
602            let msg_text = tab
603                .commits
604                .get(*index)
605                .map(|c| c.message.clone())
606                .unwrap_or_default();
607
608            let header =
609                view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
610
611            column![
612                header,
613                menu_item(
614                    "Checkout (detached HEAD)",
615                    Message::CheckoutCommitDetached(oid.clone()),
616                ),
617                menu_item(
618                    "Rebase current branch onto this",
619                    Message::RebaseOntoCommit(oid.clone()),
620                ),
621                menu_item("Revert commit", Message::RevertCommit(oid.clone())),
622                menu_item(
623                    "Reset here — soft (keep staged)",
624                    Message::ResetSoft(oid.clone())
625                ),
626                menu_item(
627                    "Reset here — mixed (keep files)",
628                    Message::ResetMixed(oid.clone())
629                ),
630                menu_item(
631                    "Reset here — hard (discard all)",
632                    Message::ResetHard(oid.clone())
633                ),
634                menu_item("Copy commit SHA", Message::CopyText(oid.clone())),
635                menu_item("Copy commit message", Message::CopyText(msg_text)),
636            ]
637            .into()
638        }
639
640        Some(crate::state::ContextMenu::Stash { index }) => {
641            let index = *index;
642            let header =
643                view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
644
645            column![
646                header,
647                menu_item("View diff", Message::ViewStashDiff(index)),
648                menu_item("Apply (keep stash)", Message::StashApply(index)),
649                menu_item("Pop (apply + remove)", Message::StashPop(index)),
650                view_utils::context_menu_separator::<Message>(),
651                menu_item("Drop (delete)", Message::StashDrop(index)),
652            ]
653            .into()
654        }
655
656        Some(crate::state::ContextMenu::UnstagedFile { path }) => {
657            let selected_count = state.active_tab().selected_unstaged.len();
658            let is_multi = selected_count > 1;
659
660            let header_text = if is_multi {
661                format!("{} files selected", selected_count)
662            } else {
663                format!("Unstaged: {}", path.rsplit('/').next().unwrap_or(path))
664            };
665            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
666
667            let mut col = column![header];
668
669            if is_multi {
670                // Batch operations for multi-select
671                col = col.push(menu_item(
672                    &format!("Stage {} file(s)", selected_count),
673                    Message::StageSelected,
674                ));
675                col = col.push(view_utils::context_menu_separator::<Message>());
676                col = col.push(menu_item(
677                    &format!("Discard {} file(s)", selected_count),
678                    Message::DiscardSelected,
679                ));
680            } else {
681                // Single file operations
682                let diff = state
683                    .active_tab()
684                    .unstaged_changes
685                    .iter()
686                    .find(|d| d.display_path() == path.as_str())
687                    .cloned()
688                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
689                        old_file: String::new(),
690                        new_file: path.clone(),
691                        status: gitkraft_core::FileStatus::Modified,
692                        hunks: Vec::new(),
693                    });
694
695                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
696                col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
697                col = col.push(view_utils::context_menu_separator::<Message>());
698                col = col.push(menu_item(
699                    "Discard changes",
700                    Message::DiscardFile(path.clone()),
701                ));
702            }
703
704            col = col.push(view_utils::context_menu_separator::<Message>());
705            col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
706            col = col.push(menu_item(
707                "Open in editor",
708                Message::OpenInEditor(path.clone()),
709            ));
710            col = col.push(menu_item(
711                "Open in default program",
712                Message::OpenInDefaultProgram(path.clone()),
713            ));
714            col = col.push(menu_item(
715                "Show in folder",
716                Message::ShowInFolder(path.clone()),
717            ));
718
719            col.into()
720        }
721
722        Some(crate::state::ContextMenu::StagedFile { path }) => {
723            let selected_count = state.active_tab().selected_staged.len();
724            let is_multi = selected_count > 1;
725
726            let header_text = if is_multi {
727                format!("{} files selected", selected_count)
728            } else {
729                format!("Staged: {}", path.rsplit('/').next().unwrap_or(path))
730            };
731            let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
732
733            let mut col = column![header];
734
735            if is_multi {
736                col = col.push(menu_item(
737                    &format!("Unstage {} file(s)", selected_count),
738                    Message::UnstageSelected,
739                ));
740                col = col.push(view_utils::context_menu_separator::<Message>());
741                col = col.push(menu_item(
742                    &format!("Discard {} file(s)", selected_count),
743                    Message::DiscardSelected,
744                ));
745            } else {
746                let diff = state
747                    .active_tab()
748                    .staged_changes
749                    .iter()
750                    .find(|d| d.display_path() == path.as_str())
751                    .cloned()
752                    .unwrap_or_else(|| gitkraft_core::DiffInfo {
753                        old_file: String::new(),
754                        new_file: path.clone(),
755                        status: gitkraft_core::FileStatus::Modified,
756                        hunks: Vec::new(),
757                    });
758
759                col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
760                col = col.push(menu_item(
761                    "Unstage file",
762                    Message::UnstageFile(path.clone()),
763                ));
764                col = col.push(view_utils::context_menu_separator::<Message>());
765                col = col.push(menu_item(
766                    "Discard changes",
767                    Message::DiscardStagedFile(path.clone()),
768                ));
769            }
770
771            col = col.push(view_utils::context_menu_separator::<Message>());
772            col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
773            col = col.push(menu_item(
774                "Open in editor",
775                Message::OpenInEditor(path.clone()),
776            ));
777            col = col.push(menu_item(
778                "Open in default program",
779                Message::OpenInDefaultProgram(path.clone()),
780            ));
781            col = col.push(menu_item(
782                "Show in folder",
783                Message::ShowInFolder(path.clone()),
784            ));
785
786            col.into()
787        }
788
789        Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
790            let file_name = file_path.rsplit('/').next().unwrap_or(file_path);
791            let header =
792                view_utils::context_menu_header::<Message>(format!("File: {}", file_name), c.muted);
793
794            column![
795                header,
796                menu_item(
797                    "Diff with working tree",
798                    Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
799                ),
800                view_utils::context_menu_separator::<Message>(),
801                menu_item("Copy file path", Message::CopyText(file_path.clone()),),
802                menu_item("Copy commit SHA", Message::CopyText(oid.clone()),),
803                menu_item("Open in editor", Message::OpenInEditor(file_path.clone()),),
804                menu_item("Show in folder", Message::ShowInFolder(file_path.clone()),),
805            ]
806            .into()
807        }
808
809        None => Space::new().into(),
810    };
811
812    container(content)
813        .width(280)
814        .style(theme::context_menu_style)
815        .into()
816}