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