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