gitkraft_gui/features/diff/
view.rs1use gitkraft_core::{DiffInfo, DiffLine};
13use iced::widget::{button, column, container, mouse_area, row, scrollable, text, Space};
14use iced::{Alignment, Element, Font, Length};
15
16use crate::icons;
17use crate::message::Message;
18use crate::state::GitKraft;
19use crate::theme;
20use crate::theme::ThemeColors;
21use crate::view_utils;
22
23const DIFF_LINE_HEIGHT: f32 = 22.0;
25const DIFF_OVERSCAN: usize = 20;
27const DIFF_VISIBLE_LINES: usize = 60;
29
30pub fn view(state: &GitKraft) -> Element<'_, Message> {
33 let c = state.colors();
34 let tab = state.active_tab();
35
36 match &tab.selected_diff {
37 Some(diff) => {
38 if !tab.commit_files.is_empty() {
39 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
41 let divider = crate::widgets::divider::vertical_divider(
42 crate::state::DragTarget::DiffFileListRight,
43 &c,
44 );
45 let diff_panel = diff_content(diff, &c, tab.diff_scroll_offset);
46
47 let layout = row![file_list, divider, diff_panel]
48 .width(Length::Fill)
49 .height(Length::Fill);
50
51 container(layout)
52 .width(Length::Fill)
53 .height(Length::Fill)
54 .style(theme::surface_style)
55 .into()
56 } else {
57 container(diff_content(diff, &c, tab.diff_scroll_offset))
59 .width(Length::Fill)
60 .height(Length::Fill)
61 .style(theme::surface_style)
62 .into()
63 }
64 }
65 None => {
66 if !tab.commit_files.is_empty() {
67 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
69 let divider = crate::widgets::divider::vertical_divider(
70 crate::state::DragTarget::DiffFileListRight,
71 &c,
72 );
73 let right_panel = if tab.is_loading_file_diff {
74 loading_diff_view(&c)
75 } else {
76 placeholder_view(&c)
77 };
78 container(
79 row![file_list, divider, right_panel]
80 .width(Length::Fill)
81 .height(Length::Fill),
82 )
83 .width(Length::Fill)
84 .height(Length::Fill)
85 .style(theme::surface_style)
86 .into()
87 } else {
88 container(placeholder_view(&c))
89 .width(Length::Fill)
90 .height(Length::Fill)
91 .style(theme::surface_style)
92 .into()
93 }
94 }
95 }
96}
97
98fn commit_file_list<'a>(state: &'a GitKraft, c: &ThemeColors, width: f32) -> Element<'a, Message> {
100 let tab = state.active_tab();
101
102 let header_icon = icon!(icons::FILE_DIFF, 13, c.accent);
103
104 let header_text = text("Files").size(13).color(c.text_primary);
105
106 let file_count = text(format!("({})", tab.commit_files.len()))
107 .size(11)
108 .color(c.muted);
109
110 let header_row = row![
111 header_icon,
112 Space::new().width(4),
113 header_text,
114 Space::new().width(4),
115 file_count,
116 ]
117 .align_y(Alignment::Center)
118 .padding([6, 8]);
119
120 let mut file_list_col = column![].spacing(1).width(Length::Fill);
121 let oid_for_menu = tab.selected_commit_oid.clone().unwrap_or_default();
122
123 for (idx, diff) in tab.commit_files.iter().enumerate() {
124 let file_name = diff.file_name();
126
127 let is_selected = tab.selected_file_index == Some(idx);
129
130 let status_color = theme::status_color(&diff.status, c);
131
132 let status_char = format!("{}", diff.status);
133
134 let status_badge = text(status_char)
135 .size(11)
136 .font(Font::MONOSPACE)
137 .color(status_color);
138
139 let name_color = if is_selected {
140 c.text_primary
141 } else {
142 c.text_secondary
143 };
144
145 let name_label = text(file_name.to_string())
146 .size(12)
147 .color(name_color)
148 .wrapping(iced::widget::text::Wrapping::None);
149
150 let dir_hint: Element<'a, Message> = {
152 let short_dir = diff.short_parent_dir();
153 if short_dir.is_empty() {
154 Space::new().into()
155 } else {
156 text(format!("{short_dir}/"))
157 .size(10)
158 .color(c.muted)
159 .wrapping(iced::widget::text::Wrapping::None)
160 .into()
161 }
162 };
163
164 let row_content = row![
165 status_badge,
166 Space::new().width(4),
167 column![row![dir_hint, name_label].align_y(Alignment::Center),],
168 ]
169 .align_y(Alignment::Center)
170 .padding([4, 8])
171 .width(Length::Fill);
172
173 let style_fn = if is_selected {
174 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
175 } else {
176 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
177 };
178
179 let file_btn = button(row_content)
180 .padding(0)
181 .width(Length::Fill)
182 .style(theme::ghost_button)
183 .on_press(Message::SelectDiffByIndex(idx));
184
185 let file_row = mouse_area(
186 container(file_btn)
187 .width(Length::Fill)
188 .height(Length::Fixed(26.0))
189 .clip(true)
190 .style(style_fn),
191 )
192 .on_right_press(Message::OpenCommitFileContextMenu(
193 oid_for_menu.clone(),
194 diff.display_path().to_string(),
195 ));
196
197 file_list_col = file_list_col.push(file_row);
198 }
199
200 let scrollable_files = scrollable(file_list_col)
201 .height(Length::Fill)
202 .direction(view_utils::thin_scrollbar())
203 .style(crate::theme::overlay_scrollbar);
204
205 container(
206 column![header_row, scrollable_files]
207 .width(Length::Fill)
208 .height(Length::Fill),
209 )
210 .width(Length::Fixed(width))
211 .height(Length::Fill)
212 .style(theme::sidebar_style)
213 .into()
214}
215
216fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
218 view_utils::centered_placeholder(
219 icons::FILE_DIFF,
220 32,
221 "Select a commit or file to view diff",
222 c.muted,
223 )
224}
225
226fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
228 view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
229}
230
231fn diff_content<'a>(
235 diff: &'a DiffInfo,
236 c: &ThemeColors,
237 scroll_offset: f32,
238) -> Element<'a, Message> {
239 let file_path_display = diff.display_path().to_string();
240
241 let status_color = theme::status_color(&diff.status, c);
242
243 let status_badge = text(format!(" {} ", diff.status))
244 .size(12)
245 .color(status_color)
246 .font(Font::MONOSPACE);
247
248 let file_label = text(file_path_display)
249 .size(14)
250 .color(c.text_primary)
251 .font(Font::MONOSPACE);
252
253 let file_header = container(
254 row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
255 )
256 .padding([8, 12])
257 .width(Length::Fill)
258 .style(theme::header_style);
259
260 let mut lines_col = column![].width(Length::Fill);
261
262 if diff.hunks.is_empty() {
263 let empty_msg = text("No diff content available.")
264 .size(13)
265 .color(c.muted)
266 .font(Font::MONOSPACE);
267 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
268 } else {
269 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
271
272 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
273 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
274
275 let top_space = first as f32 * DIFF_LINE_HEIGHT;
276 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
277
278 if top_space > 0.0 {
279 lines_col = lines_col.push(Space::new().height(top_space));
280 }
281
282 let mut global_idx = 0usize;
284 for hunk in &diff.hunks {
285 for line in &hunk.lines {
286 if global_idx >= first && global_idx < last {
287 lines_col = lines_col.push(render_line(line, c));
288 }
289 global_idx += 1;
290 if global_idx >= last {
291 break;
292 }
293 }
294 if global_idx >= last {
295 break;
296 }
297 }
298
299 if bottom_space > 0.0 {
300 lines_col = lines_col.push(Space::new().height(bottom_space));
301 }
302 }
303
304 let scrollable_content = scrollable(lines_col)
305 .height(Length::Fill)
306 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
307 .direction(view_utils::thin_scrollbar())
308 .style(crate::theme::overlay_scrollbar);
309
310 column![file_header, scrollable_content]
311 .width(Length::Fill)
312 .height(Length::Fill)
313 .into()
314}
315
316fn diff_line_widget<'a>(
318 prefix: &'static str,
319 content_str: &str,
320 color: iced::Color,
321 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
322) -> Element<'a, Message> {
323 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
324 let content = text(content_str.to_string())
325 .size(13)
326 .font(Font::MONOSPACE)
327 .color(color);
328 let c = container(row![prefix_w, Space::new().width(4), content])
329 .padding([1, 12])
330 .width(Length::Fill);
331 match style {
332 Some(s) => c.style(s).into(),
333 None => c.into(),
334 }
335}
336
337fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
339 match line {
340 DiffLine::HunkHeader(header) => {
341 let content = text(header.clone())
342 .size(13)
343 .font(Font::MONOSPACE)
344 .color(c.accent);
345 container(content)
346 .padding([4, 12])
347 .width(Length::Fill)
348 .style(theme::diff_hunk_style)
349 .into()
350 }
351 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
352 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
353 DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
354 }
355}