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 if !tab.commit_range_diffs.is_empty() {
38 let multi_panel = multi_diff_content(&tab.commit_range_diffs, &c, tab.diff_scroll_offset);
39 return container(multi_panel)
40 .width(Length::Fill)
41 .height(Length::Fill)
42 .style(theme::surface_style)
43 .into();
44 }
45
46 if tab.selected_commits.len() > 1 && tab.is_loading_file_diff {
48 return container(loading_diff_view(&c))
49 .width(Length::Fill)
50 .height(Length::Fill)
51 .style(theme::surface_style)
52 .into();
53 }
54
55 if !tab.multi_file_diffs.is_empty() {
58 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
59 let divider = crate::widgets::divider::vertical_divider(
60 crate::state::DragTarget::DiffFileListRight,
61 &c,
62 );
63 let multi_panel = multi_diff_content(&tab.multi_file_diffs, &c, tab.diff_scroll_offset);
64 return container(
65 row![file_list, divider, multi_panel]
66 .width(Length::Fill)
67 .height(Length::Fill),
68 )
69 .width(Length::Fill)
70 .height(Length::Fill)
71 .style(theme::surface_style)
72 .into();
73 }
74
75 match &tab.selected_diff {
76 Some(diff) => {
77 if !tab.commit_files.is_empty() {
78 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
80 let divider = crate::widgets::divider::vertical_divider(
81 crate::state::DragTarget::DiffFileListRight,
82 &c,
83 );
84 let diff_panel = diff_content(diff, &c, tab.diff_scroll_offset);
85
86 let layout = row![file_list, divider, diff_panel]
87 .width(Length::Fill)
88 .height(Length::Fill);
89
90 container(layout)
91 .width(Length::Fill)
92 .height(Length::Fill)
93 .style(theme::surface_style)
94 .into()
95 } else {
96 container(diff_content(diff, &c, tab.diff_scroll_offset))
98 .width(Length::Fill)
99 .height(Length::Fill)
100 .style(theme::surface_style)
101 .into()
102 }
103 }
104 None => {
105 if !tab.commit_files.is_empty() {
106 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
108 let divider = crate::widgets::divider::vertical_divider(
109 crate::state::DragTarget::DiffFileListRight,
110 &c,
111 );
112 let right_panel = if tab.is_loading_file_diff {
113 loading_diff_view(&c)
114 } else {
115 placeholder_view(&c)
116 };
117 container(
118 row![file_list, divider, right_panel]
119 .width(Length::Fill)
120 .height(Length::Fill),
121 )
122 .width(Length::Fill)
123 .height(Length::Fill)
124 .style(theme::surface_style)
125 .into()
126 } else {
127 container(placeholder_view(&c))
128 .width(Length::Fill)
129 .height(Length::Fill)
130 .style(theme::surface_style)
131 .into()
132 }
133 }
134 }
135}
136
137fn commit_file_list<'a>(state: &'a GitKraft, c: &ThemeColors, width: f32) -> Element<'a, Message> {
139 let tab = state.active_tab();
140
141 let header_icon = icon!(icons::FILE_DIFF, 13, c.accent);
142
143 let header_text = text("Files").size(13).color(c.text_primary);
144
145 let multi_count = tab.selected_commit_file_indices.len();
146 let file_count = if multi_count > 1 {
147 text(format!("({} selected)", multi_count))
148 .size(11)
149 .color(c.accent)
150 } else {
151 text(format!("({})", tab.commit_files.len()))
152 .size(11)
153 .color(c.muted)
154 };
155
156 let header_row = row![
157 header_icon,
158 Space::new().width(4),
159 header_text,
160 Space::new().width(4),
161 file_count,
162 ]
163 .align_y(Alignment::Center)
164 .padding([6, 8]);
165
166 let mut file_list_col = column![].spacing(1).width(Length::Fill);
167 let oid_for_menu = tab.selected_commit_oid.clone().unwrap_or_default();
168
169 for (idx, diff) in tab.commit_files.iter().enumerate() {
170 let file_name = diff.file_name();
172
173 let is_selected = tab.selected_file_index == Some(idx);
175 let is_multi_selected = tab.selected_commit_file_indices.contains(&idx);
176
177 let status_color = theme::status_color(&diff.status, c);
178
179 let status_char = format!("{}", diff.status);
180
181 let status_badge = text(status_char)
182 .size(11)
183 .font(Font::MONOSPACE)
184 .color(status_color);
185
186 let name_color = if is_selected || is_multi_selected {
187 c.text_primary
188 } else {
189 c.text_secondary
190 };
191
192 let name_label = text(file_name.to_string())
193 .size(12)
194 .color(name_color)
195 .wrapping(iced::widget::text::Wrapping::None);
196
197 let dir_hint: Element<'a, Message> = {
199 let short_dir = diff.short_parent_dir();
200 if short_dir.is_empty() {
201 Space::new().into()
202 } else {
203 text(format!("{short_dir}/"))
204 .size(10)
205 .color(c.muted)
206 .wrapping(iced::widget::text::Wrapping::None)
207 .into()
208 }
209 };
210
211 let selection_badge: Element<'a, Message> = if let Some(pos) = tab
213 .selected_commit_file_indices
214 .iter()
215 .position(|&i| i == idx)
216 {
217 container(
218 text(format!("{}", pos + 1))
219 .size(10)
220 .font(Font::MONOSPACE)
221 .color(c.accent),
222 )
223 .width(16)
224 .center_x(Length::Fixed(16.0))
225 .into()
226 } else {
227 Space::new().width(16).into()
228 };
229
230 let row_content = row![
231 selection_badge,
232 Space::new().width(2),
233 status_badge,
234 Space::new().width(4),
235 column![row![dir_hint, name_label].align_y(Alignment::Center),],
236 ]
237 .align_y(Alignment::Center)
238 .padding([4, 8])
239 .width(Length::Fill);
240
241 let style_fn = if is_selected {
244 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
245 } else if is_multi_selected {
246 theme::highlight_row_style as fn(&iced::Theme) -> iced::widget::container::Style
247 } else {
248 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
249 };
250
251 let file_btn = button(row_content)
252 .padding(0)
253 .width(Length::Fill)
254 .style(theme::ghost_button)
255 .on_press(Message::SelectDiffByIndex(idx));
256
257 let file_row = mouse_area(
258 container(file_btn)
259 .width(Length::Fill)
260 .height(Length::Fixed(26.0))
261 .clip(true)
262 .style(style_fn),
263 )
264 .on_right_press(Message::OpenCommitFileContextMenu(
265 oid_for_menu.clone(),
266 diff.display_path().to_string(),
267 ));
268
269 file_list_col = file_list_col.push(file_row);
270 }
271
272 let scrollable_files = scrollable(file_list_col)
273 .height(Length::Fill)
274 .direction(view_utils::thin_scrollbar())
275 .style(crate::theme::overlay_scrollbar);
276
277 container(
278 column![header_row, scrollable_files]
279 .width(Length::Fill)
280 .height(Length::Fill),
281 )
282 .width(Length::Fixed(width))
283 .height(Length::Fill)
284 .style(theme::sidebar_style)
285 .into()
286}
287
288fn multi_diff_content<'a>(
291 diffs: &'a [DiffInfo],
292 c: &ThemeColors,
293 _scroll_offset: f32,
294) -> Element<'a, Message> {
295 let mut col = column![].width(Length::Fill);
296
297 for diff in diffs {
298 let status_color = theme::status_color(&diff.status, c);
300 let header = container(
301 row![
302 text(format!(" {} ", diff.status))
303 .size(12)
304 .color(status_color)
305 .font(Font::MONOSPACE),
306 Space::new().width(8),
307 text(diff.display_path().to_string())
308 .size(13)
309 .color(c.text_primary)
310 .font(Font::MONOSPACE),
311 ]
312 .align_y(Alignment::Center),
313 )
314 .padding([6, 12])
315 .width(Length::Fill)
316 .style(theme::header_style);
317
318 col = col.push(header);
319
320 if diff.hunks.is_empty() {
321 col = col.push(
322 container(
323 text("No diff content.")
324 .size(13)
325 .color(c.muted)
326 .font(Font::MONOSPACE),
327 )
328 .padding([4, 12]),
329 );
330 } else {
331 for hunk in &diff.hunks {
332 for line in &hunk.lines {
333 col = col.push(render_line(line, c));
334 }
335 }
336 }
337
338 col = col.push(Space::new().height(8));
340 }
341
342 scrollable(col)
343 .height(Length::Fill)
344 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
345 .direction(view_utils::thin_scrollbar())
346 .style(crate::theme::overlay_scrollbar)
347 .into()
348}
349
350fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
352 view_utils::centered_placeholder(
353 icons::FILE_DIFF,
354 32,
355 "Select a commit or file to view diff",
356 c.muted,
357 )
358}
359
360fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
362 view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
363}
364
365fn diff_content<'a>(
369 diff: &'a DiffInfo,
370 c: &ThemeColors,
371 scroll_offset: f32,
372) -> Element<'a, Message> {
373 let file_path_display = diff.display_path().to_string();
374
375 let status_color = theme::status_color(&diff.status, c);
376
377 let status_badge = text(format!(" {} ", diff.status))
378 .size(12)
379 .color(status_color)
380 .font(Font::MONOSPACE);
381
382 let file_label = text(file_path_display)
383 .size(14)
384 .color(c.text_primary)
385 .font(Font::MONOSPACE);
386
387 let file_header = container(
388 row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
389 )
390 .padding([8, 12])
391 .width(Length::Fill)
392 .style(theme::header_style);
393
394 let mut lines_col = column![].width(Length::Fill);
395
396 if diff.hunks.is_empty() {
397 let empty_msg = text("No diff content available.")
398 .size(13)
399 .color(c.muted)
400 .font(Font::MONOSPACE);
401 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
402 } else {
403 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
405
406 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
407 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
408
409 let top_space = first as f32 * DIFF_LINE_HEIGHT;
410 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
411
412 if top_space > 0.0 {
413 lines_col = lines_col.push(Space::new().height(top_space));
414 }
415
416 let mut global_idx = 0usize;
418 for hunk in &diff.hunks {
419 for line in &hunk.lines {
420 if global_idx >= first && global_idx < last {
421 lines_col = lines_col.push(render_line(line, c));
422 }
423 global_idx += 1;
424 if global_idx >= last {
425 break;
426 }
427 }
428 if global_idx >= last {
429 break;
430 }
431 }
432
433 if bottom_space > 0.0 {
434 lines_col = lines_col.push(Space::new().height(bottom_space));
435 }
436 }
437
438 let scrollable_content = scrollable(lines_col)
439 .height(Length::Fill)
440 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
441 .direction(view_utils::thin_scrollbar())
442 .style(crate::theme::overlay_scrollbar);
443
444 column![file_header, scrollable_content]
445 .width(Length::Fill)
446 .height(Length::Fill)
447 .into()
448}
449
450fn diff_line_widget<'a>(
452 prefix: &'static str,
453 content_str: &str,
454 color: iced::Color,
455 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
456) -> Element<'a, Message> {
457 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
458 let content = text(content_str.to_string())
459 .size(13)
460 .font(Font::MONOSPACE)
461 .color(color);
462 let c = container(row![prefix_w, Space::new().width(4), content])
463 .padding([1, 12])
464 .width(Length::Fill);
465 match style {
466 Some(s) => c.style(s).into(),
467 None => c.into(),
468 }
469}
470
471fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
473 match line {
474 DiffLine::HunkHeader(header) => {
475 let content = text(header.clone())
476 .size(13)
477 .font(Font::MONOSPACE)
478 .color(c.accent);
479 container(content)
480 .padding([4, 12])
481 .width(Length::Fill)
482 .style(theme::diff_hunk_style)
483 .into()
484 }
485 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
486 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
487 DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
488 }
489}