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::with_width(6),
42        header_text,
43        Space::with_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::with_height(0).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::with_width(4), cancel_btn],
100            ]
101            .spacing(4)
102            .width(Length::Fill),
103        )
104        .padding([4, 10])
105        .into()
106    } else {
107        Space::with_height(0).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::with_width(4), cancel_btn]);
150
151        container(form_col).padding([4, 10]).into()
152    } else {
153        Space::with_height(0).into()
154    };
155
156    // ── Branch list ───────────────────────────────────────────────────────
157    let local_branches: Vec<Element<'_, Message>> = tab
158        .branches
159        .iter()
160        .filter(|b| b.branch_type == BranchType::Local)
161        .enumerate()
162        .map(|(local_index, branch)| {
163            let is_current = branch.is_head;
164
165            let indicator: Element<'_, Message> = if is_current {
166                icon!(icons::CHECK_CIRCLE_FILL, 12, c.green).into()
167            } else {
168                icon!(icons::GIT_BRANCH, 12, c.muted).into()
169            };
170
171            let name_color = if is_current { c.green } else { c.text_primary };
172
173            // Available px: sidebar minus button padding(16) + indicator(14)
174            // + gap(6) + delete-btn(28) + row-spacing(2) ≈ 66 px overhead.
175            let name_available = (sidebar_width - 66.0).max(20.0);
176            let display_name = truncate_to_fit(branch.name.as_str(), name_available, 7.5);
177
178            let name_label = text(display_name)
179                .size(13)
180                .color(name_color)
181                .wrapping(iced::widget::text::Wrapping::None);
182
183            let checkout_msg =
184                (!is_current).then_some(Message::CheckoutBranch(branch.name.clone()));
185            let checkout_btn = view_utils::on_press_maybe(
186                button(
187                    row![indicator, Space::with_width(6), name_label].align_y(Alignment::Center),
188                )
189                .padding([4, 8])
190                .width(Length::Fill)
191                .style(theme::ghost_button),
192                checkout_msg,
193            );
194
195            let delete_icon = icon!(icons::TRASH, 12, c.red);
196
197            let delete_msg = (!is_current).then_some(Message::DeleteBranch(branch.name.clone()));
198            let delete_btn = view_utils::on_press_maybe(
199                button(delete_icon)
200                    .padding([4, 6])
201                    .style(theme::icon_button),
202                delete_msg,
203            );
204
205            let branch_row = row![checkout_btn, delete_btn]
206                .spacing(2)
207                .align_y(Alignment::Center)
208                .width(Length::Fill);
209
210            mouse_area(
211                container(branch_row)
212                    .width(Length::Fill)
213                    .height(Length::Fixed(28.0))
214                    .clip(true),
215            )
216            .on_right_press(Message::OpenBranchContextMenu(
217                branch.name.clone(),
218                local_index,
219                branch.is_head,
220            ))
221            .into()
222        })
223        .collect();
224
225    // ── Remote branches (with context menu) ───────────────────────────────
226    let remote_branches: Vec<Element<'_, Message>> = tab
227        .branches
228        .iter()
229        .filter(|b| b.branch_type == BranchType::Remote)
230        .map(|branch| {
231            let icon = icon!(icons::CLOUD, 12, c.muted);
232
233            // Available px: sidebar minus item padding(16) + icon(14) + gap(6)
234            // ≈ 36 px overhead.
235            let label_available = (sidebar_width - 36.0).max(20.0);
236            let display_remote = truncate_to_fit(branch.name.as_str(), label_available, 7.0);
237
238            let label = text(display_remote)
239                .size(12)
240                .color(c.text_secondary)
241                .wrapping(iced::widget::text::Wrapping::None);
242
243            let branch_btn =
244                button(row![icon, Space::with_width(6), label].align_y(Alignment::Center))
245                    .padding([2, 8])
246                    .width(Length::Fill)
247                    .style(theme::ghost_button)
248                    .on_press(Message::CheckoutRemoteBranch(branch.name.clone()));
249
250            mouse_area(
251                container(branch_btn)
252                    .width(Length::Fill)
253                    .height(Length::Fixed(22.0))
254                    .clip(true),
255            )
256            .on_right_press(Message::OpenRemoteBranchContextMenu(branch.name.clone()))
257            .into()
258        })
259        .collect();
260
261    let mut list_col = column![].spacing(1).width(Length::Fill);
262
263    if !local_branches.is_empty() || tab.local_branches_expanded {
264        let local_count = tab
265            .branches
266            .iter()
267            .filter(|b| b.branch_type == BranchType::Local)
268            .count();
269
270        let local_header_btn = view_utils::collapsible_header(
271            tab.local_branches_expanded,
272            "Local",
273            local_count,
274            Message::ToggleLocalBranches,
275            c.muted,
276        );
277        list_col = list_col.push(local_header_btn);
278
279        if tab.local_branches_expanded {
280            for item in local_branches {
281                list_col = list_col.push(item);
282            }
283        }
284    }
285
286    if !remote_branches.is_empty() || tab.remote_branches_expanded {
287        let remote_count = tab
288            .branches
289            .iter()
290            .filter(|b| b.branch_type == BranchType::Remote)
291            .count();
292
293        list_col = list_col.push(Space::with_height(4));
294
295        let remote_header_btn = view_utils::collapsible_header(
296            tab.remote_branches_expanded,
297            "Remote",
298            remote_count,
299            Message::ToggleRemoteBranches,
300            c.muted,
301        );
302        list_col = list_col.push(remote_header_btn);
303
304        if tab.remote_branches_expanded {
305            for item in remote_branches {
306                list_col = list_col.push(item);
307            }
308        }
309    }
310
311    let content = column![
312        header_row,
313        create_form,
314        rename_form,
315        tag_form,
316        scrollable(list_col)
317            .height(Length::Fill)
318            .direction(view_utils::thin_scrollbar())
319            .style(crate::theme::overlay_scrollbar),
320    ]
321    .width(Length::Fill)
322    .height(Length::Fill);
323
324    container(content)
325        .width(Length::Fill)
326        .height(Length::Fill)
327        .style(theme::sidebar_style)
328        .into()
329}