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