1use 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, state.animation_tick))
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, state.animation_tick)
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, animation_tick: u64) -> Element<'a, Message> {
362 let frames = tui_spinner::FluxFrames::CORNERS;
363 let frame = frames[animation_tick as usize % frames.len()].to_string();
364 let label = format!("{frame} Loading diff…");
365 container(text(label).size(14).color(c.muted).font(Font::MONOSPACE))
366 .width(Length::Fill)
367 .height(Length::Fill)
368 .center_x(Length::Fill)
369 .center_y(Length::Fill)
370 .into()
371}
372
373fn diff_content<'a>(
377 diff: &'a DiffInfo,
378 c: &ThemeColors,
379 scroll_offset: f32,
380) -> Element<'a, Message> {
381 let file_path_display = diff.display_path().to_string();
382
383 let status_color = theme::status_color(&diff.status, c);
384
385 let status_badge = text(format!(" {} ", diff.status))
386 .size(12)
387 .color(status_color)
388 .font(Font::MONOSPACE);
389
390 let file_label = text(file_path_display)
391 .size(14)
392 .color(c.text_primary)
393 .font(Font::MONOSPACE);
394
395 let file_header = container(
396 row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
397 )
398 .padding([8, 12])
399 .width(Length::Fill)
400 .style(theme::header_style);
401
402 let mut lines_col = column![].width(Length::Fill);
403
404 if diff.hunks.is_empty() {
405 let empty_msg = text("No diff content available.")
406 .size(13)
407 .color(c.muted)
408 .font(Font::MONOSPACE);
409 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
410 } else {
411 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
413
414 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
415 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
416
417 let top_space = first as f32 * DIFF_LINE_HEIGHT;
418 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
419
420 if top_space > 0.0 {
421 lines_col = lines_col.push(Space::new().height(top_space));
422 }
423
424 let mut global_idx = 0usize;
426 for hunk in &diff.hunks {
427 for line in &hunk.lines {
428 if global_idx >= first && global_idx < last {
429 lines_col = lines_col.push(render_line(line, c));
430 }
431 global_idx += 1;
432 if global_idx >= last {
433 break;
434 }
435 }
436 if global_idx >= last {
437 break;
438 }
439 }
440
441 if bottom_space > 0.0 {
442 lines_col = lines_col.push(Space::new().height(bottom_space));
443 }
444 }
445
446 let scrollable_content = scrollable(lines_col)
447 .height(Length::Fill)
448 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
449 .direction(view_utils::thin_scrollbar())
450 .style(crate::theme::overlay_scrollbar);
451
452 column![file_header, scrollable_content]
453 .width(Length::Fill)
454 .height(Length::Fill)
455 .into()
456}
457
458fn diff_line_widget<'a>(
460 prefix: &'static str,
461 content_str: &str,
462 color: iced::Color,
463 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
464) -> Element<'a, Message> {
465 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
466 let content = text(content_str.to_string())
467 .size(13)
468 .font(Font::MONOSPACE)
469 .color(color);
470 let c = container(row![prefix_w, Space::new().width(4), content])
471 .padding([1, 12])
472 .width(Length::Fill);
473 match style {
474 Some(s) => c.style(s).into(),
475 None => c.into(),
476 }
477}
478
479fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
481 match line {
482 DiffLine::HunkHeader(header) => {
483 let content = text(header.clone())
484 .size(13)
485 .font(Font::MONOSPACE)
486 .color(c.accent);
487 container(content)
488 .padding([4, 12])
489 .width(Length::Fill)
490 .style(theme::diff_hunk_style)
491 .into()
492 }
493 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
494 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
495 DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
496 }
497}
498
499pub fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
503 let tab = state.active_tab();
504 let c = state.colors();
505 let path = tab.file_history_path.as_deref().unwrap_or("");
506 let file_name = path.rsplit('/').next().unwrap_or(path);
507
508 let close_btn = button(text("✕").size(13).color(c.muted))
509 .padding([2, 8])
510 .style(theme::ghost_button)
511 .on_press(Message::CloseFileHistory);
512
513 let header = row![
514 icon!(icons::CLOCK, 14, c.accent),
515 Space::new().width(6),
516 text(format!("File History: {file_name}"))
517 .size(14)
518 .color(c.text_primary),
519 Space::new().width(Length::Fill),
520 close_btn,
521 ]
522 .align_y(Alignment::Center)
523 .padding([8, 10]);
524
525 let body: Element<'_, Message> = if tab.file_history_commits.is_empty() {
526 let msg = if tab.is_loading {
527 "Loading…"
528 } else {
529 "No commits touch this file."
530 };
531 container(text(msg).size(13).color(c.muted))
532 .width(Length::Fill)
533 .padding(20)
534 .center_x(Length::Fill)
535 .into()
536 } else {
537 let mut list = column![].width(Length::Fill);
538 for commit in &tab.file_history_commits {
539 let short = commit.short_oid.as_str();
540 let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
541 let rel_time = commit.relative_time();
542
543 let row_content = row![
544 text(short).size(11).color(c.accent).font(Font::MONOSPACE),
545 Space::new().width(8),
546 container(
547 text(summary)
548 .size(12)
549 .color(c.text_primary)
550 .wrapping(iced::widget::text::Wrapping::None),
551 )
552 .width(Length::Fill)
553 .clip(true),
554 Space::new().width(8),
555 text(rel_time).size(11).color(c.muted),
556 ]
557 .align_y(Alignment::Center)
558 .padding([3, 8]);
559
560 let oid = commit.oid.clone();
561 list = list.push(
562 button(row_content)
563 .padding(0)
564 .width(Length::Fill)
565 .style(theme::ghost_button)
566 .on_press(Message::SelectFileHistoryCommit(oid)),
567 );
568 }
569
570 scrollable(list)
571 .height(Length::Fill)
572 .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
573 .direction(view_utils::thin_scrollbar())
574 .style(theme::overlay_scrollbar)
575 .into()
576 };
577
578 let content = column![header, body]
579 .width(Length::Fill)
580 .height(Length::Fill);
581
582 view_utils::surface_panel(content, Length::Fill)
583}
584
585pub fn blame_view(state: &GitKraft) -> Element<'_, Message> {
589 let tab = state.active_tab();
590 let c = state.colors();
591 let path = tab.blame_path.as_deref().unwrap_or("");
592 let file_name = path.rsplit('/').next().unwrap_or(path);
593
594 let close_btn = button(
595 row![
596 text("✕").size(12).color(c.text_primary),
597 Space::new().width(4),
598 text("Close").size(12).color(c.text_primary),
599 Space::new().width(4),
600 text("[Esc]").size(11).color(c.muted),
601 ]
602 .align_y(Alignment::Center),
603 )
604 .padding([4, 10])
605 .style(theme::toolbar_button)
606 .on_press(Message::CloseFileBlame);
607
608 let header = row![
609 icon!(icons::CLOCK, 14, c.accent),
610 Space::new().width(6),
611 text(format!("Blame: {file_name}"))
612 .size(14)
613 .color(c.text_primary),
614 Space::new().width(Length::Fill),
615 close_btn,
616 ]
617 .align_y(Alignment::Center)
618 .padding([8, 10]);
619
620 let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
621 let msg = if tab.is_loading {
622 "Loading…"
623 } else {
624 "No blame data."
625 };
626 container(text(msg).size(13).color(c.muted))
627 .width(Length::Fill)
628 .padding(20)
629 .center_x(Length::Fill)
630 .into()
631 } else {
632 let mut list = column![].width(Length::Fill);
633 for line in &tab.blame_lines {
634 let rel_time = line.relative_time();
635 let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
636 let line_content = container(
637 text(line.content.as_str())
638 .size(11)
639 .color(c.text_primary)
640 .font(Font::MONOSPACE)
641 .wrapping(iced::widget::text::Wrapping::None),
642 )
643 .width(Length::Fill)
644 .clip(true);
645
646 let blame_row = row![
647 text(line.short_oid.as_str())
648 .size(10)
649 .color(c.accent)
650 .font(Font::MONOSPACE),
651 Space::new().width(6),
652 container(
653 text(author)
654 .size(10)
655 .color(c.text_secondary)
656 .wrapping(iced::widget::text::Wrapping::None),
657 )
658 .width(80)
659 .clip(true),
660 Space::new().width(4),
661 container(
662 text(rel_time)
663 .size(10)
664 .color(c.muted)
665 .wrapping(iced::widget::text::Wrapping::None),
666 )
667 .width(54)
668 .clip(true),
669 Space::new().width(4),
670 container(
671 text(format!("{:4}", line.line_number))
672 .size(10)
673 .color(c.muted)
674 .font(Font::MONOSPACE),
675 )
676 .width(32),
677 Space::new().width(6),
678 line_content,
679 ]
680 .align_y(Alignment::Center)
681 .padding([1, 8]);
682
683 list = list.push(blame_row);
684 }
685
686 scrollable(list)
687 .height(Length::Fill)
688 .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
689 .direction(view_utils::thin_scrollbar())
690 .style(theme::overlay_scrollbar)
691 .into()
692 };
693
694 let content = column![header, body]
695 .width(Length::Fill)
696 .height(Length::Fill);
697
698 view_utils::surface_panel(content, Length::Fill)
699}