Skip to main content

gitkraft_gui/features/branches/
view.rs

1//! Sidebar branch list — shows local and remote branches, with checkout,
2//! create, and delete actions.
3
4use gitkraft_core::BranchType;
5use iced::widget::{
6    button, column, container, mouse_area, row, scrollable, text, text_input, Space,
7};
8use iced::{Alignment, Element, Length};
9
10use crate::icons;
11use crate::message::Message;
12use crate::state::GitKraft;
13use crate::theme;
14use crate::view_utils;
15use crate::view_utils::truncate_to_fit;
16
17/// Render the branches sidebar panel.
18pub fn view(state: &GitKraft) -> Element<'_, Message> {
19    let tab = state.active_tab();
20    let c = state.colors();
21    let sidebar_width = state.sidebar_width;
22
23    let header_icon = icon!(icons::GIT_BRANCH, 14, c.accent);
24
25    let header_text = text("Branches").size(14).color(c.text_primary);
26
27    let toggle_icon_char = if tab.show_branch_create {
28        icons::DASH_CIRCLE
29    } else {
30        icons::PLUS_CIRCLE
31    };
32    let toggle_icon = icon!(toggle_icon_char, 14, c.accent);
33
34    let toggle_btn = button(toggle_icon)
35        .padding([2, 6])
36        .style(theme::icon_button)
37        .on_press(Message::ToggleBranchCreate);
38
39    let header_row = row![
40        header_icon,
41        Space::new().width(6),
42        header_text,
43        Space::new().width(Length::Fill),
44        toggle_btn,
45    ]
46    .align_y(Alignment::Center)
47    .padding([8, 10]);
48
49    // ── New branch form ───────────────────────────────────────────────────
50    let create_form: Element<'_, Message> = if tab.show_branch_create {
51        let input = text_input("new-branch-name", &tab.new_branch_name)
52            .on_input(Message::NewBranchNameChanged)
53            .padding(6)
54            .size(13);
55
56        let create_msg = (!tab.new_branch_name.trim().is_empty()).then_some(Message::CreateBranch);
57        let create_btn = view_utils::on_press_maybe(
58            button(text("Create").size(13))
59                .padding([4, 10])
60                .style(theme::toolbar_button),
61            create_msg,
62        );
63
64        container(column![input, create_btn,].spacing(4).width(Length::Fill))
65            .padding([4, 10])
66            .into()
67    } else {
68        Space::new().into()
69    };
70
71    // ── Inline rename form ────────────────────────────────────────────────
72    let rename_form: Element<'_, Message> = if let Some(ref orig) = tab.rename_branch_target {
73        let input = iced::widget::text_input("new branch name", &tab.rename_branch_input)
74            .on_input(Message::RenameBranchInputChanged)
75            .on_submit(Message::ConfirmRenameBranch)
76            .padding(6)
77            .size(13);
78
79        let rename_enabled = !tab.rename_branch_input.trim().is_empty()
80            && tab.rename_branch_input.trim() != orig.as_str();
81        let confirm_btn = view_utils::on_press_maybe(
82            button(text("Rename").size(13))
83                .padding([4, 10])
84                .style(theme::toolbar_button),
85            rename_enabled.then_some(Message::ConfirmRenameBranch),
86        );
87
88        let cancel_btn = button(text("Cancel").size(13))
89            .padding([4, 10])
90            .style(theme::toolbar_button)
91            .on_press(Message::CancelRename);
92
93        let hint = text(format!("Renaming '{orig}'")).size(11).color(c.muted);
94
95        container(
96            column![
97                hint,
98                input,
99                row![confirm_btn, Space::new().width(4), cancel_btn],
100            ]
101            .spacing(4)
102            .width(Length::Fill),
103        )
104        .padding([4, 10])
105        .into()
106    } else {
107        Space::new().into()
108    };
109
110    // ── Tag creation form ─────────────────────────────────────────────────
111    let tag_form: Element<'_, Message> = if let Some(ref oid) = tab.create_tag_target_oid {
112        let short_oid = gitkraft_core::utils::short_oid_str(oid);
113        let label = if tab.create_tag_annotated {
114            format!("Creating annotated tag at {short_oid}")
115        } else {
116            format!("Creating lightweight tag at {short_oid}")
117        };
118        let hint = text(label).size(11).color(c.muted);
119
120        let name_input = iced::widget::text_input("tag-name", &tab.create_tag_name)
121            .on_input(Message::TagNameChanged)
122            .on_submit(Message::ConfirmCreateTag)
123            .padding(6)
124            .size(13);
125
126        let tag_msg = (!tab.create_tag_name.trim().is_empty()).then_some(Message::ConfirmCreateTag);
127        let confirm_btn = view_utils::on_press_maybe(
128            button(text("Create tag").size(13))
129                .padding([4, 10])
130                .style(theme::toolbar_button),
131            tag_msg,
132        );
133
134        let cancel_btn = button(text("Cancel").size(13))
135            .padding([4, 10])
136            .style(theme::toolbar_button)
137            .on_press(Message::CancelCreateTag);
138
139        let mut form_col = column![hint, name_input].spacing(4).width(Length::Fill);
140
141        if tab.create_tag_annotated {
142            let msg_input = iced::widget::text_input("tag message", &tab.create_tag_message)
143                .on_input(Message::TagMessageChanged)
144                .padding(6)
145                .size(13);
146            form_col = form_col.push(msg_input);
147        }
148
149        form_col = form_col.push(row![confirm_btn, Space::new().width(4), cancel_btn]);
150
151        container(form_col).padding([4, 10]).into()
152    } else {
153        Space::new().into()
154    };
155
156    // ── Create branch at commit form ──────────────────────────────────────
157    let create_branch_at_form: Element<'_, Message> =
158        if let Some(ref oid) = tab.create_branch_at_oid {
159            let short_oid = gitkraft_core::utils::short_oid_str(oid);
160            let hint = text(format!("Creating branch at {short_oid}"))
161                .size(11)
162                .color(c.muted);
163
164            let name_input = iced::widget::text_input("branch-name", &tab.new_branch_name)
165                .on_input(Message::NewBranchNameChanged)
166                .on_submit(Message::ConfirmCreateBranchAtCommit)
167                .padding(6)
168                .size(13);
169
170            let confirm_msg = (!tab.new_branch_name.trim().is_empty())
171                .then_some(Message::ConfirmCreateBranchAtCommit);
172            let confirm_btn = view_utils::on_press_maybe(
173                button(text("Create").size(13))
174                    .padding([4, 10])
175                    .style(theme::toolbar_button),
176                confirm_msg,
177            );
178
179            let cancel_btn = button(text("Cancel").size(13))
180                .padding([4, 10])
181                .style(theme::toolbar_button)
182                .on_press(Message::CancelCreateBranchAtCommit);
183
184            container(
185                column![
186                    hint,
187                    name_input,
188                    row![confirm_btn, Space::new().width(4), cancel_btn],
189                ]
190                .spacing(4)
191                .width(Length::Fill),
192            )
193            .padding([4, 10])
194            .into()
195        } else {
196            Space::new().into()
197        };
198
199    // ── Branch list ───────────────────────────────────────────────────────
200    let local_branches: Vec<Element<'_, Message>> = tab
201        .branches
202        .iter()
203        .filter(|b| b.branch_type == BranchType::Local)
204        .enumerate()
205        .map(|(local_index, branch)| {
206            let is_current = branch.is_head;
207
208            let indicator: Element<'_, Message> = if is_current {
209                icon!(icons::CHECK_CIRCLE_FILL, 12, c.green).into()
210            } else {
211                icon!(icons::GIT_BRANCH, 12, c.muted).into()
212            };
213
214            let name_color = if is_current { c.green } else { c.text_primary };
215
216            // Available px: sidebar minus button padding(16) + indicator(14)
217            // + gap(6) + delete-btn(28) + row-spacing(2) ≈ 66 px overhead.
218            let name_available = (sidebar_width - 66.0).max(20.0);
219            let display_name = truncate_to_fit(branch.name.as_str(), name_available, 7.5);
220
221            let name_label = text(display_name)
222                .size(13)
223                .color(name_color)
224                .wrapping(iced::widget::text::Wrapping::None);
225
226            let checkout_msg =
227                (!is_current).then_some(Message::CheckoutBranch(branch.name.clone()));
228            let checkout_btn = view_utils::on_press_maybe(
229                button(
230                    row![indicator, Space::new().width(6), name_label].align_y(Alignment::Center),
231                )
232                .padding([4, 8])
233                .width(Length::Fill)
234                .style(theme::ghost_button),
235                checkout_msg,
236            );
237
238            let delete_icon = icon!(icons::TRASH, 12, c.red);
239
240            let delete_msg = (!is_current).then_some(Message::DeleteBranch(branch.name.clone()));
241            let delete_btn = view_utils::on_press_maybe(
242                button(delete_icon)
243                    .padding([4, 6])
244                    .style(theme::icon_button),
245                delete_msg,
246            );
247
248            let branch_row = row![checkout_btn, delete_btn]
249                .spacing(2)
250                .align_y(Alignment::Center)
251                .width(Length::Fill);
252
253            mouse_area(
254                container(branch_row)
255                    .width(Length::Fill)
256                    .height(Length::Fixed(28.0))
257                    .clip(true),
258            )
259            .on_right_press(Message::OpenBranchContextMenu(
260                branch.name.clone(),
261                local_index,
262                branch.is_head,
263            ))
264            .into()
265        })
266        .collect();
267
268    // ── Remote branches (with context menu) ───────────────────────────────
269    let remote_branches: Vec<Element<'_, Message>> = tab
270        .branches
271        .iter()
272        .filter(|b| b.branch_type == BranchType::Remote)
273        .map(|branch| {
274            let icon = icon!(icons::CLOUD, 12, c.muted);
275
276            // Available px: sidebar minus item padding(16) + icon(14) + gap(6)
277            // ≈ 36 px overhead.
278            let label_available = (sidebar_width - 36.0).max(20.0);
279            let display_remote = truncate_to_fit(branch.name.as_str(), label_available, 7.0);
280
281            let label = text(display_remote)
282                .size(12)
283                .color(c.text_secondary)
284                .wrapping(iced::widget::text::Wrapping::None);
285
286            let branch_btn =
287                button(row![icon, Space::new().width(6), label].align_y(Alignment::Center))
288                    .padding([2, 8])
289                    .width(Length::Fill)
290                    .style(theme::ghost_button)
291                    .on_press(Message::CheckoutRemoteBranch(branch.name.clone()));
292
293            mouse_area(
294                container(branch_btn)
295                    .width(Length::Fill)
296                    .height(Length::Fixed(22.0))
297                    .clip(true),
298            )
299            .on_right_press(Message::OpenRemoteBranchContextMenu(branch.name.clone()))
300            .into()
301        })
302        .collect();
303
304    let mut list_col = column![].spacing(1).width(Length::Fill);
305
306    if !local_branches.is_empty() || tab.local_branches_expanded {
307        let local_count = tab
308            .branches
309            .iter()
310            .filter(|b| b.branch_type == BranchType::Local)
311            .count();
312
313        let local_header_btn = view_utils::collapsible_header(
314            tab.local_branches_expanded,
315            "Local",
316            local_count,
317            Message::ToggleLocalBranches,
318            c.muted,
319        );
320        list_col = list_col.push(local_header_btn);
321
322        if tab.local_branches_expanded {
323            for item in local_branches {
324                list_col = list_col.push(item);
325            }
326        }
327    }
328
329    if !remote_branches.is_empty() || tab.remote_branches_expanded {
330        let remote_count = tab
331            .branches
332            .iter()
333            .filter(|b| b.branch_type == BranchType::Remote)
334            .count();
335
336        list_col = list_col.push(Space::new().height(4));
337
338        let remote_header_btn = view_utils::collapsible_header(
339            tab.remote_branches_expanded,
340            "Remote",
341            remote_count,
342            Message::ToggleRemoteBranches,
343            c.muted,
344        );
345        list_col = list_col.push(remote_header_btn);
346
347        if tab.remote_branches_expanded {
348            for item in remote_branches {
349                list_col = list_col.push(item);
350            }
351        }
352    }
353
354    let content = column![
355        header_row,
356        create_form,
357        rename_form,
358        tag_form,
359        create_branch_at_form,
360        scrollable(list_col)
361            .height(Length::Fill)
362            .direction(view_utils::thin_scrollbar())
363            .style(crate::theme::overlay_scrollbar),
364    ]
365    .width(Length::Fill)
366    .height(Length::Fill);
367
368    container(content)
369        .width(Length::Fill)
370        .height(Length::Fill)
371        .style(theme::sidebar_style)
372        .into()
373}