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 = if is_diffed {
436 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
437 } else if is_selected {
438 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
439 } else {
440 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
441 };
442
443 let oid_label = text(&commit.short_oid)
444 .size(12)
445 .color(c.accent)
446 .font(iced::Font::MONOSPACE);
447
448 let summary_label = text(&commit.summary).size(13).color(c.text_primary);
449
450 let author_label = text(&commit.author_name).size(11).color(c.text_secondary);
451
452 let time_label = text(commit.relative_time()).size(11).color(c.muted);
453
454 let row_content = row![
455 oid_label,
456 Space::new().width(8),
457 summary_label,
458 Space::new().width(Length::Fill),
459 author_label,
460 Space::new().width(8),
461 time_label,
462 ]
463 .align_y(Alignment::Center)
464 .padding([6, 10]);
465
466 let result_btn = button(row_content)
467 .padding(0)
468 .width(Length::Fill)
469 .style(theme::ghost_button)
470 .on_press(Message::ConfirmSearchResult);
471
472 let result_row: Element<'a, Message> =
473 mouse_area(container(result_btn).width(Length::Fill).style(bg_style))
474 .on_press(Message::SelectSearchResult(i))
475 .on_right_press(Message::OpenSearchResultContextMenu(i))
476 .into();
477
478 results_col = results_col.push(result_row);
479 }
480
481 let result_count = if !state.search_results.is_empty() {
482 text(format!("{} result(s)", state.search_results.len()))
483 .size(11)
484 .color(c.muted)
485 } else {
486 text("").size(1)
487 };
488
489 let left_header = row![
490 icon!(icons::CLOCK_HISTORY, 16, c.accent),
491 Space::new().width(8),
492 text("Search Commits").size(16).color(c.text_primary),
493 Space::new().width(Length::Fill),
494 result_count,
495 Space::new().width(8),
496 close_btn,
497 ]
498 .align_y(Alignment::Center)
499 .padding([8, 12]);
500
501 let scrollable_results = scrollable(results_col)
502 .height(Length::Fill)
503 .direction(crate::view_utils::thin_scrollbar())
504 .style(crate::theme::overlay_scrollbar);
505
506 let left_panel = column![left_header, input, scrollable_results]
507 .width(Length::Fill)
508 .height(Length::Fill)
509 .spacing(4);
510
511 let panel: Element<'a, Message> = if has_diff_content {
513 let file_count = state.search_diff_content.len();
515 let title_label = if file_count == 1 {
516 state.search_diff_content[0].display_path().to_string()
517 } else {
518 format!("{file_count} file(s)")
519 };
520
521 let back_btn = button(
522 row![
523 text("← ").size(14).color(c.accent),
524 text("Back to file list").size(13).color(c.text_primary),
525 ]
526 .align_y(Alignment::Center),
527 )
528 .padding([6, 12])
529 .style(theme::ghost_button)
530 .on_press(Message::SearchDiffBack);
531
532 let close_btn2 = button(text("\u{2715}").size(14).color(c.text_secondary))
533 .padding([4, 8])
534 .style(theme::ghost_button)
535 .on_press(Message::ToggleSearch);
536
537 let diff_header = row![
538 back_btn,
539 Space::new().width(Length::Fill),
540 text(title_label).size(13).color(c.accent),
541 Space::new().width(8),
542 close_btn2,
543 ]
544 .align_y(Alignment::Center)
545 .padding([4, 8]);
546
547 let mut diff_lines_col = column![].spacing(0).width(Length::Fill);
548 for diff in &state.search_diff_content {
549 let status_color = match diff.status.color_category() {
551 gitkraft_core::StatusColorCategory::Added => c.green,
552 gitkraft_core::StatusColorCategory::Modified => c.yellow,
553 gitkraft_core::StatusColorCategory::Deleted => c.red,
554 gitkraft_core::StatusColorCategory::Renamed => c.accent,
555 };
556 if file_count > 1 {
557 diff_lines_col = diff_lines_col.push(
558 container(
559 row![
560 text(format!("{}", diff.status))
561 .size(12)
562 .color(status_color)
563 .font(iced::Font::MONOSPACE),
564 Space::new().width(8),
565 text(diff.display_path()).size(13).color(c.text_primary),
566 ]
567 .align_y(Alignment::Center),
568 )
569 .padding([6, 8])
570 .width(Length::Fill)
571 .style(theme::surface_style),
572 );
573 }
574 for hunk in &diff.hunks {
575 for line in &hunk.lines {
576 let (prefix, content, color) = match line {
577 gitkraft_core::DiffLine::Context(s) => (" ", s.as_str(), c.diff_context),
578 gitkraft_core::DiffLine::Addition(s) => ("+", s.as_str(), c.green),
579 gitkraft_core::DiffLine::Deletion(s) => ("-", s.as_str(), c.red),
580 gitkraft_core::DiffLine::HunkHeader(s) => ("@@", s.as_str(), c.accent),
581 };
582 diff_lines_col = diff_lines_col.push(
583 text(format!("{prefix} {content}"))
584 .size(12)
585 .color(color)
586 .font(iced::Font::MONOSPACE),
587 );
588 }
589 }
590 }
591
592 let scrollable_diff = scrollable(
593 container(diff_lines_col)
594 .padding([4, 8])
595 .width(Length::Fill),
596 )
597 .height(Length::Fill)
598 .direction(crate::view_utils::thin_scrollbar())
599 .style(crate::theme::overlay_scrollbar);
600
601 let right_panel = column![diff_header, scrollable_diff]
602 .width(Length::Fill)
603 .height(Length::Fill)
604 .spacing(4);
605
606 let content = row![
607 container(left_panel).width(Length::FillPortion(2)),
608 container(right_panel).width(Length::FillPortion(3)),
609 ]
610 .spacing(4)
611 .width(Length::Fill)
612 .height(Length::Fill);
613
614 container(content)
615 .width(1100)
616 .height(600)
617 .style(theme::context_menu_style)
618 .padding(8)
619 .into()
620 } else if has_diff_files {
621 let oid_short = state
623 .search_diff_oid
624 .as_ref()
625 .map(|o| &o[..7.min(o.len())])
626 .unwrap_or("???");
627
628 let file_count = state.search_diff_files.len();
629 let selected_count = state.search_diff_selected.len();
630
631 let select_all_label = if selected_count == file_count {
632 "Deselect All"
633 } else {
634 "Select All"
635 };
636
637 let select_all_btn = button(text(select_all_label).size(12).color(c.accent))
638 .padding([4, 8])
639 .style(theme::ghost_button)
640 .on_press(Message::ToggleSearchDiffSelectAll);
641
642 let diff_selected_btn: Element<'a, Message> = if selected_count > 0 {
643 button(
644 text(format!("Diff Selected ({selected_count})"))
645 .size(12)
646 .color(c.green),
647 )
648 .padding([4, 8])
649 .style(theme::ghost_button)
650 .on_press(Message::DiffSelectedFiles)
651 .into()
652 } else {
653 Space::new().width(0).into()
654 };
655
656 let close_btn3 = button(text("\u{2715}").size(14).color(c.text_secondary))
657 .padding([4, 8])
658 .style(theme::ghost_button)
659 .on_press(Message::ToggleSearch);
660
661 let right_header = row![
662 text(format!("Files changed vs working tree ({oid_short})"))
663 .size(14)
664 .color(c.text_primary),
665 Space::new().width(Length::Fill),
666 text(format!("{file_count} file(s)"))
667 .size(11)
668 .color(c.muted),
669 Space::new().width(8),
670 diff_selected_btn,
671 Space::new().width(4),
672 select_all_btn,
673 Space::new().width(4),
674 close_btn3,
675 ]
676 .align_y(Alignment::Center)
677 .padding([8, 12]);
678
679 let mut files_col = column![].spacing(2).width(Length::Fill);
680
681 for (i, file) in state.search_diff_files.iter().enumerate() {
682 let is_checked = state.search_diff_selected.contains(&i);
683 let status_str = format!("{}", file.status);
684 let status_color = match file.status.color_category() {
685 gitkraft_core::StatusColorCategory::Added => c.green,
686 gitkraft_core::StatusColorCategory::Modified => c.yellow,
687 gitkraft_core::StatusColorCategory::Deleted => c.red,
688 gitkraft_core::StatusColorCategory::Renamed => c.accent,
689 };
690
691 let file_row = button(
692 row![
693 checkbox(is_checked).on_toggle(move |_| Message::ToggleSearchDiffFile(i)),
694 Space::new().width(4),
695 text(status_str)
696 .size(12)
697 .color(status_color)
698 .font(iced::Font::MONOSPACE),
699 Space::new().width(8),
700 text(file.display_path()).size(13).color(c.text_primary),
701 Space::new().width(Length::Fill),
702 ]
703 .align_y(Alignment::Center)
704 .padding([4, 8]),
705 )
706 .padding(0)
707 .width(Length::Fill)
708 .style(theme::ghost_button)
709 .on_press(Message::ViewSearchDiffFile(i));
710
711 files_col = files_col.push(file_row);
712 }
713
714 let scrollable_files = scrollable(files_col)
715 .height(Length::Fill)
716 .direction(crate::view_utils::thin_scrollbar())
717 .style(crate::theme::overlay_scrollbar);
718
719 let right_panel = column![right_header, scrollable_files]
720 .width(Length::Fill)
721 .height(Length::Fill)
722 .spacing(4);
723
724 let content = row![
725 container(left_panel).width(Length::FillPortion(2)),
726 container(right_panel).width(Length::FillPortion(3)),
727 ]
728 .spacing(4)
729 .width(Length::Fill)
730 .height(Length::Fill);
731
732 container(content)
733 .width(1100)
734 .height(600)
735 .style(theme::context_menu_style)
736 .padding(8)
737 .into()
738 } else {
739 container(left_panel)
741 .width(700)
742 .height(500)
743 .style(theme::context_menu_style)
744 .padding(8)
745 .into()
746 };
747
748 let backdrop = mouse_area(
750 container(Space::new().width(Length::Fill).height(Length::Fill))
751 .style(theme::backdrop_style),
752 )
753 .on_press(Message::ToggleSearch);
754
755 let panel_intercepted = mouse_area(panel).on_press(Message::Noop);
758
759 let centered = container(panel_intercepted)
760 .width(Length::Fill)
761 .height(Length::Fill)
762 .center_x(Length::Fill)
763 .center_y(Length::Fill);
764
765 iced::widget::stack![backdrop, centered].into()
766}
767
768fn file_context_menu_items<'a>(
773 state: &'a GitKraft,
774 c: &ThemeColors,
775 path: &str,
776 staged: bool,
777 menu_item: impl Fn(&str, Message) -> iced::widget::Button<'a, Message>,
778) -> iced::widget::Column<'a, Message> {
779 use iced::widget::column;
780
781 let tab = state.active_tab();
782
783 let (selected_count, changes, selected) = if staged {
784 (
785 tab.selected_staged.len(),
786 &tab.staged_changes,
787 &tab.selected_staged,
788 )
789 } else {
790 (
791 tab.selected_unstaged.len(),
792 &tab.unstaged_changes,
793 &tab.selected_unstaged,
794 )
795 };
796
797 let is_multi = selected_count > 1;
798
799 let header_text = if is_multi {
800 format!("{} files selected", selected_count)
801 } else {
802 let prefix = if staged { "Staged" } else { "Unstaged" };
803 format!("{}: {}", prefix, gitkraft_core::path_basename(path))
804 };
805 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
806
807 let mut col = column![header];
808
809 if is_multi {
810 let selected_paths: Vec<String> = changes
812 .iter()
813 .filter(|d| selected.contains(d.display_path()))
814 .map(|d| d.display_path().to_string())
815 .collect();
816
817 let action_label = if staged { "Unstage" } else { "Stage" };
818 let action_msg = if staged {
819 Message::UnstageSelected
820 } else {
821 Message::StageSelected
822 };
823
824 col = col.push(menu_item(
826 &format!("{} {} file(s)", action_label, selected_count),
827 action_msg,
828 ));
829 col = col.push(view_utils::context_menu_separator::<Message>());
830 col = col.push(menu_item(
831 &format!("Open {} file(s) in editor", selected_count),
832 Message::OpenFilesInEditor(selected_paths.clone()),
833 ));
834 col = col.push(menu_item(
835 &format!("Open {} file(s)", selected_count),
836 Message::OpenFilesInDefaultProgram(selected_paths),
837 ));
838 col = col.push(view_utils::context_menu_separator::<Message>());
839 col = col.push(menu_item(
840 &format!("Discard {} file(s)", selected_count),
841 Message::DiscardSelected,
842 ));
843 } else {
844 col = col.push(menu_item(
846 "File History",
847 Message::ViewFileHistory(path.to_string()),
848 ));
849 col = col.push(menu_item(
850 "File Blame",
851 Message::ViewFileBlame(path.to_string()),
852 ));
853 col = col.push(view_utils::context_menu_separator::<Message>());
854 col = col.push(menu_item(
855 "View diff",
856 Message::LoadStagingFileDiff(path.to_string(), staged),
857 ));
858 col = col.push(menu_item(
859 "Preview file",
860 Message::PreviewFile(path.to_string()),
861 ));
862 col = col.push(menu_item(
863 "Open in editor",
864 Message::OpenInEditor(path.to_string()),
865 ));
866 col = col.push(menu_item(
867 "Open file",
868 Message::OpenInDefaultProgram(path.to_string()),
869 ));
870 col = col.push(view_utils::context_menu_separator::<Message>());
871
872 if staged {
873 col = col.push(menu_item(
874 "Unstage file",
875 Message::UnstageFile(path.to_string()),
876 ));
877 col = col.push(view_utils::context_menu_separator::<Message>());
878 col = col.push(menu_item(
879 "Discard changes",
880 Message::DiscardStagedFile(path.to_string()),
881 ));
882 } else {
883 col = col.push(menu_item(
884 "Stage file",
885 Message::StageFile(path.to_string()),
886 ));
887 col = col.push(view_utils::context_menu_separator::<Message>());
888 col = col.push(menu_item(
889 "Discard changes",
890 Message::DiscardFile(path.to_string()),
891 ));
892 }
893 }
894
895 col = col.push(view_utils::context_menu_separator::<Message>());
897 col = col.push(menu_item(
898 "Copy filename",
899 Message::CopyText(gitkraft_core::path_basename(path).to_string()),
900 ));
901 col = col.push(menu_item(
902 "Copy file path",
903 Message::CopyText(path.to_string()),
904 ));
905 col = col.push(menu_item(
906 "Show in folder",
907 Message::ShowInFolder(path.to_string()),
908 ));
909
910 if !staged {
912 col = col.push(view_utils::context_menu_separator::<Message>());
913 col = col.push(menu_item(
914 "Delete file",
915 Message::DeleteFile(path.to_string()),
916 ));
917 }
918
919 col
920}
921
922fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
924 use iced::widget::{button, column, row, text, Space};
925 use iced::{Alignment, Length};
926
927 let text_primary = c.text_primary;
928 let menu_item = move |label: &str, msg: Message| {
929 button(
930 row![
931 Space::new().width(4),
932 text(label.to_string()).size(13).color(text_primary),
933 ]
934 .align_y(Alignment::Center),
935 )
936 .padding([7, 12])
937 .width(Length::Fill)
938 .style(theme::context_menu_item)
939 .on_press(msg)
940 };
941
942 match &state.active_tab().context_menu {
943 Some(crate::state::ContextMenu::Branch {
944 name, is_current, ..
945 }) => {
946 let tab = state.active_tab();
947 let remote = tab
948 .remotes
949 .first()
950 .map(|r| r.name.clone())
951 .unwrap_or_else(|| "origin".to_string());
952
953 let tip_oid: Option<String> = tab
955 .branches
956 .iter()
957 .find(|b| &b.name == name)
958 .and_then(|b| b.target_oid.clone());
959
960 let header =
961 view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
962
963 let mut col = column![header];
964
965 if !is_current {
967 col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
968 }
969
970 let push_label = format!("Push to {remote}");
972 let pull_label = format!("Pull from {remote} (rebase)");
973 col = col
974 .push(menu_item(&push_label, Message::PushBranch(name.clone())))
975 .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
976
977 col = col.push(view_utils::context_menu_separator::<Message>());
979 let rebase_label = format!("Rebase current onto '{name}'");
980 col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
981 if !is_current {
982 col = col.push(menu_item(
983 "Merge into current branch",
984 Message::MergeBranch(name.clone()),
985 ));
986 }
987
988 col = col.push(view_utils::context_menu_separator::<Message>());
990 col = col
991 .push(menu_item(
992 "Rename\u{2026}",
993 Message::BeginRenameBranch(name.clone()),
994 ))
995 .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
996
997 col = col.push(view_utils::context_menu_separator::<Message>());
999 col = col.push(menu_item(
1000 "Copy branch name",
1001 Message::CopyText(name.clone()),
1002 ));
1003 if let Some(ref oid) = tip_oid {
1004 col = col.push(menu_item(
1005 "Copy tip commit SHA",
1006 Message::CopyText(oid.clone()),
1007 ));
1008 }
1009
1010 if let Some(oid) = tip_oid {
1012 col = col.push(view_utils::context_menu_separator::<Message>());
1013 col = col
1014 .push(menu_item(
1015 "Create tag here",
1016 Message::BeginCreateTag(oid.clone(), false),
1017 ))
1018 .push(menu_item(
1019 "Create annotated tag here\u{2026}",
1020 Message::BeginCreateTag(oid, true),
1021 ));
1022 }
1023
1024 col.into()
1025 }
1026
1027 Some(crate::state::ContextMenu::RemoteBranch { name }) => {
1028 let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
1030
1031 let header =
1032 view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
1033
1034 let local_exists =
1036 state.active_tab().branches.iter().any(|b| {
1037 b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
1038 });
1039
1040 let mut col = column![header];
1041
1042 if !local_exists {
1044 col = col.push(menu_item(
1045 &format!("Checkout as '{short_name}'"),
1046 Message::CheckoutRemoteBranch(name.clone()),
1047 ));
1048 }
1049
1050 col = col.push(view_utils::context_menu_separator::<Message>());
1052 col = col.push(menu_item(
1053 &format!("Delete from {remote}"),
1054 Message::DeleteRemoteBranch(name.clone()),
1055 ));
1056
1057 col = col.push(view_utils::context_menu_separator::<Message>());
1059 col = col.push(menu_item(
1060 "Copy branch name",
1061 Message::CopyText(name.clone()),
1062 ));
1063 col = col.push(menu_item(
1064 &format!("Copy short name '{short_name}'"),
1065 Message::CopyText(short_name.to_string()),
1066 ));
1067
1068 let tip_oid: Option<String> = state
1070 .active_tab()
1071 .branches
1072 .iter()
1073 .find(|b| &b.name == name)
1074 .and_then(|b| b.target_oid.clone());
1075
1076 if let Some(ref oid) = tip_oid {
1077 col = col.push(menu_item(
1078 "Copy tip commit SHA",
1079 Message::CopyText(oid.clone()),
1080 ));
1081 }
1082
1083 col.into()
1084 }
1085
1086 Some(crate::state::ContextMenu::Commit { index, oid }) => {
1087 let tab = state.active_tab();
1088 let multi_count = tab.selected_commits.len();
1089
1090 if multi_count > 1 {
1091 let header = view_utils::context_menu_header::<Message>(
1093 format!("{} commits selected", multi_count),
1094 c.accent,
1095 );
1096
1097 let oids: Vec<String> = tab
1099 .selected_commits
1100 .iter()
1101 .filter_map(|&i| tab.commits.get(i).map(|c| c.oid.clone()))
1102 .collect();
1103
1104 let shas_joined = oids
1105 .iter()
1106 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
1107 .map(|c| c.short_oid.clone())
1108 .collect::<Vec<_>>()
1109 .join("\n");
1110
1111 let messages_joined = oids
1112 .iter()
1113 .filter_map(|o| tab.commits.iter().find(|c| c.oid == *o))
1114 .map(|c| c.summary.trim().to_string())
1115 .collect::<Vec<_>>()
1116 .join("\n\n");
1117
1118 let mut col = column![header];
1119 col = col.push(menu_item(
1120 &format!("Cherry-pick {} commits", multi_count),
1121 Message::CherryPickCommits(oids.clone()),
1122 ));
1123 col = col.push(menu_item(
1124 &format!("Revert {} commits", multi_count),
1125 Message::RevertCommits(oids),
1126 ));
1127 col = col.push(view_utils::context_menu_separator::<Message>());
1128 col = col.push(menu_item(
1129 "Copy commit SHAs",
1130 Message::CopyText(shas_joined),
1131 ));
1132 col = col.push(menu_item(
1133 "Copy commit messages",
1134 Message::CopyText(messages_joined),
1135 ));
1136 col.into()
1137 } else {
1138 let short = gitkraft_core::utils::short_oid_str(oid);
1140 let msg_text = tab
1141 .commits
1142 .get(*index)
1143 .map(|c| c.summary.clone())
1144 .unwrap_or_default();
1145
1146 let header =
1147 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
1148
1149 let mut col = column![header];
1150
1151 for (group_idx, group) in gitkraft_core::COMMIT_MENU_GROUPS.iter().enumerate() {
1152 if group_idx > 0 {
1153 col = col.push(view_utils::context_menu_separator::<Message>());
1154 }
1155 for &kind in *group {
1156 let msg = match kind.as_simple_action() {
1157 Some(action) => Message::ExecuteCommitAction(oid.clone(), action),
1159 None => match kind {
1161 gitkraft_core::CommitActionKind::CreateBranchHere => {
1162 Message::BeginCreateBranchAtCommit(oid.clone())
1163 }
1164 gitkraft_core::CommitActionKind::CreateTag => {
1165 Message::BeginCreateTag(oid.clone(), false)
1166 }
1167 gitkraft_core::CommitActionKind::CreateAnnotatedTag => {
1168 Message::BeginCreateTag(oid.clone(), true)
1169 }
1170 _ => Message::Noop,
1171 },
1172 };
1173 col = col.push(menu_item(kind.label(), msg));
1174 }
1175 }
1176
1177 col = col.push(view_utils::context_menu_separator::<Message>());
1179 col = col
1180 .push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())))
1181 .push(menu_item(
1182 "Copy commit message",
1183 Message::CopyText(msg_text),
1184 ));
1185
1186 col.into()
1187 }
1188 }
1189
1190 Some(crate::state::ContextMenu::Stash { index }) => {
1191 let index = *index;
1192 let header =
1193 view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
1194
1195 column![
1196 header,
1197 menu_item("View diff", Message::ViewStashDiff(index)),
1198 menu_item("Apply (keep stash)", Message::StashApply(index)),
1199 menu_item("Pop (apply + remove)", Message::StashPop(index)),
1200 view_utils::context_menu_separator::<Message>(),
1201 menu_item("Drop (delete)", Message::StashDrop(index)),
1202 ]
1203 .into()
1204 }
1205
1206 Some(crate::state::ContextMenu::UnstagedFile { path }) => {
1207 file_context_menu_items(state, c, path, false, menu_item).into()
1208 }
1209
1210 Some(crate::state::ContextMenu::StagedFile { path }) => {
1211 file_context_menu_items(state, c, path, true, menu_item).into()
1212 }
1213
1214 Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
1215 let tab = state.active_tab();
1216 let multi_count = tab.selected_commit_file_indices.len();
1217
1218 if multi_count > 1 {
1219 let header = view_utils::context_menu_header::<Message>(
1221 format!("{} files selected", multi_count),
1222 c.accent,
1223 );
1224
1225 let file_paths: Vec<String> = tab
1227 .selected_commit_file_indices
1228 .iter()
1229 .filter_map(|&i| {
1230 tab.commit_files
1231 .get(i)
1232 .map(|f| f.display_path().to_string())
1233 })
1234 .collect();
1235
1236 let paths_joined = file_paths.join("\n");
1237
1238 let mut col = column![header];
1239
1240 col = col.push(menu_item(
1242 &format!("Diff {} files with working tree", multi_count),
1243 Message::DiffMultiWithWorkingTree(oid.clone(), file_paths.clone()),
1244 ));
1245 col = col.push(menu_item(
1246 &format!("Checkout {} files from this commit", multi_count),
1247 Message::CheckoutMultiFilesAtCommit(oid.clone(), file_paths),
1248 ));
1249
1250 col = col.push(view_utils::context_menu_separator::<Message>());
1252 col = col.push(menu_item(
1253 "Copy file paths",
1254 Message::CopyText(paths_joined),
1255 ));
1256 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1257
1258 col.into()
1259 } else {
1260 let file_name = gitkraft_core::path_basename(file_path);
1262 let header = view_utils::context_menu_header::<Message>(
1263 format!("File: {}", file_name),
1264 c.muted,
1265 );
1266
1267 let mut col = column![header];
1269 col = col.push(menu_item(
1270 "File History",
1271 Message::ViewFileHistory(file_path.clone()),
1272 ));
1273 col = col.push(menu_item(
1274 "File Blame",
1275 Message::ViewFileBlame(file_path.clone()),
1276 ));
1277 col = col.push(view_utils::context_menu_separator::<Message>());
1278
1279 col = col.push(menu_item(
1281 "Diff with working tree",
1282 Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
1283 ));
1284 col = col.push(menu_item(
1285 "Checkout file from this commit",
1286 Message::CheckoutFileAtCommit(oid.clone(), file_path.clone()),
1287 ));
1288 col = col.push(menu_item(
1289 "Preview file",
1290 Message::PreviewFile(file_path.clone()),
1291 ));
1292
1293 col = col.push(view_utils::context_menu_separator::<Message>());
1295 col = col.push(menu_item(
1296 "Copy filename",
1297 Message::CopyText(file_name.to_string()),
1298 ));
1299 col = col.push(menu_item(
1300 "Copy file path",
1301 Message::CopyText(file_path.clone()),
1302 ));
1303 col = col.push(menu_item("Copy commit SHA", Message::CopyText(oid.clone())));
1304
1305 col = col.push(view_utils::context_menu_separator::<Message>());
1307 col = col.push(menu_item(
1308 "Open in editor",
1309 Message::OpenInEditor(file_path.clone()),
1310 ));
1311 col = col.push(menu_item(
1312 "Open in default program",
1313 Message::OpenInDefaultProgram(file_path.clone()),
1314 ));
1315 col = col.push(menu_item(
1316 "Show in folder",
1317 Message::ShowInFolder(file_path.clone()),
1318 ));
1319
1320 col.into()
1321 }
1322 }
1323
1324 None => Space::new().into(),
1325 }
1326}