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 .into();
372
373 results_col = results_col.push(result_row);
374 }
375
376 let result_count = if !state.search_results.is_empty() {
377 text(format!("{} result(s)", state.search_results.len()))
378 .size(11)
379 .color(c.muted)
380 } else {
381 text("").size(1)
382 };
383
384 let close_btn = button(text("\u{2715}").size(14).color(c.text_secondary))
385 .padding([4, 8])
386 .style(theme::ghost_button)
387 .on_press(Message::ToggleSearch);
388
389 let header = row![
390 icon!(icons::CLOCK_HISTORY, 16, c.accent),
391 Space::new().width(8),
392 text("Search Commits").size(16).color(c.text_primary),
393 Space::new().width(Length::Fill),
394 result_count,
395 Space::new().width(8),
396 close_btn,
397 ]
398 .align_y(Alignment::Center)
399 .padding([8, 12]);
400
401 let scrollable_results = scrollable(results_col)
402 .height(Length::Fill)
403 .direction(crate::view_utils::thin_scrollbar())
404 .style(crate::theme::overlay_scrollbar);
405
406 let panel = container(
407 column![header, input, scrollable_results,]
408 .width(Length::Fill)
409 .height(Length::Fill)
410 .spacing(4),
411 )
412 .width(700)
413 .height(500)
414 .style(theme::context_menu_style)
415 .padding(8);
416
417 let backdrop = mouse_area(
419 container(Space::new().width(Length::Fill).height(Length::Fill))
420 .style(theme::backdrop_style),
421 )
422 .on_press(Message::ToggleSearch);
423
424 let centered = container(panel)
425 .width(Length::Fill)
426 .height(Length::Fill)
427 .center_x(Length::Fill)
428 .center_y(Length::Fill);
429
430 iced::widget::stack![backdrop, centered].into()
431}
432
433fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
435 use iced::widget::{button, column, container, row, text, Space};
436 use iced::{Alignment, Length};
437
438 let text_primary = c.text_primary;
439 let menu_item = move |label: &str, msg: Message| {
440 button(
441 row![
442 Space::new().width(4),
443 text(label.to_string()).size(13).color(text_primary),
444 ]
445 .align_y(Alignment::Center),
446 )
447 .padding([7, 12])
448 .width(Length::Fill)
449 .style(theme::context_menu_item)
450 .on_press(msg)
451 };
452
453 let content: Element<'a, Message> = match &state.active_tab().context_menu {
454 Some(crate::state::ContextMenu::Branch {
455 name, is_current, ..
456 }) => {
457 let tab = state.active_tab();
458 let remote = tab
459 .remotes
460 .first()
461 .map(|r| r.name.clone())
462 .unwrap_or_else(|| "origin".to_string());
463
464 let tip_oid: Option<String> = tab
466 .branches
467 .iter()
468 .find(|b| &b.name == name)
469 .and_then(|b| b.target_oid.clone());
470
471 let header =
472 view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
473
474 let mut col = column![header];
475
476 if !is_current {
478 col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
479 }
480
481 let push_label = format!("Push to {remote}");
483 let pull_label = format!("Pull from {remote} (rebase)");
484 col = col
485 .push(menu_item(&push_label, Message::PushBranch(name.clone())))
486 .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
487
488 col = col.push(view_utils::context_menu_separator::<Message>());
490 let rebase_label = format!("Rebase current onto '{name}'");
491 col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
492 if !is_current {
493 col = col.push(menu_item(
494 "Merge into current branch",
495 Message::MergeBranch(name.clone()),
496 ));
497 }
498
499 col = col.push(view_utils::context_menu_separator::<Message>());
501 col = col
502 .push(menu_item(
503 "Rename\u{2026}",
504 Message::BeginRenameBranch(name.clone()),
505 ))
506 .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
507
508 col = col.push(view_utils::context_menu_separator::<Message>());
510 col = col.push(menu_item(
511 "Copy branch name",
512 Message::CopyText(name.clone()),
513 ));
514 if let Some(ref oid) = tip_oid {
515 col = col.push(menu_item(
516 "Copy tip commit SHA",
517 Message::CopyText(oid.clone()),
518 ));
519 }
520
521 if tip_oid.is_some() {
523 col = col.push(view_utils::context_menu_separator::<Message>());
524 let oid = tip_oid.clone().unwrap();
525 col = col
526 .push(menu_item(
527 "Create tag here",
528 Message::BeginCreateTag(oid.clone(), false),
529 ))
530 .push(menu_item(
531 "Create annotated tag here\u{2026}",
532 Message::BeginCreateTag(oid, true),
533 ));
534 }
535
536 col.into()
537 }
538
539 Some(crate::state::ContextMenu::RemoteBranch { name }) => {
540 let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
542
543 let header =
544 view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
545
546 let local_exists =
548 state.active_tab().branches.iter().any(|b| {
549 b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
550 });
551
552 let mut col = column![header];
553
554 if !local_exists {
556 col = col.push(menu_item(
557 &format!("Checkout as '{short_name}'"),
558 Message::CheckoutRemoteBranch(name.clone()),
559 ));
560 }
561
562 col = col.push(view_utils::context_menu_separator::<Message>());
564 col = col.push(menu_item(
565 &format!("Delete from {remote}"),
566 Message::DeleteRemoteBranch(name.clone()),
567 ));
568
569 col = col.push(view_utils::context_menu_separator::<Message>());
571 col = col.push(menu_item(
572 "Copy branch name",
573 Message::CopyText(name.clone()),
574 ));
575 col = col.push(menu_item(
576 &format!("Copy short name '{short_name}'"),
577 Message::CopyText(short_name.to_string()),
578 ));
579
580 let tip_oid: Option<String> = state
582 .active_tab()
583 .branches
584 .iter()
585 .find(|b| &b.name == name)
586 .and_then(|b| b.target_oid.clone());
587
588 if let Some(ref oid) = tip_oid {
589 col = col.push(menu_item(
590 "Copy tip commit SHA",
591 Message::CopyText(oid.clone()),
592 ));
593 }
594
595 col.into()
596 }
597
598 Some(crate::state::ContextMenu::Commit { index, oid }) => {
599 let tab = state.active_tab();
600 let short = gitkraft_core::utils::short_oid_str(oid);
601 let msg_text = tab
602 .commits
603 .get(*index)
604 .map(|c| c.message.clone())
605 .unwrap_or_default();
606
607 let header =
608 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
609
610 column![
611 header,
612 menu_item(
613 "Checkout (detached HEAD)",
614 Message::CheckoutCommitDetached(oid.clone()),
615 ),
616 menu_item(
617 "Rebase current branch onto this",
618 Message::RebaseOntoCommit(oid.clone()),
619 ),
620 menu_item("Revert commit", Message::RevertCommit(oid.clone())),
621 menu_item(
622 "Reset here — soft (keep staged)",
623 Message::ResetSoft(oid.clone())
624 ),
625 menu_item(
626 "Reset here — mixed (keep files)",
627 Message::ResetMixed(oid.clone())
628 ),
629 menu_item(
630 "Reset here — hard (discard all)",
631 Message::ResetHard(oid.clone())
632 ),
633 menu_item("Copy commit SHA", Message::CopyText(oid.clone())),
634 menu_item("Copy commit message", Message::CopyText(msg_text)),
635 ]
636 .into()
637 }
638
639 None => Space::new().into(),
640 };
641
642 container(content)
643 .width(280)
644 .style(theme::context_menu_style)
645 .into()
646}