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 if self.active_tab().context_menu.is_some() {
159 let backdrop = mouse_area(
161 container(Space::new().width(Length::Fill).height(Length::Fill))
162 .style(theme::backdrop_style),
163 )
164 .on_press(Message::CloseContextMenu)
165 .on_right_press(Message::CloseContextMenu);
166
167 let (menu_x, menu_y) = context_menu_position(self);
168 let menu_panel = context_menu_panel(self, &c);
169
170 let positioned = column![
171 Space::new().height(menu_y),
172 row![Space::new().width(menu_x), menu_panel,],
173 ]
174 .width(Length::Fill)
175 .height(Length::Fill);
176
177 iced::widget::stack![ma, backdrop, positioned].into()
178 } else {
179 ma
180 }
181 }
182}
183
184fn status_bar_view(state: &GitKraft) -> Element<'_, Message> {
186 let tab = state.active_tab();
187 let c = state.colors();
188
189 let status_text = if tab.is_loading {
190 tab.status_message
191 .as_deref()
192 .unwrap_or("Loading…")
193 .to_string()
194 } else {
195 tab.status_message.as_deref().unwrap_or("Ready").to_string()
196 };
197
198 let status_label = text(status_text).size(12).color(c.text_secondary);
199
200 let branch_info: Element<'_, Message> = if let Some(ref branch) = tab.current_branch {
201 let icon = icon!(icons::GIT_BRANCH, 12, c.accent);
202 let label = text(branch.as_str()).size(12).color(c.text_primary);
203 row![icon, Space::new().width(4), label]
204 .align_y(Alignment::Center)
205 .into()
206 } else {
207 Space::new().into()
208 };
209
210 let repo_state_info: Element<'_, Message> = if let Some(ref info) = tab.repo_info {
211 let state_str = format!("{}", info.state);
212 if state_str != "Clean" {
213 text(state_str).size(12).color(c.yellow).into()
214 } else {
215 Space::new().into()
216 }
217 } else {
218 Space::new().into()
219 };
220
221 let changes_summary = {
222 let unstaged_count = tab.unstaged_changes.len();
223 let staged_count = tab.staged_changes.len();
224 if unstaged_count > 0 || staged_count > 0 {
225 text(format!("{unstaged_count} unstaged, {staged_count} staged"))
226 .size(12)
227 .color(c.muted)
228 } else {
229 text("Working tree clean").size(12).color(c.muted)
230 }
231 };
232
233 let zoom_label: Element<'_, Message> = if (state.ui_scale - 1.0).abs() > 0.01 {
234 text(format!("{}%", (state.ui_scale * 100.0).round() as u32))
235 .size(11)
236 .color(c.muted)
237 .into()
238 } else {
239 Space::new().into()
240 };
241
242 let bar = row![
243 status_label,
244 Space::new().width(Length::Fill),
245 changes_summary,
246 Space::new().width(16),
247 zoom_label,
248 Space::new().width(16),
249 repo_state_info,
250 Space::new().width(16),
251 branch_info,
252 ]
253 .align_y(Alignment::Center)
254 .padding([4, 10])
255 .width(Length::Fill);
256
257 container(bar)
258 .width(Length::Fill)
259 .style(theme::header_style)
260 .into()
261}
262
263fn error_banner<'a>(message: &str, c: &ThemeColors) -> Element<'a, Message> {
265 let icon = icon!(icons::EXCLAMATION_TRIANGLE, 14, c.red);
266
267 let msg = text(message.to_string()).size(13).color(c.text_primary);
268
269 let dismiss = iced::widget::button(icon!(icons::X_CIRCLE, 14, c.text_secondary))
270 .padding([2, 6])
271 .on_press(Message::DismissError);
272
273 let banner_row = row![
274 icon,
275 Space::new().width(8),
276 msg,
277 Space::new().width(Length::Fill),
278 dismiss,
279 ]
280 .align_y(Alignment::Center)
281 .padding([6, 12])
282 .width(Length::Fill);
283
284 container(banner_row)
285 .width(Length::Fill)
286 .style(theme::error_banner_style)
287 .into()
288}
289
290fn context_menu_position(state: &GitKraft) -> (f32, f32) {
292 let (x, y) = state.active_tab().context_menu_pos;
296 ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
297}
298
299fn context_menu_panel<'a>(state: &'a GitKraft, c: &ThemeColors) -> Element<'a, Message> {
301 use iced::widget::{button, column, container, row, text, Space};
302 use iced::{Alignment, Length};
303
304 let text_primary = c.text_primary;
305 let menu_item = move |label: &str, msg: Message| {
306 button(
307 row![
308 Space::new().width(4),
309 text(label.to_string()).size(13).color(text_primary),
310 ]
311 .align_y(Alignment::Center),
312 )
313 .padding([7, 12])
314 .width(Length::Fill)
315 .style(theme::context_menu_item)
316 .on_press(msg)
317 };
318
319 let content: Element<'a, Message> = match &state.active_tab().context_menu {
320 Some(crate::state::ContextMenu::Branch {
321 name, is_current, ..
322 }) => {
323 let tab = state.active_tab();
324 let remote = tab
325 .remotes
326 .first()
327 .map(|r| r.name.clone())
328 .unwrap_or_else(|| "origin".to_string());
329
330 let tip_oid: Option<String> = tab
332 .branches
333 .iter()
334 .find(|b| &b.name == name)
335 .and_then(|b| b.target_oid.clone());
336
337 let header =
338 view_utils::context_menu_header::<Message>(format!("Branch: {name}"), c.muted);
339
340 let mut col = column![header];
341
342 if !is_current {
344 col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
345 }
346
347 let push_label = format!("Push to {remote}");
349 let pull_label = format!("Pull from {remote} (rebase)");
350 col = col
351 .push(menu_item(&push_label, Message::PushBranch(name.clone())))
352 .push(menu_item(&pull_label, Message::PullBranch(name.clone())));
353
354 col = col.push(view_utils::context_menu_separator::<Message>());
356 let rebase_label = format!("Rebase current onto '{name}'");
357 col = col.push(menu_item(&rebase_label, Message::RebaseOnto(name.clone())));
358 if !is_current {
359 col = col.push(menu_item(
360 "Merge into current branch",
361 Message::MergeBranch(name.clone()),
362 ));
363 }
364
365 col = col.push(view_utils::context_menu_separator::<Message>());
367 col = col
368 .push(menu_item(
369 "Rename\u{2026}",
370 Message::BeginRenameBranch(name.clone()),
371 ))
372 .push(menu_item("Delete", Message::DeleteBranch(name.clone())));
373
374 col = col.push(view_utils::context_menu_separator::<Message>());
376 col = col.push(menu_item(
377 "Copy branch name",
378 Message::CopyText(name.clone()),
379 ));
380 if let Some(ref oid) = tip_oid {
381 col = col.push(menu_item(
382 "Copy tip commit SHA",
383 Message::CopyText(oid.clone()),
384 ));
385 }
386
387 if tip_oid.is_some() {
389 col = col.push(view_utils::context_menu_separator::<Message>());
390 let oid = tip_oid.clone().unwrap();
391 col = col
392 .push(menu_item(
393 "Create tag here",
394 Message::BeginCreateTag(oid.clone(), false),
395 ))
396 .push(menu_item(
397 "Create annotated tag here\u{2026}",
398 Message::BeginCreateTag(oid, true),
399 ));
400 }
401
402 col.into()
403 }
404
405 Some(crate::state::ContextMenu::RemoteBranch { name }) => {
406 let (remote, short_name) = name.split_once('/').unwrap_or(("", name.as_str()));
408
409 let header =
410 view_utils::context_menu_header::<Message>(format!("Remote: {name}"), c.muted);
411
412 let local_exists =
414 state.active_tab().branches.iter().any(|b| {
415 b.branch_type == gitkraft_core::BranchType::Local && b.name == short_name
416 });
417
418 let mut col = column![header];
419
420 if !local_exists {
422 col = col.push(menu_item(
423 &format!("Checkout as '{short_name}'"),
424 Message::CheckoutRemoteBranch(name.clone()),
425 ));
426 }
427
428 col = col.push(view_utils::context_menu_separator::<Message>());
430 col = col.push(menu_item(
431 &format!("Delete from {remote}"),
432 Message::DeleteRemoteBranch(name.clone()),
433 ));
434
435 col = col.push(view_utils::context_menu_separator::<Message>());
437 col = col.push(menu_item(
438 "Copy branch name",
439 Message::CopyText(name.clone()),
440 ));
441 col = col.push(menu_item(
442 &format!("Copy short name '{short_name}'"),
443 Message::CopyText(short_name.to_string()),
444 ));
445
446 let tip_oid: Option<String> = state
448 .active_tab()
449 .branches
450 .iter()
451 .find(|b| &b.name == name)
452 .and_then(|b| b.target_oid.clone());
453
454 if let Some(ref oid) = tip_oid {
455 col = col.push(menu_item(
456 "Copy tip commit SHA",
457 Message::CopyText(oid.clone()),
458 ));
459 }
460
461 col.into()
462 }
463
464 Some(crate::state::ContextMenu::Commit { index, oid }) => {
465 let tab = state.active_tab();
466 let short = gitkraft_core::utils::short_oid_str(oid);
467 let msg_text = tab
468 .commits
469 .get(*index)
470 .map(|c| c.message.clone())
471 .unwrap_or_default();
472
473 let header =
474 view_utils::context_menu_header::<Message>(format!("Commit: {short}"), c.muted);
475
476 column![
477 header,
478 menu_item(
479 "Checkout (detached HEAD)",
480 Message::CheckoutCommitDetached(oid.clone()),
481 ),
482 menu_item(
483 "Rebase current branch onto this",
484 Message::RebaseOntoCommit(oid.clone()),
485 ),
486 menu_item("Revert commit", Message::RevertCommit(oid.clone())),
487 menu_item(
488 "Reset here — soft (keep staged)",
489 Message::ResetSoft(oid.clone())
490 ),
491 menu_item(
492 "Reset here — mixed (keep files)",
493 Message::ResetMixed(oid.clone())
494 ),
495 menu_item(
496 "Reset here — hard (discard all)",
497 Message::ResetHard(oid.clone())
498 ),
499 menu_item("Copy commit SHA", Message::CopyText(oid.clone())),
500 menu_item("Copy commit message", Message::CopyText(msg_text)),
501 ]
502 .into()
503 }
504
505 None => Space::new().into(),
506 };
507
508 container(content)
509 .width(280)
510 .style(theme::context_menu_style)
511 .into()
512}