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