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::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        // ── 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().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
184/// Render the status bar at the very bottom of the window.
185fn 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
263/// Render an error banner at the top of the window with a dismiss button.
264fn 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
290/// Approximate pixel position of the context menu based on what was right-clicked.
291fn context_menu_position(state: &GitKraft) -> (f32, f32) {
292    // Use the position that was frozen when the menu opened, not the live
293    // cursor_pos — otherwise the panel would follow the mouse.
294    // Nudge right/down by 2 px so the pointer tip sits just inside the panel.
295    let (x, y) = state.active_tab().context_menu_pos;
296    ((x + 2.0).max(2.0), (y + 2.0).max(2.0))
297}
298
299/// Build the context menu panel widget for the currently active menu.
300fn 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            // Look up the branch tip OID for SHA copy and tag creation.
331            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            // Group 1: Checkout (when not on this branch)
343            if !is_current {
344                col = col.push(menu_item("Checkout", Message::CheckoutBranch(name.clone())));
345            }
346
347            // Group 2: Remote sync
348            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            // Group 3: Rebase / merge
355            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            // Group 4: Branch management
366            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            // Group 5: Copy info
375            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            // Group 6: Tag creation
388            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            // Extract remote and branch parts for display
407            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            // Check if a local branch with the same short name already exists
413            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            // Checkout (only if no local branch with same name exists)
421            if !local_exists {
422                col = col.push(menu_item(
423                    &format!("Checkout as '{short_name}'"),
424                    Message::CheckoutRemoteBranch(name.clone()),
425                ));
426            }
427
428            // Delete from remote
429            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            // Copy info
436            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            // Look up tip OID
447            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}