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