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