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 let Some(oid) = tip_oid {
862 col = col.push(view_utils::context_menu_separator::<Message>());
863 col = col
864 .push(menu_item(
865 "Create tag here",
866 Message::BeginCreateTag(oid.clone(), false),
867 ))
868 .push(menu_item(
869 "Create annotated tag here\u{2026}",
870 Message::BeginCreateTag(oid, true),
871 ));
872 }
873
874 col.into()
875 }
876
877 Some(crate::state::ContextMenu::RemoteBranch { name }) => {
878 let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
880
881 let header =
882 view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
883
884 let local_exists =
886 state.active_tab().branches.iter().any(|b| {
887 b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
888 });
889
890 let mut col = column![header];
891
892 if !local_exists {
894 col = col.push(menu_item(
895 &format!("Checkout as '{short_name}'"),
896 Message::CheckoutRemoteBranch(name.clone()),
897 ));
898 }
899
900 col = col.push(view_utils::context_menu_separator::<Message>());
902 col = col.push(menu_item(
903 &format!("Delete from {remote}"),
904 Message::DeleteRemoteBranch(name.clone()),
905 ));
906
907 col = col.push(view_utils::context_menu_separator::<Message>());
909 col = col.push(menu_item(
910 "Copy branch name",
911 Message::CopyText(name.clone()),
912 ));
913 col = col.push(menu_item(
914 &format!("Copy short name '{short_name}'"),
915 Message::CopyText(short_name.to_string()),
916 ));
917
918 let tip_oid: Option<String> = state
920 .active_tab()
921 .branches
922 .iter()
923 .find(|b| &b.name == name)
924 .and_then(|b| b.target_oid.clone());
925
926 if let Some(ref oid) = tip_oid {
927 col = col.push(menu_item(
928 "Copy tip commit SHA",
929 Message::CopyText(oid.clone()),
930 ));
931 }
932
933 col.into()
934 }
935
936 Some(crate::state::ContextMenu::Commit { index, oid }) => {
937 let tab = state.active_tab();
938 let multi_count = tab.selected_commits.len();
939
940 if multi_count > 1 {
941 let header = view_utils::context_menu_header::<Message>(
943 format!("{} commits selected", multi_count),
944 c.accent,
945 );
946
947 let oids: Vec<String> = tab
949 .selected_commits
950 .iter()
951 .filter_map(|&i| tab.commits.get(i).map(|c| c.oid.clone()))
952 .collect();
953
954 let shas_joined = oids
955 .iter()
956 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
957 .map(|c| c.short_oid.clone())
958 .collect::<Vec<_>>()
959 .join("\n");
960
961 let messages_joined = oids
962 .iter()
963 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
964 .map(|c| c.message.trim().to_string())
965 .collect::<Vec<_>>()
966 .join("\n\n");
967
968 let mut col = column![header];
969 col = col.push(menu_item(
970 &format!("Cherry-pick {} commits", multi_count),
971 Message::CherryPickCommits(oids.clone()),
972 ));
973 col = col.push(menu_item(
974 &format!("Revert {} commits", multi_count),
975 Message::RevertCommits(oids),
976 ));
977 col = col.push(view_utils::context_menu_separator::<Message>());
978 col = col.push(menu_item(
979 "Copy commit SHAs",
980 Message::CopyText(shas_joined),
981 ));
982 col = col.push(menu_item(
983 "Copy commit messages",
984 Message::CopyText(messages_joined),
985 ));
986 col.into()
987 } else {
988 let short = gitkraft_core::utils::short_oid_str(oid);
990 let msg_text = tab
991 .commits
992 .get(*index)
993 .map(|c| c.message.clone())
994 .unwrap_or_default();
995
996 let header =
997 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
998
999 let mut col = column![header];
1000
1001 for (group_idx, group) in gitkraft_core::COMMIT_MENU_GROUPS.iter().enumerate() {
1002 if group_idx > 0 {
1003 col = col.push(view_utils::context_menu_separator::<Message>());
1004 }
1005 for &kind in *group {
1006 let msg = match kind.as_simple_action() {
1007 Some(action) => Message::ExecuteCommitAction(oid.clone(), action),
1009 None => match kind {
1011 gitkraft_core::CommitActionKind::CreateBranchHere => {
1012 Message::BeginCreateBranchAtCommit(oid.clone())
1013 }
1014 gitkraft_core::CommitActionKind::CreateTag => {
1015 Message::BeginCreateTag(oid.clone(), false)
1016 }
1017 gitkraft_core::CommitActionKind::CreateAnnotatedTag => {
1018 Message::BeginCreateTag(oid.clone(), true)
1019 }
1020 _ => Message::Noop,
1021 },
1022 };
1023 col = col.push(menu_item(kind.label(), msg));
1024 }
1025 }
1026
1027 col = col.push(view_utils::context_menu_separator::<Message>());
1029 col = col
1030 .push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())))
1031 .push(menu_item(
1032 "Copy commit message",
1033 Message::CopyText(msg_text),
1034 ));
1035
1036 col.into()
1037 }
1038 }
1039
1040 Some(crate::state::ContextMenu::Stash { index }) => {
1041 let index = *index;
1042 let header =
1043 view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
1044
1045 column![
1046 header,
1047 menu_item("View diff", Message::ViewStashDiff(index)),
1048 menu_item("Apply (keep stash)", Message::StashApply(index)),
1049 menu_item("Pop (apply + remove)", Message::StashPop(index)),
1050 view_utils::context_menu_separator::<Message>(),
1051 menu_item("Drop (delete)", Message::StashDrop(index)),
1052 ]
1053 .into()
1054 }
1055
1056 Some(crate::state::ContextMenu::UnstagedFile { path }) => {
1057 let selected_count = state.active_tab().selected_unstaged.len();
1058 let is_multi = selected_count > 1;
1059
1060 let header_text = if is_multi {
1061 format!("{} files selected", selected_count)
1062 } else {
1063 format!("Unstaged: {}", gitkraft_core::path_basename(path))
1064 };
1065 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
1066
1067 let mut col = column![header];
1068
1069 if is_multi {
1070 let selected_paths: Vec<String> = state
1072 .active_tab()
1073 .unstaged_changes
1074 .iter()
1075 .filter(|d| {
1076 state
1077 .active_tab()
1078 .selected_unstaged
1079 .contains(d.display_path())
1080 })
1081 .map(|d| d.display_path().to_string())
1082 .collect();
1083
1084 col = col.push(menu_item(
1086 &format!("Stage {} file(s)", selected_count),
1087 Message::StageSelected,
1088 ));
1089 col = col.push(view_utils::context_menu_separator::<Message>());
1090 col = col.push(menu_item(
1091 &format!("Open {} file(s) in editor", selected_count),
1092 Message::OpenFilesInEditor(selected_paths.clone()),
1093 ));
1094 col = col.push(menu_item(
1095 &format!("Open {} file(s)", selected_count),
1096 Message::OpenFilesInDefaultProgram(selected_paths),
1097 ));
1098 col = col.push(view_utils::context_menu_separator::<Message>());
1099 col = col.push(menu_item(
1100 &format!("Discard {} file(s)", selected_count),
1101 Message::DiscardSelected,
1102 ));
1103 } else {
1104 let diff = state
1106 .active_tab()
1107 .unstaged_changes
1108 .iter()
1109 .find(|d| d.display_path() == path.as_str())
1110 .cloned()
1111 .unwrap_or_else(|| gitkraft_core::DiffInfo {
1112 old_file: String::new(),
1113 new_file: path.clone(),
1114 status: gitkraft_core::FileStatus::Modified,
1115 hunks: Vec::new(),
1116 });
1117
1118 col = col.push(menu_item(
1119 "File History",
1120 Message::ViewFileHistory(path.clone()),
1121 ));
1122 col = col.push(menu_item(
1123 "File Blame",
1124 Message::ViewFileBlame(path.clone()),
1125 ));
1126 col = col.push(view_utils::context_menu_separator::<Message>());
1127 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1128 col = col.push(menu_item(
1129 "Preview file",
1130 Message::PreviewFile(path.clone()),
1131 ));
1132 col = col.push(menu_item(
1133 "Open in editor",
1134 Message::OpenInEditor(path.clone()),
1135 ));
1136 col = col.push(menu_item(
1137 "Open file",
1138 Message::OpenInDefaultProgram(path.clone()),
1139 ));
1140 col = col.push(view_utils::context_menu_separator::<Message>());
1141 col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
1142 col = col.push(view_utils::context_menu_separator::<Message>());
1143 col = col.push(menu_item(
1144 "Discard changes",
1145 Message::DiscardFile(path.clone()),
1146 ));
1147 }
1148
1149 col = col.push(view_utils::context_menu_separator::<Message>());
1150 col = col.push(menu_item(
1151 "Copy filename",
1152 Message::CopyText(gitkraft_core::path_basename(path).to_string()),
1153 ));
1154 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1155 col = col.push(menu_item(
1156 "Show in folder",
1157 Message::ShowInFolder(path.clone()),
1158 ));
1159 col = col.push(view_utils::context_menu_separator::<Message>());
1160 col = col.push(menu_item("Delete file", Message::DeleteFile(path.clone())));
1161
1162 col.into()
1163 }
1164
1165 Some(crate::state::ContextMenu::StagedFile { path }) => {
1166 let selected_count = state.active_tab().selected_staged.len();
1167 let is_multi = selected_count > 1;
1168
1169 let header_text = if is_multi {
1170 format!("{} files selected", selected_count)
1171 } else {
1172 format!("Staged: {}", gitkraft_core::path_basename(path))
1173 };
1174 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
1175
1176 let mut col = column![header];
1177
1178 if is_multi {
1179 let selected_paths: Vec<String> = state
1180 .active_tab()
1181 .staged_changes
1182 .iter()
1183 .filter(|d| {
1184 state
1185 .active_tab()
1186 .selected_staged
1187 .contains(d.display_path())
1188 })
1189 .map(|d| d.display_path().to_string())
1190 .collect();
1191
1192 col = col.push(menu_item(
1193 &format!("Unstage {} file(s)", selected_count),
1194 Message::UnstageSelected,
1195 ));
1196 col = col.push(view_utils::context_menu_separator::<Message>());
1197 col = col.push(menu_item(
1198 &format!("Open {} file(s) in editor", selected_count),
1199 Message::OpenFilesInEditor(selected_paths.clone()),
1200 ));
1201 col = col.push(menu_item(
1202 &format!("Open {} file(s)", selected_count),
1203 Message::OpenFilesInDefaultProgram(selected_paths),
1204 ));
1205 col = col.push(view_utils::context_menu_separator::<Message>());
1206 col = col.push(menu_item(
1207 &format!("Discard {} file(s)", selected_count),
1208 Message::DiscardSelected,
1209 ));
1210 } else {
1211 let diff = state
1212 .active_tab()
1213 .staged_changes
1214 .iter()
1215 .find(|d| d.display_path() == path.as_str())
1216 .cloned()
1217 .unwrap_or_else(|| gitkraft_core::DiffInfo {
1218 old_file: String::new(),
1219 new_file: path.clone(),
1220 status: gitkraft_core::FileStatus::Modified,
1221 hunks: Vec::new(),
1222 });
1223
1224 col = col.push(menu_item(
1225 "File History",
1226 Message::ViewFileHistory(path.clone()),
1227 ));
1228 col = col.push(menu_item(
1229 "File Blame",
1230 Message::ViewFileBlame(path.clone()),
1231 ));
1232 col = col.push(view_utils::context_menu_separator::<Message>());
1233 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
1234 col = col.push(menu_item(
1235 "Preview file",
1236 Message::PreviewFile(path.clone()),
1237 ));
1238 col = col.push(menu_item(
1239 "Open in editor",
1240 Message::OpenInEditor(path.clone()),
1241 ));
1242 col = col.push(menu_item(
1243 "Open file",
1244 Message::OpenInDefaultProgram(path.clone()),
1245 ));
1246 col = col.push(view_utils::context_menu_separator::<Message>());
1247 col = col.push(menu_item(
1248 "Unstage file",
1249 Message::UnstageFile(path.clone()),
1250 ));
1251 col = col.push(view_utils::context_menu_separator::<Message>());
1252 col = col.push(menu_item(
1253 "Discard changes",
1254 Message::DiscardStagedFile(path.clone()),
1255 ));
1256 }
1257
1258 col = col.push(view_utils::context_menu_separator::<Message>());
1259 col = col.push(menu_item(
1260 "Copy filename",
1261 Message::CopyText(gitkraft_core::path_basename(path).to_string()),
1262 ));
1263 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
1264 col = col.push(menu_item(
1265 "Show in folder",
1266 Message::ShowInFolder(path.clone()),
1267 ));
1268
1269 col.into()
1270 }
1271
1272 Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
1273 let tab = state.active_tab();
1274 let multi_count = tab.selected_commit_file_indices.len();
1275
1276 if multi_count > 1 {
1277 let header = view_utils::context_menu_header::<Message>(
1279 format!("{} files selected", multi_count),
1280 c.accent,
1281 );
1282
1283 let file_paths: Vec<String> = tab
1285 .selected_commit_file_indices
1286 .iter()
1287 .filter_map(|&i| {
1288 tab.commit_files
1289 .get(i)
1290 .map(|f| f.display_path().to_string())
1291 })
1292 .collect();
1293
1294 let paths_joined = file_paths.join("\n");
1295
1296 let mut col = column![header];
1297
1298 col = col.push(menu_item(
1300 &format!("Diff {} files with working tree", multi_count),
1301 Message::DiffMultiWithWorkingTree(oid.clone(), file_paths.clone()),
1302 ));
1303 col = col.push(menu_item(
1304 &format!("Checkout {} files from this commit", multi_count),
1305 Message::CheckoutMultiFilesAtCommit(oid.clone(), file_paths),
1306 ));
1307
1308 col = col.push(view_utils::context_menu_separator::<Message>());
1310 col = col.push(menu_item(
1311 "Copy file paths",
1312 Message::CopyText(paths_joined),
1313 ));
1314 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1315
1316 col.into()
1317 } else {
1318 let file_name = gitkraft_core::path_basename(file_path);
1320 let header = view_utils::context_menu_header::<Message>(
1321 format!("File: {}", file_name),
1322 c.muted,
1323 );
1324
1325 let mut col = column![header];
1327 col = col.push(menu_item(
1328 "File History",
1329 Message::ViewFileHistory(file_path.clone()),
1330 ));
1331 col = col.push(menu_item(
1332 "File Blame",
1333 Message::ViewFileBlame(file_path.clone()),
1334 ));
1335 col = col.push(view_utils::context_menu_separator::<Message>());
1336
1337 col = col.push(menu_item(
1339 "Diff with working tree",
1340 Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
1341 ));
1342 col = col.push(menu_item(
1343 "Checkout file from this commit",
1344 Message::CheckoutFileAtCommit(oid.clone(), file_path.clone()),
1345 ));
1346
1347 col = col.push(view_utils::context_menu_separator::<Message>());
1349 col = col.push(menu_item(
1350 "Copy filename",
1351 Message::CopyText(file_name.to_string()),
1352 ));
1353 col = col.push(menu_item(
1354 "Copy file path",
1355 Message::CopyText(file_path.clone()),
1356 ));
1357 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1358
1359 col = col.push(view_utils::context_menu_separator::<Message>());
1361 col = col.push(menu_item(
1362 "Open in editor",
1363 Message::OpenInEditor(file_path.clone()),
1364 ));
1365 col = col.push(menu_item(
1366 "Open in default program",
1367 Message::OpenInDefaultProgram(file_path.clone()),
1368 ));
1369 col = col.push(menu_item(
1370 "Show in folder",
1371 Message::ShowInFolder(file_path.clone()),
1372 ));
1373
1374 col.into()
1375 }
1376 }
1377
1378 None => Space::new().into(),
1379 };
1380
1381 container(content)
1382 .width(280)
1383 .style(theme::context_menu_style)
1384 .into()
1385}