Skip to main content

gitkraft_gui/features/staging/
view.rs

1//! Staging area view — shows unstaged changes, staged changes, and commit
2//! message input side-by-side as three columns at the bottom of the main
3//! layout.
4
5use iced::widget::{
6    button, column, container, mouse_area, row, scrollable, text, text_input, Space,
7};
8use iced::{Alignment, Element, Font, Length};
9
10use crate::icons;
11use crate::message::Message;
12use crate::state::GitKraft;
13use crate::theme;
14use crate::view_utils;
15
16/// Render the full staging area panel (unstaged | staged | commit input).
17pub fn view(state: &GitKraft) -> Element<'_, Message> {
18    let unstaged_panel = unstaged_view(state);
19    let staged_panel = staged_view(state);
20    let commit_panel = commit_view(state);
21
22    let content = row![unstaged_panel, staged_panel, commit_panel]
23        .spacing(1)
24        .height(Length::Fill)
25        .width(Length::Fill);
26
27    container(content)
28        .width(Length::Fill)
29        .style(theme::surface_style)
30        .into()
31}
32
33/// Render the "Unstaged Changes" file list.
34fn unstaged_view(state: &GitKraft) -> Element<'_, Message> {
35    let tab = state.active_tab();
36    let c = state.colors();
37
38    let header_icon = icon!(icons::FILE_DIFF, 13, c.yellow);
39
40    let header_label = text("Unstaged").size(13).color(c.text_primary);
41
42    let count_label = text(format!("({})", tab.unstaged_changes.len()))
43        .size(11)
44        .color(c.muted);
45
46    let stage_msg = (!tab.unstaged_changes.is_empty()).then_some(Message::StageAll);
47    let stage_all_btn = view_utils::on_press_maybe(
48        button(text("Stage All").size(11))
49            .padding([2, 8])
50            .style(theme::toolbar_button),
51        stage_msg,
52    );
53
54    let header_row = row![
55        header_icon,
56        Space::new().width(4),
57        header_label,
58        Space::new().width(4),
59        count_label,
60        Space::new().width(Length::Fill),
61        stage_all_btn,
62    ]
63    .align_y(Alignment::Center)
64    .padding([6, 8]);
65
66    let file_rows: Vec<Element<'_, Message>> = tab
67        .unstaged_changes
68        .iter()
69        .map(|diff| {
70            let file_path_display = diff.display_path();
71
72            let status_color = theme::status_color(&diff.status, &c);
73
74            let status_badge = text(format!("{}", diff.status))
75                .size(11)
76                .font(Font::MONOSPACE)
77                .color(status_color);
78
79            let is_selected = tab.selected_unstaged.contains(file_path_display);
80
81            let name_color = if is_selected {
82                c.accent
83            } else {
84                c.text_primary
85            };
86            let file_label = text(file_path_display)
87                .size(12)
88                .color(name_color)
89                .wrapping(iced::widget::text::Wrapping::None);
90
91            let view_btn = button(icon!(icons::CLOUD_UPLOAD, 11, c.accent))
92                .padding([2, 4])
93                .style(theme::icon_button)
94                .on_press(Message::SelectDiff(diff.clone()));
95
96            let stage_btn = button(icon!(icons::PLUS_CIRCLE, 11, c.green))
97                .padding([2, 4])
98                .style(theme::icon_button)
99                .on_press(Message::StageFile(file_path_display.to_string()));
100
101            let discard_btn = button(icon!(icons::TRASH, 11, c.red))
102                .padding([2, 4])
103                .style(theme::icon_button)
104                .on_press(Message::DiscardFile(file_path_display.to_string()));
105
106            let file_row = row![
107                status_badge,
108                Space::new().width(6),
109                file_label,
110                Space::new().width(Length::Fill),
111                view_btn,
112                Space::new().width(2),
113                stage_btn,
114                Space::new().width(2),
115                discard_btn,
116            ]
117            .align_y(Alignment::Center)
118            .padding([2, 8]);
119
120            let row_style = if is_selected {
121                theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
122            } else {
123                theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
124            };
125
126            mouse_area(container(file_row).width(Length::Fill).style(row_style))
127                .on_press(Message::ToggleSelectUnstaged(file_path_display.to_string()))
128                .on_right_press(Message::OpenUnstagedFileContextMenu(
129                    file_path_display.to_string(),
130                ))
131                .into()
132        })
133        .collect();
134
135    let mut list_col = column![].spacing(1).width(Length::Fill);
136
137    if file_rows.is_empty() {
138        list_col = list_col.push(view_utils::empty_list_hint("No unstaged changes", c.muted));
139    } else {
140        for row_el in file_rows {
141            list_col = list_col.push(row_el);
142        }
143    }
144
145    let content = column![
146        header_row,
147        scrollable(list_col)
148            .height(Length::Fill)
149            .direction(view_utils::thin_scrollbar())
150            .style(crate::theme::overlay_scrollbar)
151    ]
152    .width(Length::Fill)
153    .height(Length::Fill);
154
155    view_utils::surface_panel(content, Length::FillPortion(3))
156}
157
158/// Render the "Staged Changes" file list.
159fn staged_view(state: &GitKraft) -> Element<'_, Message> {
160    let tab = state.active_tab();
161    let c = state.colors();
162
163    let header_icon = icon!(icons::CHECK_CIRCLE_FILL, 13, c.green);
164
165    let header_label = text("Staged").size(13).color(c.text_primary);
166
167    let count_label = text(format!("({})", tab.staged_changes.len()))
168        .size(11)
169        .color(c.muted);
170
171    let unstage_msg = (!tab.staged_changes.is_empty()).then_some(Message::UnstageAll);
172    let unstage_all_btn = view_utils::on_press_maybe(
173        button(text("Unstage All").size(11))
174            .padding([2, 8])
175            .style(theme::toolbar_button),
176        unstage_msg,
177    );
178
179    let header_row = row![
180        header_icon,
181        Space::new().width(4),
182        header_label,
183        Space::new().width(4),
184        count_label,
185        Space::new().width(Length::Fill),
186        unstage_all_btn,
187    ]
188    .align_y(Alignment::Center)
189    .padding([6, 8]);
190
191    let file_rows: Vec<Element<'_, Message>> = tab
192        .staged_changes
193        .iter()
194        .map(|diff| {
195            let file_path_display = diff.display_path();
196
197            let status_color = theme::status_color(&diff.status, &c);
198
199            let status_badge = text(format!("{}", diff.status))
200                .size(11)
201                .font(Font::MONOSPACE)
202                .color(status_color);
203
204            let is_selected = tab.selected_staged.contains(file_path_display);
205
206            let name_color = if is_selected {
207                c.accent
208            } else {
209                c.text_primary
210            };
211            let file_label = text(file_path_display)
212                .size(12)
213                .color(name_color)
214                .wrapping(iced::widget::text::Wrapping::None);
215
216            let view_btn = button(icon!(icons::CLOUD_UPLOAD, 11, c.accent))
217                .padding([2, 4])
218                .style(theme::icon_button)
219                .on_press(Message::SelectDiff(diff.clone()));
220
221            let unstage_btn = button(icon!(icons::DASH_CIRCLE, 11, c.yellow))
222                .padding([2, 4])
223                .style(theme::icon_button)
224                .on_press(Message::UnstageFile(file_path_display.to_string()));
225
226            let file_row = row![
227                status_badge,
228                Space::new().width(6),
229                file_label,
230                Space::new().width(Length::Fill),
231                view_btn,
232                Space::new().width(2),
233                unstage_btn,
234            ]
235            .align_y(Alignment::Center)
236            .padding([2, 8]);
237
238            let row_style = if is_selected {
239                theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
240            } else {
241                theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
242            };
243
244            mouse_area(container(file_row).width(Length::Fill).style(row_style))
245                .on_press(Message::ToggleSelectStaged(file_path_display.to_string()))
246                .on_right_press(Message::OpenStagedFileContextMenu(
247                    file_path_display.to_string(),
248                ))
249                .into()
250        })
251        .collect();
252
253    let mut list_col = column![].spacing(1).width(Length::Fill);
254
255    if file_rows.is_empty() {
256        list_col = list_col.push(view_utils::empty_list_hint("No staged changes", c.muted));
257    } else {
258        for row_el in file_rows {
259            list_col = list_col.push(row_el);
260        }
261    }
262
263    let content = column![
264        header_row,
265        scrollable(list_col)
266            .height(Length::Fill)
267            .direction(view_utils::thin_scrollbar())
268            .style(crate::theme::overlay_scrollbar)
269    ]
270    .width(Length::Fill)
271    .height(Length::Fill);
272
273    view_utils::surface_panel(content, Length::FillPortion(3))
274}
275
276/// Render the commit message input and "Commit" button.
277fn commit_view(state: &GitKraft) -> Element<'_, Message> {
278    let tab = state.active_tab();
279    let c = state.colors();
280
281    let header_icon = icon!(icons::COMMIT, 13, c.accent);
282
283    let header_label = text("Commit").size(13).color(c.text_primary);
284
285    let header_row = row![header_icon, Space::new().width(4), header_label,]
286        .align_y(Alignment::Center)
287        .padding([6, 8]);
288
289    let input = text_input("Commit message…", &tab.commit_message)
290        .on_input(Message::CommitMessageChanged)
291        .padding(8)
292        .size(13);
293
294    let can_commit = !tab.commit_message.trim().is_empty() && !tab.staged_changes.is_empty();
295
296    let commit_icon = icon!(
297        icons::CHECK_CIRCLE_FILL,
298        14,
299        if can_commit { c.green } else { c.muted }
300    );
301
302    let commit_btn_content = row![commit_icon, Space::new().width(6), text("Commit").size(13),]
303        .align_y(Alignment::Center);
304
305    let commit_msg = can_commit.then_some(Message::CreateCommit);
306    let commit_btn = view_utils::on_press_maybe(
307        button(commit_btn_content)
308            .padding([8, 16])
309            .width(Length::Fill)
310            .style(theme::toolbar_button),
311        commit_msg,
312    );
313
314    let staged_hint = if tab.staged_changes.is_empty() {
315        text("Stage files before committing")
316            .size(11)
317            .color(c.muted)
318    } else {
319        text(format!("{} file(s) staged", tab.staged_changes.len()))
320            .size(11)
321            .color(c.text_secondary)
322    };
323
324    let content = column![
325        header_row,
326        container(
327            column![
328                input,
329                Space::new().height(6),
330                commit_btn,
331                Space::new().height(4),
332                staged_hint,
333            ]
334            .spacing(2)
335            .width(Length::Fill),
336        )
337        .padding([4, 8]),
338    ]
339    .width(Length::Fill)
340    .height(Length::Fill);
341
342    view_utils::surface_panel(content, Length::FillPortion(2))
343}