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