Skip to main content

gitkraft_gui/
view.rs

1//! Main view entry point — builds the top-level layout shell and delegates to
2//! feature views for each region of the UI.
3//!
4//! Layout (when a repo is open):
5//! ```text
6//! ┌──────────────────────────────────────────┐
7//! │  header toolbar                          │
8//! ├────────┬─────────────────┬───────────────┤
9//! │        │                 │               │
10//! │ side-  │  commit log     │  diff viewer  │
11//! │ bar    │                 │               │
12//! │        │                 │               │
13//! ├────────┴─────────────────┴───────────────┤
14//! │  staging area (unstaged | staged | msg)  │
15//! ├──────────────────────────────────────────┤
16//! │  status bar                              │
17//! └──────────────────────────────────────────┘
18//! ```
19//!
20//! All vertical and horizontal dividers between panes are **draggable** — the
21//! user can resize the sidebar, commit-log, diff-viewer, and staging area by
22//! grabbing the thin divider lines and dragging.
23//!
24//! The outer-most widget is a `mouse_area` that captures `on_release` events
25//! unconditionally, and `on_move` events **only while a drag is in progress**.
26//! This avoids firing `PaneDragMove → update() → view()` on every cursor
27//! movement when no resize drag is active.
28
29use 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    /// Top-level view — called by the Iced runtime on every frame.
43    pub fn view(&self) -> Element<'_, Message> {
44        let c = self.colors();
45
46        // ── Tab bar (always visible) ──────────────────────────────────────
47        let tab_bar = widgets::tab_bar::view(self);
48
49        if !self.has_repo() {
50            // Show the tab bar above the welcome screen so users can
51            // switch between tabs even when the active one has no repo.
52            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        // ── Header toolbar ────────────────────────────────────────────────
66        let header = widgets::header::view(self);
67
68        // ── Sidebar (branches + stash + remotes) ──────────────────────────
69        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        // ── Commit log ────────────────────────────────────────────────────
97        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        // ── Diff viewer (fills all remaining horizontal space) ────────────
108        let diff_viewer = container(features::diff::view::view(self))
109            .width(Length::Fill)
110            .height(Length::Fill);
111
112        // ── Middle row: sidebar | divider | commit log | divider | diff ───
113        let middle = row![sidebar, commit_log, diff_viewer]
114            .height(Length::Fill)
115            .width(Length::Fill);
116
117        // ── Horizontal divider between middle and staging ─────────────────
118        let h_divider = widgets::divider::horizontal_divider(DragTargetH::StagingTop, &c);
119
120        // ── Staging area ──────────────────────────────────────────────────
121        let staging = container(features::staging::view::view(self))
122            .width(Length::Fill)
123            .height(Length::Fixed(self.staging_height));
124
125        // ── Status bar ────────────────────────────────────────────────────
126        let status_bar = status_bar_view(self);
127
128        // ── Error banner (if any) ─────────────────────────────────────────
129        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        // on_move is always active so cursor_pos stays current for context
150        // menus.  Virtual scrolling keeps the per-frame rebuild cost low
151        // (~66 commit rows instead of 500) so this is acceptable.
152        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        // ── Context menu overlay ──────────────────────────────────────────
158        if self.active_tab().context_menu.is_some() {
159            // Transparent full-screen backdrop — clicking it dismisses the menu.
160            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
183/// Render the status bar at the very bottom of the window.
184fn 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
262/// Render an error banner at the top of the window with a dismiss button.
263fn 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
289/// Approximate pixel position of the context menu based on what was right-clicked.
290fn context_menu_position(state: &GitKraft) -> (f32, f32) {
291    // Use the position that was frozen when the menu opened, not the live
292    // cursor_pos — otherwise the panel would follow the mouse.
293    // Nudge right/down by 2 px so the pointer tip sits just inside the panel.
294    let (x, y) = state.active_tab().context_menu_pos;
295    ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
296}
297
298/// Build the context menu panel widget for the currently active menu.
299fn 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            // Look up the branch tip OID for SHA copy and tag creation.
330            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            // Group 1: Checkout (when not on this branch)
342            if !is_current {
343                col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
344            }
345
346            // Group 2: Remote sync
347            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            // Group 3: Rebase / merge
354            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            // Group 4: Branch management
365            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            // Group 5: Copy info
374            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            // Group 6: Tag creation
387            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            // Extract remote and branch parts for display
406            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            // Check if a local branch with the same short name already exists
412            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            // Checkout (only if no local branch with same name exists)
420            if !local_exists {
421                col = col.push(menu_item(
422                    &format!("Checkout as '{short_name}'"),
423                    Message::CheckoutRemoteBranch(name.clone()),
424                ));
425            }
426
427            // Delete from remote
428            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            // Copy info
435            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            // Look up tip OID
446            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}