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 outer = column![tab_bar, welcome]
54 .width(Length::Fill)
55 .height(Length::Fill);
56 return container(outer)
57 .width(Length::Fill)
58 .height(Length::Fill)
59 .style(theme::bg_style)
60 .into();
61 }
62
63 let tab = self.active_tab();
64
65 let header = widgets::header::view(self);
67
68 let sidebar: Element<'_, Message> = if self.sidebar_expanded {
70 let branches = features::branches::view::view(self);
71 let stash = features::stash::view::view(self);
72 let remotes = features::remotes::view::view(self);
73
74 let sidebar_content = container(
75 column![
76 branches,
77 iced::widget::rule::horizontal(1),
78 stash,
79 iced::widget::rule::horizontal(1),
80 remotes
81 ]
82 .width(Length::Fill)
83 .height(Length::Fill),
84 )
85 .width(Length::Fixed(self.sidebar_width))
86 .height(Length::Fill)
87 .style(theme::sidebar_style);
88
89 let divider = widgets::divider::vertical_divider(DragTarget::SidebarRight, &c);
90
91 row![sidebar_content, divider].height(Length::Fill).into()
92 } else {
93 Space::new().into()
94 };
95
96 let commit_log_content = container(features::commits::view::view(self))
98 .width(Length::Fixed(self.commit_log_width))
99 .height(Length::Fill);
100
101 let commit_divider = widgets::divider::vertical_divider(DragTarget::CommitLogRight, &c);
102
103 let commit_log: Element<'_, Message> = row![commit_log_content, commit_divider]
104 .height(Length::Fill)
105 .into();
106
107 let diff_viewer = container(features::diff::view::view(self))
109 .width(Length::Fill)
110 .height(Length::Fill);
111
112 let middle = row![sidebar, commit_log, diff_viewer]
114 .height(Length::Fill)
115 .width(Length::Fill);
116
117 let h_divider = widgets::divider::horizontal_divider(DragTargetH::StagingTop, &c);
119
120 let staging = container(features::staging::view::view(self))
122 .width(Length::Fill)
123 .height(Length::Fixed(self.staging_height));
124
125 let status_bar = status_bar_view(self);
127
128 let mut main_col = column![].width(Length::Fill).height(Length::Fill);
130
131 main_col = main_col.push(tab_bar);
132
133 if let Some(ref err) = tab.error_message {
134 main_col = main_col.push(error_banner(err, &c));
135 }
136
137 main_col = main_col
138 .push(header)
139 .push(middle)
140 .push(h_divider)
141 .push(staging)
142 .push(status_bar);
143
144 let body = container(main_col)
145 .width(Length::Fill)
146 .height(Length::Fill)
147 .style(theme::bg_style);
148
149 let ma: Element<'_, Message> = mouse_area(body)
153 .on_move(|p| Message::PaneDragMove(p.x, p.y))
154 .on_release(Message::PaneDragEnd)
155 .into();
156
157 let ma: Element<'_, Message> = if self.search_visible {
159 let search_panel = search_overlay(self, &c);
160 iced::widget::stack![ma, search_panel].into()
161 } else {
162 ma
163 };
164
165 if self.active_tab().context_menu.is_some() {
167 let backdrop = mouse_area(
169 container(Space::new().width(Length::Fill).height(Length::Fill))
170 .style(theme::backdrop_style),
171 )
172 .on_press(Message::CloseContextMenu)
173 .on_right_press(Message::CloseContextMenu);
174
175 let (menu_x, menu_y) = context_menu_position(self);
176 let menu_panel = context_menu_panel(self, &c);
177
178 let positioned = column![
179 Space::new().height(menu_y),
180 row![Space::new().width(menu_x), menu_panel,],
181 ]
182 .width(Length::Fill)
183 .height(Length::Fill);
184
185 iced::widget::stack![ma, backdrop, positioned].into()
186 } else {
187 ma
188 }
189 }
190}
191
192fn status_bar_view(state: &GitKraft) -> Element<'_, Message> {
194 let tab = state.active_tab();
195 let c = state.colors();
196
197 let status_text = if tab.is_loading {
198 tab.status_message
199 .as_deref()
200 .unwrap_or("Loading…")
201 .to_string()
202 } else {
203 tab.status_message.as_deref().unwrap_or("Ready").to_string()
204 };
205
206 let status_label = text(status_text).size(12).color(c.text_secondary);
207
208 let branch_info: Element<'_, Message> = if let Some(ref branch) = tab.current_branch {
209 let icon = icon!(icons::GIT_BRANCH, 12, c.accent);
210 let label = text(branch.as_str()).size(12).color(c.text_primary);
211 row![icon, Space::new().width(4), label]
212 .align_y(Alignment::Center)
213 .into()
214 } else {
215 Space::new().into()
216 };
217
218 let repo_state_info: Element<'_, Message> = if let Some(ref info) = tab.repo_info {
219 let state_str = format!("{}", info.state);
220 if state_str != "Clean" {
221 text(state_str).size(12).color(c.yellow).into()
222 } else {
223 Space::new().into()
224 }
225 } else {
226 Space::new().into()
227 };
228
229 let changes_summary = {
230 let unstaged_count = tab.unstaged_changes.len();
231 let staged_count = tab.staged_changes.len();
232 if unstaged_count > 0 || staged_count > 0 {
233 text(format!("{unstaged_count} unstaged, {staged_count} staged"))
234 .size(12)
235 .color(c.muted)
236 } else {
237 text("Working tree clean").size(12).color(c.muted)
238 }
239 };
240
241 let zoom_label: Element<'_, Message> = if (state.ui_scale - 1.0).abs() > 0.01 {
242 text(format!("{}%", (state.ui_scale * 100.0).round() as u32))
243 .size(11)
244 .color(c.muted)
245 .into()
246 } else {
247 Space::new().into()
248 };
249
250 let bar = row![
251 status_label,
252 Space::new().width(Length::Fill),
253 changes_summary,
254 Space::new().width(16),
255 zoom_label,
256 Space::new().width(16),
257 repo_state_info,
258 Space::new().width(16),
259 branch_info,
260 ]
261 .align_y(Alignment::Center)
262 .padding([4, 10])
263 .width(Length::Fill);
264
265 container(bar)
266 .width(Length::Fill)
267 .style(theme::header_style)
268 .into()
269}
270
271fn error_banner<'a>(message: &str, c: &ThemeColors) -> Element<'a, Message> {
273 let icon = icon!(icons::EXCLAMATION_TRIANGLE, 14, c.red);
274
275 let msg = text(message.to_string()).size(13).color(c.text_primary);
276
277 let dismiss = iced::widget::button(icon!(icons::X_CIRCLE, 14, c.text_secondary))
278 .padding([2, 6])
279 .on_press(Message::DismissError);
280
281 let banner_row = row![
282 icon,
283 Space::new().width(8),
284 msg,
285 Space::new().width(Length::Fill),
286 dismiss,
287 ]
288 .align_y(Alignment::Center)
289 .padding([6, 12])
290 .width(Length::Fill);
291
292 container(banner_row)
293 .width(Length::Fill)
294 .style(theme::error_banner_style)
295 .into()
296}
297
298fn context_menu_position(state: &GitKraft) -> (f32, f32) {
300 let (x, y) = state.active_tab().context_menu_pos;
304 ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
305}
306
307fn search_overlay<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
309 use iced::widget::{
310 button, column, container, mouse_area, row, scrollable, text, text_input, Space,
311 };
312 use iced::{Alignment, Length};
313
314 let input = text_input("Search commits…", &state.search_query)
315 .on_input(Message::SearchQueryChanged)
316 .on_submit(Message::ConfirmSearchResult)
317 .padding(10)
318 .size(16);
319
320 let mut results_col = column![].spacing(2).width(Length::Fill);
321
322 if state.search_results.is_empty() && state.search_query.len() >= 2 {
323 results_col = results_col.push(
324 container(text("No results found").size(13).color(c.muted))
325 .padding([12, 8])
326 .width(Length::Fill)
327 .center_x(Length::Fill),
328 );
329 }
330
331 for (i, commit) in state.search_results.iter().take(50).enumerate() {
332 let is_selected = state.search_selected == Some(i);
333 let bg_style = if is_selected {
334 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
335 } else {
336 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
337 };
338
339 let oid_label = text(&commit.short_oid)
340 .size(12)
341 .color(c.accent)
342 .font(iced::Font::MONOSPACE);
343
344 let summary_label = text(&commit.summary).size(13).color(c.text_primary);
345
346 let author_label = text(&commit.author_name).size(11).color(c.text_secondary);
347
348 let time_label = text(commit.relative_time()).size(11).color(c.muted);
349
350 let row_content = row![
351 oid_label,
352 Space::new().width(8),
353 summary_label,
354 Space::new().width(Length::Fill),
355 author_label,
356 Space::new().width(8),
357 time_label,
358 ]
359 .align_y(Alignment::Center)
360 .padding([6, 10]);
361
362 let result_btn = button(row_content)
363 .padding(0)
364 .width(Length::Fill)
365 .style(theme::ghost_button)
366 .on_press(Message::ConfirmSearchResult);
367
368 let result_row: Element<'a, Message> =
369 mouse_area(container(result_btn).width(Length::Fill).style(bg_style))
370 .on_press(Message::SelectSearchResult(i))
371 .on_right_press(Message::OpenSearchResultContextMenu(i))
372 .into();
373
374 results_col = results_col.push(result_row);
375 }
376
377 let result_count = if !state.search_results.is_empty() {
378 text(format!("{} result(s)", state.search_results.len()))
379 .size(11)
380 .color(c.muted)
381 } else {
382 text("").size(1)
383 };
384
385 let close_btn = button(text("\u{2715}").size(14).color(c.text_secondary))
386 .padding([4, 8])
387 .style(theme::ghost_button)
388 .on_press(Message::ToggleSearch);
389
390 let header = row![
391 icon!(icons::CLOCK_HISTORY, 16, c.accent),
392 Space::new().width(8),
393 text("Search Commits").size(16).color(c.text_primary),
394 Space::new().width(Length::Fill),
395 result_count,
396 Space::new().width(8),
397 close_btn,
398 ]
399 .align_y(Alignment::Center)
400 .padding([8, 12]);
401
402 let scrollable_results = scrollable(results_col)
403 .height(Length::Fill)
404 .direction(crate::view_utils::thin_scrollbar())
405 .style(crate::theme::overlay_scrollbar);
406
407 let panel = container(
408 column![header, input, scrollable_results,]
409 .width(Length::Fill)
410 .height(Length::Fill)
411 .spacing(4),
412 )
413 .width(700)
414 .height(500)
415 .style(theme::context_menu_style)
416 .padding(8);
417
418 let backdrop = mouse_area(
420 container(Space::new().width(Length::Fill).height(Length::Fill))
421 .style(theme::backdrop_style),
422 )
423 .on_press(Message::ToggleSearch);
424
425 let centered = container(panel)
426 .width(Length::Fill)
427 .height(Length::Fill)
428 .center_x(Length::Fill)
429 .center_y(Length::Fill);
430
431 iced::widget::stack![backdrop, centered].into()
432}
433
434fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
436 use iced::widget::{button, column, container, row, text, Space};
437 use iced::{Alignment, Length};
438
439 let text_primary = c.text_primary;
440 let menu_item = move |label: &str, msg: Message| {
441 button(
442 row![
443 Space::new().width(4),
444 text(label.to_string()).size(13).color(text_primary),
445 ]
446 .align_y(Alignment::Center),
447 )
448 .padding([7, 12])
449 .width(Length::Fill)
450 .style(theme::context_menu_item)
451 .on_press(msg)
452 };
453
454 let content: Element<'a, Message> = match &state.active_tab().context_menu {
455 Some(crate::state::ContextMenu::Branch {
456 name, is_current, ..
457 }) => {
458 let tab = state.active_tab();
459 let remote = tab
460 .remotes
461 .first()
462 .map(|r| r.name.clone())
463 .unwrap_or_else(|| "origin".to_string());
464
465 let tip_oid: Option<String> = tab
467 .branches
468 .iter()
469 .find(|b| &b.name == name)
470 .and_then(|b| b.target_oid.clone());
471
472 let header =
473 view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
474
475 let mut col = column![header];
476
477 if !is_current {
479 col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
480 }
481
482 let push_label = format!("Push to {remote}");
484 let pull_label = format!("Pull from {remote} (rebase)");
485 col = col
486 .push(menu_item(&push_label, Message::PushBranch(name.clone())))
487 .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
488
489 col = col.push(view_utils::context_menu_separator::<Message>());
491 let rebase_label = format!("Rebase current onto '{name}'");
492 col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
493 if !is_current {
494 col = col.push(menu_item(
495 "Merge into current branch",
496 Message::MergeBranch(name.clone()),
497 ));
498 }
499
500 col = col.push(view_utils::context_menu_separator::<Message>());
502 col = col
503 .push(menu_item(
504 "Rename\u{2026}",
505 Message::BeginRenameBranch(name.clone()),
506 ))
507 .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
508
509 col = col.push(view_utils::context_menu_separator::<Message>());
511 col = col.push(menu_item(
512 "Copy branch name",
513 Message::CopyText(name.clone()),
514 ));
515 if let Some(ref oid) = tip_oid {
516 col = col.push(menu_item(
517 "Copy tip commit SHA",
518 Message::CopyText(oid.clone()),
519 ));
520 }
521
522 if tip_oid.is_some() {
524 col = col.push(view_utils::context_menu_separator::<Message>());
525 let oid = tip_oid.clone().unwrap();
526 col = col
527 .push(menu_item(
528 "Create tag here",
529 Message::BeginCreateTag(oid.clone(), false),
530 ))
531 .push(menu_item(
532 "Create annotated tag here\u{2026}",
533 Message::BeginCreateTag(oid, true),
534 ));
535 }
536
537 col.into()
538 }
539
540 Some(crate::state::ContextMenu::RemoteBranch { name }) => {
541 let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
543
544 let header =
545 view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
546
547 let local_exists =
549 state.active_tab().branches.iter().any(|b| {
550 b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
551 });
552
553 let mut col = column![header];
554
555 if !local_exists {
557 col = col.push(menu_item(
558 &format!("Checkout as '{short_name}'"),
559 Message::CheckoutRemoteBranch(name.clone()),
560 ));
561 }
562
563 col = col.push(view_utils::context_menu_separator::<Message>());
565 col = col.push(menu_item(
566 &format!("Delete from {remote}"),
567 Message::DeleteRemoteBranch(name.clone()),
568 ));
569
570 col = col.push(view_utils::context_menu_separator::<Message>());
572 col = col.push(menu_item(
573 "Copy branch name",
574 Message::CopyText(name.clone()),
575 ));
576 col = col.push(menu_item(
577 &format!("Copy short name '{short_name}'"),
578 Message::CopyText(short_name.to_string()),
579 ));
580
581 let tip_oid: Option<String> = state
583 .active_tab()
584 .branches
585 .iter()
586 .find(|b| &b.name == name)
587 .and_then(|b| b.target_oid.clone());
588
589 if let Some(ref oid) = tip_oid {
590 col = col.push(menu_item(
591 "Copy tip commit SHA",
592 Message::CopyText(oid.clone()),
593 ));
594 }
595
596 col.into()
597 }
598
599 Some(crate::state::ContextMenu::Commit { index, oid }) => {
600 let tab = state.active_tab();
601 let short = gitkraft_core::utils::short_oid_str(oid);
602 let msg_text = tab
603 .commits
604 .get(*index)
605 .map(|c| c.message.clone())
606 .unwrap_or_default();
607
608 let header =
609 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
610
611 column![
612 header,
613 menu_item(
614 "Checkout (detached HEAD)",
615 Message::CheckoutCommitDetached(oid.clone()),
616 ),
617 menu_item(
618 "Rebase current branch onto this",
619 Message::RebaseOntoCommit(oid.clone()),
620 ),
621 menu_item("Revert commit", Message::RevertCommit(oid.clone())),
622 menu_item(
623 "Reset here — soft (keep staged)",
624 Message::ResetSoft(oid.clone())
625 ),
626 menu_item(
627 "Reset here — mixed (keep files)",
628 Message::ResetMixed(oid.clone())
629 ),
630 menu_item(
631 "Reset here — hard (discard all)",
632 Message::ResetHard(oid.clone())
633 ),
634 menu_item("Copy commit SHA", Message::CopyText(oid.clone())),
635 menu_item("Copy commit message", Message::CopyText(msg_text)),
636 ]
637 .into()
638 }
639
640 Some(crate::state::ContextMenu::Stash { index }) => {
641 let index = *index;
642 let header =
643 view_utils::context_menu_header::<Message>(format!("stash@{{{index}}}"), c.muted);
644
645 column![
646 header,
647 menu_item("View diff", Message::ViewStashDiff(index)),
648 menu_item("Apply (keep stash)", Message::StashApply(index)),
649 menu_item("Pop (apply + remove)", Message::StashPop(index)),
650 view_utils::context_menu_separator::<Message>(),
651 menu_item("Drop (delete)", Message::StashDrop(index)),
652 ]
653 .into()
654 }
655
656 Some(crate::state::ContextMenu::UnstagedFile { path }) => {
657 let selected_count = state.active_tab().selected_unstaged.len();
658 let is_multi = selected_count > 1;
659
660 let header_text = if is_multi {
661 format!("{} files selected", selected_count)
662 } else {
663 format!("Unstaged: {}", path.rsplit('/').next().unwrap_or(path))
664 };
665 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
666
667 let mut col = column![header];
668
669 if is_multi {
670 col = col.push(menu_item(
672 &format!("Stage {} file(s)", selected_count),
673 Message::StageSelected,
674 ));
675 col = col.push(view_utils::context_menu_separator::<Message>());
676 col = col.push(menu_item(
677 &format!("Discard {} file(s)", selected_count),
678 Message::DiscardSelected,
679 ));
680 } else {
681 let diff = state
683 .active_tab()
684 .unstaged_changes
685 .iter()
686 .find(|d| d.display_path() == path.as_str())
687 .cloned()
688 .unwrap_or_else(|| gitkraft_core::DiffInfo {
689 old_file: String::new(),
690 new_file: path.clone(),
691 status: gitkraft_core::FileStatus::Modified,
692 hunks: Vec::new(),
693 });
694
695 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
696 col = col.push(menu_item("Stage file", Message::StageFile(path.clone())));
697 col = col.push(view_utils::context_menu_separator::<Message>());
698 col = col.push(menu_item(
699 "Discard changes",
700 Message::DiscardFile(path.clone()),
701 ));
702 }
703
704 col = col.push(view_utils::context_menu_separator::<Message>());
705 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
706 col = col.push(menu_item(
707 "Open in editor",
708 Message::OpenInEditor(path.clone()),
709 ));
710 col = col.push(menu_item(
711 "Open in default program",
712 Message::OpenInDefaultProgram(path.clone()),
713 ));
714 col = col.push(menu_item(
715 "Show in folder",
716 Message::ShowInFolder(path.clone()),
717 ));
718
719 col.into()
720 }
721
722 Some(crate::state::ContextMenu::StagedFile { path }) => {
723 let selected_count = state.active_tab().selected_staged.len();
724 let is_multi = selected_count > 1;
725
726 let header_text = if is_multi {
727 format!("{} files selected", selected_count)
728 } else {
729 format!("Staged: {}", path.rsplit('/').next().unwrap_or(path))
730 };
731 let header = view_utils::context_menu_header::<Message>(header_text, c.muted);
732
733 let mut col = column![header];
734
735 if is_multi {
736 col = col.push(menu_item(
737 &format!("Unstage {} file(s)", selected_count),
738 Message::UnstageSelected,
739 ));
740 col = col.push(view_utils::context_menu_separator::<Message>());
741 col = col.push(menu_item(
742 &format!("Discard {} file(s)", selected_count),
743 Message::DiscardSelected,
744 ));
745 } else {
746 let diff = state
747 .active_tab()
748 .staged_changes
749 .iter()
750 .find(|d| d.display_path() == path.as_str())
751 .cloned()
752 .unwrap_or_else(|| gitkraft_core::DiffInfo {
753 old_file: String::new(),
754 new_file: path.clone(),
755 status: gitkraft_core::FileStatus::Modified,
756 hunks: Vec::new(),
757 });
758
759 col = col.push(menu_item("View diff", Message::SelectDiff(diff)));
760 col = col.push(menu_item(
761 "Unstage file",
762 Message::UnstageFile(path.clone()),
763 ));
764 col = col.push(view_utils::context_menu_separator::<Message>());
765 col = col.push(menu_item(
766 "Discard changes",
767 Message::DiscardStagedFile(path.clone()),
768 ));
769 }
770
771 col = col.push(view_utils::context_menu_separator::<Message>());
772 col = col.push(menu_item("Copy file path", Message::CopyText(path.clone())));
773 col = col.push(menu_item(
774 "Open in editor",
775 Message::OpenInEditor(path.clone()),
776 ));
777 col = col.push(menu_item(
778 "Open in default program",
779 Message::OpenInDefaultProgram(path.clone()),
780 ));
781 col = col.push(menu_item(
782 "Show in folder",
783 Message::ShowInFolder(path.clone()),
784 ));
785
786 col.into()
787 }
788
789 Some(crate::state::ContextMenu::CommitFile { oid, file_path }) => {
790 let file_name = file_path.rsplit('/').next().unwrap_or(file_path);
791 let header =
792 view_utils::context_menu_header::<Message>(format!("File: {}", file_name), c.muted);
793
794 column![
795 header,
796 menu_item(
797 "Diff with working tree",
798 Message::DiffFileWithWorkingTree(oid.clone(), file_path.clone()),
799 ),
800 view_utils::context_menu_separator::<Message>(),
801 menu_item("Copy file path", Message::CopyText(file_path.clone()),),
802 menu_item("Copy commit SHA", Message::CopyText(oid.clone()),),
803 menu_item("Open in editor", Message::OpenInEditor(file_path.clone()),),
804 menu_item("Show in folder", Message::ShowInFolder(file_path.clone()),),
805 ]
806 .into()
807 }
808
809 None => Space::new().into(),
810 };
811
812 container(content)
813 .width(280)
814 .style(theme::context_menu_style)
815 .into()
816}