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 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 let header = widgets::header::view(self);
67
68 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 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 let diff_viewer = container(features::diff::view::view(self))
109 .width(Length::Fill)
110 .height(Length::Fill);
111
112 let middle = row![sidebar, commit_log, diff_viewer]
114 .height(Length::Fill)
115 .width(Length::Fill);
116
117 let h_divider = widgets::divider::horizontal_divider(DragTargetH::StagingTop, &c);
119
120 let staging = container(features::staging::view::view(self))
122 .width(Length::Fill)
123 .height(Length::Fixed(self.staging_height));
124
125 let status_bar = status_bar_view(self);
127
128 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 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 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 if self.active_tab().context_menu.is_some() {
167 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
192fn 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
271fn 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
298fn context_menu_position(state: &GitKraft) -> (f32, f32) {
300 let (x, y) = state.active_tab().context_menu_pos;
304 ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
305}
306
307fn 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 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 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 let panel: Element<'a, Message> = if has_diff_content {
426 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 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 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 container(left_panel)
654 .width(700)
655 .height(500)
656 .style(theme::context_menu_style)
657 .padding(8)
658 .into()
659 };
660
661 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 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
681fn 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 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 if !is_current {
726 col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
727 }
728
729 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 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 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 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 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 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 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 if !local_exists {
804 col = col.push(menu_item(
805 &format!("Checkout as '{short_name}'"),
806 Message::CheckoutRemoteBranch(name.clone()),
807 ));
808 }
809
810 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 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 let tip_oid: Option<String> = state
830 .active_tab()
831 .branches
832 .iter()
833 .find(|b| &b.name == name)
834 .and_then(|b| b.target_oid.clone());
835
836 if let Some(ref oid) = tip_oid {
837 col = col.push(menu_item(
838 "Copy tip commit SHA",
839 Message::CopyText(oid.clone()),
840 ));
841 }
842
843 col.into()
844 }
845
846 Some(crate::state::ContextMenu::Commit { index, oid }) => {
847 let tab = state.active_tab();
848 let multi_count = tab.selected_commits.len();
849
850 if multi_count > 1 {
851 let header = view_utils::context_menu_header::<Message>(
853 format!("{} commits selected", multi_count),
854 c.accent,
855 );
856
857 let oids: Vec<String> = tab
859 .selected_commits
860 .iter()
861 .filter_map(|&i| tab.commits.get(i).map(|c| c.oid.clone()))
862 .collect();
863
864 let shas_joined = oids
865 .iter()
866 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
867 .map(|c| c.short_oid.clone())
868 .collect::<Vec<_>>()
869 .join("\n");
870
871 let messages_joined = oids
872 .iter()
873 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
874 .map(|c| c.message.trim().to_string())
875 .collect::<Vec<_>>()
876 .join("\n\n");
877
878 let mut col = column![header];
879 col = col.push(menu_item(
880 &format!("Cherry-pick {} commits", multi_count),
881 Message::CherryPickCommits(oids.clone()),
882 ));
883 col = col.push(menu_item(
884 &format!("Revert {} commits", multi_count),
885 Message::RevertCommits(oids),
886 ));
887 col = col.push(view_utils::context_menu_separator::<Message>());
888 col = col.push(menu_item(
889 "Copy commit SHAs",
890 Message::CopyText(shas_joined),
891 ));
892 col = col.push(menu_item(
893 "Copy commit messages",
894 Message::CopyText(messages_joined),
895 ));
896 col.into()
897 } else {
898 let short = gitkraft_core::utils::short_oid_str(oid);
900 let msg_text = tab
901 .commits
902 .get(*index)
903 .map(|c| c.message.clone())
904 .unwrap_or_default();
905
906 let header =
907 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
908
909 let mut col = column![header];
910
911 for (group_idx, group) in gitkraft_core::COMMIT_MENU_GROUPS.iter().enumerate() {
912 if group_idx > 0 {
913 col = col.push(view_utils::context_menu_separator::<Message>());
914 }
915 for &kind in *group {
916 let msg = match kind.as_simple_action() {
917 Some(action) => Message::ExecuteCommitAction(oid.clone(), action),
919 None => match kind {
921 gitkraft_core::CommitActionKind::CreateBranchHere => {
922 Message::BeginCreateBranchAtCommit(oid.clone())
923 }
924 gitkraft_core::CommitActionKind::CreateTag => {
925 Message::BeginCreateTag(oid.clone(), false)
926 }
927 gitkraft_core::CommitActionKind::CreateAnnotatedTag => {
928 Message::BeginCreateTag(oid.clone(), true)
929 }
930 _ => Message::Noop,
931 },
932 };
933 col = col.push(menu_item(kind.label(), msg));
934 }
935 }
936
937 col = col.push(view_utils::context_menu_separator::<Message>());
939 col = col
940 .push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())))
941 .push(menu_item(
942 "Copy commit message",
943 Message::CopyText(msg_text),
944 ));
945
946 col.into()
947 }
948 }
949
950 Some(crate::state::ContextMenu::Stash { index }) => {
951 let index = *index;
952 let header =
953 view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
954
955 column![
956 header,
957 menu_item("View diff", Message::ViewStashDiff(index)),
958 menu_item("Apply (keep stash)", Message::StashApply(index)),
959 menu_item("Pop (apply + remove)", Message::StashPop(index)),
960 view_utils::context_menu_separator::<Message>(),
961 menu_item("Drop (delete)", Message::StashDrop(index)),
962 ]
963 .into()
964 }
965
966 Some(crate::state::ContextMenu::UnstagedFile { path }) => {
967 let selected_count = state.active_tab().selected_unstaged.len();
968 let is_multi = selected_count > 1;
969
970 let header_text = if is_multi {
971 format!("{} files selected", selected_count)
972 } else {
973 format!("Unstaged: {}", path.rsplit('/').next().unwrap_or(path))
974 };
975 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
976
977 let mut col = column![header];
978
979 if is_multi {
980 col = col.push(menu_item(
982 &format!("Stage {} file(s)", selected_count),
983 Message::StageSelected,
984 ));
985 col = col.push(view_utils::context_menu_separator::<Message>());
986 col = col.push(menu_item(
987 &format!("Discard {} file(s)", selected_count),
988 Message::DiscardSelected,
989 ));
990 } else {
991 let diff = state
993 .active_tab()
994 .unstaged_changes
995 .iter()
996 .find(|d| d.display_path() == path.as_str())
997 .cloned()
998 .unwrap_or_else(|| gitkraft_core::DiffInfo {
999 old_file: String::new(),
1000 new_file: path.clone(),
1001 status: gitkraft_core::FileStatus::Modified,
1002 hunks: Vec::new(),
1003 });
1004
1005 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1006 col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
1007 col = col.push(view_utils::context_menu_separator::<Message>());
1008 col = col.push(menu_item(
1009 "Discard changes",
1010 Message::DiscardFile(path.clone()),
1011 ));
1012 }
1013
1014 col = col.push(view_utils::context_menu_separator::<Message>());
1015 col = col.push(menu_item(
1016 "Copy filename",
1017 Message::CopyText(path.rsplit('/').next().unwrap_or(path).to_string()),
1018 ));
1019 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1020 col = col.push(menu_item(
1021 "Open in editor",
1022 Message::OpenInEditor(path.clone()),
1023 ));
1024 col = col.push(menu_item(
1025 "Open in default program",
1026 Message::OpenInDefaultProgram(path.clone()),
1027 ));
1028 col = col.push(menu_item(
1029 "Show in folder",
1030 Message::ShowInFolder(path.clone()),
1031 ));
1032
1033 col.into()
1034 }
1035
1036 Some(crate::state::ContextMenu::StagedFile { path }) => {
1037 let selected_count = state.active_tab().selected_staged.len();
1038 let is_multi = selected_count > 1;
1039
1040 let header_text = if is_multi {
1041 format!("{} files selected", selected_count)
1042 } else {
1043 format!("Staged: {}", path.rsplit('/').next().unwrap_or(path))
1044 };
1045 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
1046
1047 let mut col = column![header];
1048
1049 if is_multi {
1050 col = col.push(menu_item(
1051 &format!("Unstage {} file(s)", selected_count),
1052 Message::UnstageSelected,
1053 ));
1054 col = col.push(view_utils::context_menu_separator::<Message>());
1055 col = col.push(menu_item(
1056 &format!("Discard {} file(s)", selected_count),
1057 Message::DiscardSelected,
1058 ));
1059 } else {
1060 let diff = state
1061 .active_tab()
1062 .staged_changes
1063 .iter()
1064 .find(|d| d.display_path() == path.as_str())
1065 .cloned()
1066 .unwrap_or_else(|| gitkraft_core::DiffInfo {
1067 old_file: String::new(),
1068 new_file: path.clone(),
1069 status: gitkraft_core::FileStatus::Modified,
1070 hunks: Vec::new(),
1071 });
1072
1073 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1074 col = col.push(menu_item(
1075 "Unstage file",
1076 Message::UnstageFile(path.clone()),
1077 ));
1078 col = col.push(view_utils::context_menu_separator::<Message>());
1079 col = col.push(menu_item(
1080 "Discard changes",
1081 Message::DiscardStagedFile(path.clone()),
1082 ));
1083 }
1084
1085 col = col.push(view_utils::context_menu_separator::<Message>());
1086 col = col.push(menu_item(
1087 "Copy filename",
1088 Message::CopyText(path.rsplit('/').next().unwrap_or(path).to_string()),
1089 ));
1090 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1091 col = col.push(menu_item(
1092 "Open in editor",
1093 Message::OpenInEditor(path.clone()),
1094 ));
1095 col = col.push(menu_item(
1096 "Open in default program",
1097 Message::OpenInDefaultProgram(path.clone()),
1098 ));
1099 col = col.push(menu_item(
1100 "Show in folder",
1101 Message::ShowInFolder(path.clone()),
1102 ));
1103
1104 col.into()
1105 }
1106
1107 Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
1108 let tab = state.active_tab();
1109 let multi_count = tab.selected_commit_file_indices.len();
1110
1111 if multi_count > 1 {
1112 let header = view_utils::context_menu_header::<Message>(
1114 format!("{} files selected", multi_count),
1115 c.accent,
1116 );
1117
1118 let file_paths: Vec<String> = tab
1120 .selected_commit_file_indices
1121 .iter()
1122 .filter_map(|&i| {
1123 tab.commit_files
1124 .get(i)
1125 .map(|f| f.display_path().to_string())
1126 })
1127 .collect();
1128
1129 let paths_joined = file_paths.join("\n");
1130
1131 let mut col = column![header];
1132
1133 col = col.push(menu_item(
1135 &format!("Diff {} files with working tree", multi_count),
1136 Message::DiffMultiWithWorkingTree(oid.clone(), file_paths.clone()),
1137 ));
1138 col = col.push(menu_item(
1139 &format!("Checkout {} files from this commit", multi_count),
1140 Message::CheckoutMultiFilesAtCommit(oid.clone(), file_paths),
1141 ));
1142
1143 col = col.push(view_utils::context_menu_separator::<Message>());
1145 col = col.push(menu_item(
1146 "Copy file paths",
1147 Message::CopyText(paths_joined),
1148 ));
1149 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1150
1151 col.into()
1152 } else {
1153 let file_name = file_path.rsplit('/').next().unwrap_or(file_path);
1155 let header = view_utils::context_menu_header::<Message>(
1156 format!("File: {}", file_name),
1157 c.muted,
1158 );
1159
1160 let mut col = column![
1162 header,
1163 menu_item(
1164 "Diff with working tree",
1165 Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
1166 ),
1167 menu_item(
1168 "Checkout file from this commit",
1169 Message::CheckoutFileAtCommit(oid.clone(), file_path.clone()),
1170 ),
1171 ];
1172
1173 col = col.push(view_utils::context_menu_separator::<Message>());
1175 col = col.push(menu_item(
1176 "Copy filename",
1177 Message::CopyText(file_name.to_string()),
1178 ));
1179 col = col.push(menu_item(
1180 "Copy file path",
1181 Message::CopyText(file_path.clone()),
1182 ));
1183 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1184
1185 col = col.push(view_utils::context_menu_separator::<Message>());
1187 col = col.push(menu_item(
1188 "Open in editor",
1189 Message::OpenInEditor(file_path.clone()),
1190 ));
1191 col = col.push(menu_item(
1192 "Open in default program",
1193 Message::OpenInDefaultProgram(file_path.clone()),
1194 ));
1195 col = col.push(menu_item(
1196 "Show in folder",
1197 Message::ShowInFolder(file_path.clone()),
1198 ));
1199
1200 col.into()
1201 }
1202 }
1203
1204 None => Space::new().into(),
1205 };
1206
1207 container(content)
1208 .width(280)
1209 .style(theme::context_menu_style)
1210 .into()
1211}