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(6, 0),
42        header_text,
43        Space::new(Length::Fill, 0),
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(0, 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![hint, input, row![confirm_btn, Space::new(4, 0), cancel_btn],]
97                .spacing(4)
98                .width(Length::Fill),
99        )
100        .padding([4, 10])
101        .into()
102    } else {
103        Space::new(0, 0).into()
104    };
105
106    // ── Tag creation form ─────────────────────────────────────────────────
107    let tag_form: Element<'_, Message> = if let Some(ref oid) = tab.create_tag_target_oid {
108        let short_oid = gitkraft_core::utils::short_oid_str(oid);
109        let label = if tab.create_tag_annotated {
110            format!("Creating annotated tag at {short_oid}")
111        } else {
112            format!("Creating lightweight tag at {short_oid}")
113        };
114        let hint = text(label).size(11).color(c.muted);
115
116        let name_input = iced::widget::text_input("tag-name", &tab.create_tag_name)
117            .on_input(Message::TagNameChanged)
118            .on_submit(Message::ConfirmCreateTag)
119            .padding(6)
120            .size(13);
121
122        let tag_msg = (!tab.create_tag_name.trim().is_empty()).then_some(Message::ConfirmCreateTag);
123        let confirm_btn = view_utils::on_press_maybe(
124            button(text("Create tag").size(13))
125                .padding([4, 10])
126                .style(theme::toolbar_button),
127            tag_msg,
128        );
129
130        let cancel_btn = button(text("Cancel").size(13))
131            .padding([4, 10])
132            .style(theme::toolbar_button)
133            .on_press(Message::CancelCreateTag);
134
135        let mut form_col = column![hint, name_input].spacing(4).width(Length::Fill);
136
137        if tab.create_tag_annotated {
138            let msg_input = iced::widget::text_input("tag message", &tab.create_tag_message)
139                .on_input(Message::TagMessageChanged)
140                .padding(6)
141                .size(13);
142            form_col = form_col.push(msg_input);
143        }
144
145        form_col = form_col.push(row![confirm_btn, Space::new(4, 0), cancel_btn]);
146
147        container(form_col).padding([4, 10]).into()
148    } else {
149        Space::new(0, 0).into()
150    };
151
152    // ── Branch list ───────────────────────────────────────────────────────
153    let local_branches: Vec<Element<'_, Message>> = tab
154        .branches
155        .iter()
156        .filter(|b| b.branch_type == BranchType::Local)
157        .enumerate()
158        .map(|(local_index, branch)| {
159            let is_current = branch.is_head;
160
161            let indicator: Element<'_, Message> = if is_current {
162                icon!(icons::CHECK_CIRCLE_FILL, 12, c.green).into()
163            } else {
164                icon!(icons::GIT_BRANCH, 12, c.muted).into()
165            };
166
167            let name_color = if is_current { c.green } else { c.text_primary };
168
169            // Available px: sidebar minus button padding(16) + indicator(14)
170            // + gap(6) + delete-btn(28) + row-spacing(2) ≈ 66 px overhead.
171            let name_available = (sidebar_width - 66.0).max(20.0);
172            let display_name = truncate_to_fit(branch.name.as_str(), name_available, 7.5);
173
174            let name_label = text(display_name)
175                .size(13)
176                .color(name_color)
177                .wrapping(iced::widget::text::Wrapping::None);
178
179            let checkout_msg =
180                (!is_current).then_some(Message::CheckoutBranch(branch.name.clone()));
181            let checkout_btn = view_utils::on_press_maybe(
182                button(row![indicator, Space::new(6, 0), name_label].align_y(Alignment::Center))
183                    .padding([4, 8])
184                    .width(Length::Fill)
185                    .style(theme::ghost_button),
186                checkout_msg,
187            );
188
189            let delete_icon = icon!(icons::TRASH, 12, c.red);
190
191            let delete_msg = (!is_current).then_some(Message::DeleteBranch(branch.name.clone()));
192            let delete_btn = view_utils::on_press_maybe(
193                button(delete_icon)
194                    .padding([4, 6])
195                    .style(theme::icon_button),
196                delete_msg,
197            );
198
199            let branch_row = row![checkout_btn, delete_btn]
200                .spacing(2)
201                .align_y(Alignment::Center)
202                .width(Length::Fill);
203
204            mouse_area(
205                container(branch_row)
206                    .width(Length::Fill)
207                    .height(Length::Fixed(28.0))
208                    .clip(true),
209            )
210            .on_right_press(Message::OpenBranchContextMenu(
211                branch.name.clone(),
212                local_index,
213                branch.is_head,
214            ))
215            .into()
216        })
217        .collect();
218
219    // ── Remote branches (with context menu) ───────────────────────────────
220    let remote_branches: Vec<Element<'_, Message>> = tab
221        .branches
222        .iter()
223        .filter(|b| b.branch_type == BranchType::Remote)
224        .map(|branch| {
225            let icon = icon!(icons::CLOUD, 12, c.muted);
226
227            // Available px: sidebar minus item padding(16) + icon(14) + gap(6)
228            // ≈ 36 px overhead.
229            let label_available = (sidebar_width - 36.0).max(20.0);
230            let display_remote = truncate_to_fit(branch.name.as_str(), label_available, 7.0);
231
232            let label = text(display_remote)
233                .size(12)
234                .color(c.text_secondary)
235                .wrapping(iced::widget::text::Wrapping::None);
236
237            let branch_btn = button(row![icon, Space::new(6, 0), label].align_y(Alignment::Center))
238                .padding([2, 8])
239                .width(Length::Fill)
240                .style(theme::ghost_button)
241                .on_press(Message::CheckoutRemoteBranch(branch.name.clone()));
242
243            mouse_area(
244                container(branch_btn)
245                    .width(Length::Fill)
246                    .height(Length::Fixed(22.0))
247                    .clip(true),
248            )
249            .on_right_press(Message::OpenRemoteBranchContextMenu(branch.name.clone()))
250            .into()
251        })
252        .collect();
253
254    let mut list_col = column![].spacing(1).width(Length::Fill);
255
256    if !local_branches.is_empty() || tab.local_branches_expanded {
257        let local_count = tab
258            .branches
259            .iter()
260            .filter(|b| b.branch_type == BranchType::Local)
261            .count();
262
263        let local_header_btn = view_utils::collapsible_header(
264            tab.local_branches_expanded,
265            "Local",
266            local_count,
267            Message::ToggleLocalBranches,
268            c.muted,
269        );
270        list_col = list_col.push(local_header_btn);
271
272        if tab.local_branches_expanded {
273            for item in local_branches {
274                list_col = list_col.push(item);
275            }
276        }
277    }
278
279    if !remote_branches.is_empty() || tab.remote_branches_expanded {
280        let remote_count = tab
281            .branches
282            .iter()
283            .filter(|b| b.branch_type == BranchType::Remote)
284            .count();
285
286        list_col = list_col.push(Space::new(0, 4));
287
288        let remote_header_btn = view_utils::collapsible_header(
289            tab.remote_branches_expanded,
290            "Remote",
291            remote_count,
292            Message::ToggleRemoteBranches,
293            c.muted,
294        );
295        list_col = list_col.push(remote_header_btn);
296
297        if tab.remote_branches_expanded {
298            for item in remote_branches {
299                list_col = list_col.push(item);
300            }
301        }
302    }
303
304    let content = column![
305        header_row,
306        create_form,
307        rename_form,
308        tag_form,
309        scrollable(list_col)
310            .height(Length::Fill)
311            .direction(view_utils::thin_scrollbar())
312            .style(crate::theme::overlay_scrollbar),
313    ]
314    .width(Length::Fill)
315    .height(Length::Fill);
316
317    container(content)
318        .width(Length::Fill)
319        .height(Length::Fill)
320        .style(theme::sidebar_style)
321        .into()
322}