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(crate) fn view(state: &GitKraft) -> Element<'_, Message> {
33 let c = state.colors();
34 let tab = state.active_tab();
35
36 if let (Some(content), Some(path)) = (&state.preview_editor, &state.preview_path) {
38 let file_name = gitkraft_core::path_basename(path);
39
40 let close_btn = button(text("\u{2715}").size(13).color(c.muted))
41 .padding([2, 8])
42 .style(theme::ghost_button)
43 .on_press(Message::ClosePreview);
44
45 let header = row![
46 icon!(icons::FILE_TEXT, 14, c.accent),
47 Space::new().width(6),
48 text(format!("Preview: {file_name}"))
49 .size(14)
50 .color(c.text_primary),
51 Space::new().width(Length::Fill),
52 close_btn,
53 ]
54 .align_y(Alignment::Center)
55 .padding([8, 10]);
56
57 let editor = iced::widget::text_editor(content)
58 .on_action(Message::PreviewEditorAction)
59 .font(Font::MONOSPACE)
60 .size(13)
61 .height(Length::Fill);
62
63 let panel = column![header, editor]
64 .width(Length::Fill)
65 .height(Length::Fill);
66
67 return container(panel)
68 .width(Length::Fill)
69 .height(Length::Fill)
70 .style(theme::surface_style)
71 .into();
72 }
73
74 if !tab.commit_range_diffs.is_empty() {
76 let multi_panel = multi_diff_content(&tab.commit_range_diffs, &c, tab.diff_scroll_offset);
77 return container(multi_panel)
78 .width(Length::Fill)
79 .height(Length::Fill)
80 .style(theme::surface_style)
81 .into();
82 }
83
84 if tab.selected_commits.len() > 1 && tab.is_loading_file_diff {
86 return container(loading_diff_view(&c, state.animation_tick))
87 .width(Length::Fill)
88 .height(Length::Fill)
89 .style(theme::surface_style)
90 .into();
91 }
92
93 if !tab.multi_file_diffs.is_empty() {
96 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
97 let divider = crate::widgets::divider::vertical_divider(
98 crate::state::DragTarget::DiffFileListRight,
99 &c,
100 );
101 let multi_panel = multi_diff_content(&tab.multi_file_diffs, &c, tab.diff_scroll_offset);
102 return container(
103 row![file_list, divider, multi_panel]
104 .width(Length::Fill)
105 .height(Length::Fill),
106 )
107 .width(Length::Fill)
108 .height(Length::Fill)
109 .style(theme::surface_style)
110 .into();
111 }
112
113 match &tab.selected_diff {
114 Some(diff) => {
115 if !tab.commit_files.is_empty() {
116 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
118 let divider = crate::widgets::divider::vertical_divider(
119 crate::state::DragTarget::DiffFileListRight,
120 &c,
121 );
122 let diff_panel = diff_content(diff, &c, tab.diff_scroll_offset);
123
124 let layout = row![file_list, divider, diff_panel]
125 .width(Length::Fill)
126 .height(Length::Fill);
127
128 container(layout)
129 .width(Length::Fill)
130 .height(Length::Fill)
131 .style(theme::surface_style)
132 .into()
133 } else {
134 container(diff_content(diff, &c, tab.diff_scroll_offset))
136 .width(Length::Fill)
137 .height(Length::Fill)
138 .style(theme::surface_style)
139 .into()
140 }
141 }
142 None => {
143 if !tab.commit_files.is_empty() {
144 let file_list = commit_file_list(state, &c, state.diff_file_list_width);
146 let divider = crate::widgets::divider::vertical_divider(
147 crate::state::DragTarget::DiffFileListRight,
148 &c,
149 );
150 let right_panel = if tab.is_loading_file_diff {
151 loading_diff_view(&c, state.animation_tick)
152 } else {
153 placeholder_view(&c)
154 };
155 container(
156 row![file_list, divider, right_panel]
157 .width(Length::Fill)
158 .height(Length::Fill),
159 )
160 .width(Length::Fill)
161 .height(Length::Fill)
162 .style(theme::surface_style)
163 .into()
164 } else {
165 container(placeholder_view(&c))
166 .width(Length::Fill)
167 .height(Length::Fill)
168 .style(theme::surface_style)
169 .into()
170 }
171 }
172 }
173}
174
175fn commit_file_list<'a>(state: &'a GitKraft, c: &ThemeColors, width: f32) -> Element<'a, Message> {
177 let tab = state.active_tab();
178
179 let header_icon = icon!(icons::FILE_DIFF, 13, c.accent);
180
181 let header_text = text("Files").size(13).color(c.text_primary);
182
183 let multi_count = tab.selected_commit_file_indices.len();
184 let file_count = if multi_count > 1 {
185 text(format!("({} selected)", multi_count))
186 .size(11)
187 .color(c.accent)
188 } else {
189 text(format!("({})", tab.commit_files.len()))
190 .size(11)
191 .color(c.muted)
192 };
193
194 let header_row = row![
195 header_icon,
196 Space::new().width(4),
197 header_text,
198 Space::new().width(4),
199 file_count,
200 ]
201 .align_y(Alignment::Center)
202 .padding([6, 8]);
203
204 let mut file_list_col = column![].spacing(1).width(Length::Fill);
205 let oid_for_menu = tab.selected_commit_oid.clone().unwrap_or_default();
206
207 for (idx, diff) in tab.commit_files.iter().enumerate() {
208 let file_name = diff.file_name();
210
211 let is_selected = tab.selected_file_index == Some(idx);
213 let is_multi_selected = tab.selected_commit_file_indices.contains(&idx);
214
215 let status_color = theme::status_color(&diff.status, c);
216
217 let status_char = format!("{}", diff.status);
218
219 let status_badge = text(status_char)
220 .size(11)
221 .font(Font::MONOSPACE)
222 .color(status_color);
223
224 let name_color = if is_selected || is_multi_selected {
225 c.text_primary
226 } else {
227 c.text_secondary
228 };
229
230 let name_label = text(file_name.to_string())
231 .size(12)
232 .color(name_color)
233 .wrapping(iced::widget::text::Wrapping::None);
234
235 let dir_hint: Element<'a, Message> = {
237 let short_dir = diff.short_parent_dir();
238 if short_dir.is_empty() {
239 Space::new().into()
240 } else {
241 text(format!("{short_dir}/"))
242 .size(10)
243 .color(c.muted)
244 .wrapping(iced::widget::text::Wrapping::None)
245 .into()
246 }
247 };
248
249 let selection_badge: Element<'a, Message> = if let Some(pos) = tab
251 .selected_commit_file_indices
252 .iter()
253 .position(|&i| i == idx)
254 {
255 container(
256 text(format!("{}", pos + 1))
257 .size(10)
258 .font(Font::MONOSPACE)
259 .color(c.accent),
260 )
261 .width(16)
262 .center_x(Length::Fixed(16.0))
263 .into()
264 } else {
265 Space::new().width(16).into()
266 };
267
268 let row_content = row![
269 selection_badge,
270 Space::new().width(2),
271 status_badge,
272 Space::new().width(4),
273 column![row![dir_hint, name_label].align_y(Alignment::Center),],
274 ]
275 .align_y(Alignment::Center)
276 .padding([4, 8])
277 .width(Length::Fill);
278
279 let style_fn = row_style!(
282 is_selected => theme::selected_row_style,
283 is_multi_selected => theme::highlight_row_style,
284 );
285
286 let file_btn = button(row_content)
287 .padding(0)
288 .width(Length::Fill)
289 .style(theme::ghost_button)
290 .on_press(Message::SelectDiffByIndex(idx));
291
292 let file_row = mouse_area(
293 container(file_btn)
294 .width(Length::Fill)
295 .height(Length::Fixed(26.0))
296 .clip(true)
297 .style(style_fn),
298 )
299 .on_right_press(Message::OpenCommitFileContextMenu(
300 oid_for_menu.clone(),
301 diff.display_path().to_string(),
302 ));
303
304 file_list_col = file_list_col.push(file_row);
305 }
306
307 let scrollable_files = scrollable(file_list_col)
308 .height(Length::Fill)
309 .direction(view_utils::thin_scrollbar())
310 .style(crate::theme::overlay_scrollbar);
311
312 container(
313 column![header_row, scrollable_files]
314 .width(Length::Fill)
315 .height(Length::Fill),
316 )
317 .width(Length::Fixed(width))
318 .height(Length::Fill)
319 .style(theme::sidebar_style)
320 .into()
321}
322
323fn multi_diff_content<'a>(
326 diffs: &'a [DiffInfo],
327 c: &ThemeColors,
328 scroll_offset: f32,
329) -> Element<'a, Message> {
330 let total_lines: usize = diffs
333 .iter()
334 .map(|d| {
335 let content = if d.hunks.is_empty() {
336 1 } else {
338 d.hunks.iter().map(|h| h.lines.len()).sum::<usize>()
339 };
340 1 + content + 1 })
342 .sum();
343
344 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
345 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
346
347 let top_space = first as f32 * DIFF_LINE_HEIGHT;
348 let bottom_space = (total_lines.saturating_sub(last)) as f32 * DIFF_LINE_HEIGHT;
349
350 let mut col = column![].width(Length::Fill);
351
352 if top_space > 0.0 {
353 col = col.push(Space::new().height(top_space));
354 }
355
356 let mut global_idx = 0usize;
357 for diff in diffs {
358 if global_idx >= first && global_idx < last {
360 let status_color = theme::status_color(&diff.status, c);
361 let header = container(
362 row![
363 text(format!(" {} ", diff.status))
364 .size(12)
365 .color(status_color)
366 .font(Font::MONOSPACE),
367 Space::new().width(8),
368 text(diff.display_path().to_string())
369 .size(13)
370 .color(c.text_primary)
371 .font(Font::MONOSPACE),
372 ]
373 .align_y(Alignment::Center),
374 )
375 .padding([6, 12])
376 .width(Length::Fill)
377 .style(theme::header_style);
378 col = col.push(header);
379 }
380 global_idx += 1;
381 if global_idx >= last {
382 break;
383 }
384
385 if diff.hunks.is_empty() {
386 if global_idx >= first && global_idx < last {
387 col = col.push(
388 container(
389 text("No diff content.")
390 .size(13)
391 .color(c.muted)
392 .font(Font::MONOSPACE),
393 )
394 .padding([4, 12]),
395 );
396 }
397 global_idx += 1;
398 } else {
399 for hunk in &diff.hunks {
400 for line in &hunk.lines {
401 if global_idx >= first && global_idx < last {
402 col = col.push(render_line(line, c));
403 }
404 global_idx += 1;
405 if global_idx >= last {
406 break;
407 }
408 }
409 if global_idx >= last {
410 break;
411 }
412 }
413 }
414
415 if global_idx >= first && global_idx < last {
417 col = col.push(Space::new().height(8));
418 }
419 global_idx += 1;
420 if global_idx >= last {
421 break;
422 }
423 }
424
425 if bottom_space > 0.0 {
426 col = col.push(Space::new().height(bottom_space));
427 }
428
429 scrollable(col)
430 .height(Length::Fill)
431 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
432 .direction(view_utils::thin_scrollbar())
433 .style(crate::theme::overlay_scrollbar)
434 .into()
435}
436
437fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
439 view_utils::centered_placeholder(
440 icons::FILE_DIFF,
441 32,
442 "Select a commit or file to view diff",
443 c.muted,
444 )
445}
446
447fn loading_diff_view<'a>(c: &ThemeColors, animation_tick: u64) -> Element<'a, Message> {
449 let frames = tui_spinner::FluxFrames::CORNERS;
450 let frame = frames[animation_tick as usize % frames.len()].to_string();
451 let label = format!("{frame} Loading diff…");
452 container(text(label).size(14).color(c.muted).font(Font::MONOSPACE))
453 .width(Length::Fill)
454 .height(Length::Fill)
455 .center_x(Length::Fill)
456 .center_y(Length::Fill)
457 .into()
458}
459
460fn diff_content<'a>(
464 diff: &'a DiffInfo,
465 c: &ThemeColors,
466 scroll_offset: f32,
467) -> Element<'a, Message> {
468 let file_path_display = diff.display_path().to_string();
469
470 let status_color = theme::status_color(&diff.status, c);
471
472 let status_badge = text(format!(" {} ", diff.status))
473 .size(12)
474 .color(status_color)
475 .font(Font::MONOSPACE);
476
477 let file_label = text(file_path_display)
478 .size(14)
479 .color(c.text_primary)
480 .font(Font::MONOSPACE);
481
482 let file_header = container(
483 row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
484 )
485 .padding([8, 12])
486 .width(Length::Fill)
487 .style(theme::header_style);
488
489 let mut lines_col = column![].width(Length::Fill);
490
491 if diff.hunks.is_empty() {
492 let empty_msg = text("No diff content available.")
493 .size(13)
494 .color(c.muted)
495 .font(Font::MONOSPACE);
496 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
497 } else {
498 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
500
501 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
502 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
503
504 let top_space = first as f32 * DIFF_LINE_HEIGHT;
505 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
506
507 if top_space > 0.0 {
508 lines_col = lines_col.push(Space::new().height(top_space));
509 }
510
511 let mut global_idx = 0usize;
513 for hunk in &diff.hunks {
514 for line in &hunk.lines {
515 if global_idx >= first && global_idx < last {
516 lines_col = lines_col.push(render_line(line, c));
517 }
518 global_idx += 1;
519 if global_idx >= last {
520 break;
521 }
522 }
523 if global_idx >= last {
524 break;
525 }
526 }
527
528 if bottom_space > 0.0 {
529 lines_col = lines_col.push(Space::new().height(bottom_space));
530 }
531 }
532
533 let scrollable_content = scrollable(lines_col)
534 .height(Length::Fill)
535 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
536 .direction(view_utils::thin_scrollbar())
537 .style(crate::theme::overlay_scrollbar);
538
539 column![file_header, scrollable_content]
540 .width(Length::Fill)
541 .height(Length::Fill)
542 .into()
543}
544
545fn diff_line_widget<'a>(
547 prefix: &'static str,
548 content_str: &str,
549 color: iced::Color,
550 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
551) -> Element<'a, Message> {
552 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
553 let content = text(content_str.to_string())
554 .size(13)
555 .font(Font::MONOSPACE)
556 .color(color);
557 let c = container(row![prefix_w, Space::new().width(4), content])
558 .padding([1, 12])
559 .width(Length::Fill);
560 match style {
561 Some(s) => c.style(s).into(),
562 None => c.into(),
563 }
564}
565
566fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
568 match line {
569 DiffLine::HunkHeader(header) => {
570 let content = text(header.clone())
571 .size(13)
572 .font(Font::MONOSPACE)
573 .color(c.accent);
574 container(content)
575 .padding([4, 12])
576 .width(Length::Fill)
577 .style(theme::diff_hunk_style)
578 .into()
579 }
580 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
581 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
582 DiffLine::Context(s) => diff_line_widget(" ", s, c.diff_context, None),
583 }
584}
585
586pub(crate) fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
590 let tab = state.active_tab();
591 let c = state.colors();
592 let path = tab.file_history_path.as_deref().unwrap_or("");
593 let file_name = gitkraft_core::path_basename(path);
594
595 let close_btn = button(text("✕").size(13).color(c.muted))
596 .padding([2, 8])
597 .style(theme::ghost_button)
598 .on_press(Message::CloseFileHistory);
599
600 let header = row![
601 icon!(icons::CLOCK, 14, c.accent),
602 Space::new().width(6),
603 text(format!("File History: {file_name}"))
604 .size(14)
605 .color(c.text_primary),
606 Space::new().width(Length::Fill),
607 close_btn,
608 ]
609 .align_y(Alignment::Center)
610 .padding([8, 10]);
611
612 let body: Element<'_, Message> = if tab.file_history_commits.is_empty() {
613 let msg = if tab.is_loading {
614 "Loading…"
615 } else {
616 "No commits touch this file."
617 };
618 container(text(msg).size(13).color(c.muted))
619 .width(Length::Fill)
620 .padding(20)
621 .center_x(Length::Fill)
622 .into()
623 } else {
624 let mut list = column![].width(Length::Fill);
625 for commit in &tab.file_history_commits {
626 let short = commit.short_oid();
627 let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
628 let rel_time = commit.relative_time();
629
630 let row_content = row![
631 text(short).size(11).color(c.accent).font(Font::MONOSPACE),
632 Space::new().width(8),
633 container(
634 text(summary)
635 .size(12)
636 .color(c.text_primary)
637 .wrapping(iced::widget::text::Wrapping::None),
638 )
639 .width(Length::Fill)
640 .clip(true),
641 Space::new().width(8),
642 text(rel_time).size(11).color(c.muted),
643 ]
644 .align_y(Alignment::Center)
645 .padding([3, 8]);
646
647 let oid = commit.oid.clone();
648 list = list.push(
649 button(row_content)
650 .padding(0)
651 .width(Length::Fill)
652 .style(theme::ghost_button)
653 .on_press(Message::SelectFileHistoryCommit(oid)),
654 );
655 }
656
657 scrollable(list)
658 .height(Length::Fill)
659 .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
660 .direction(view_utils::thin_scrollbar())
661 .style(theme::overlay_scrollbar)
662 .into()
663 };
664
665 let content = column![header, body]
666 .width(Length::Fill)
667 .height(Length::Fill);
668
669 view_utils::surface_panel(content, Length::Fill)
670}
671
672pub(crate) fn blame_view(state: &GitKraft) -> Element<'_, Message> {
676 let tab = state.active_tab();
677 let c = state.colors();
678 let path = tab.blame_path.as_deref().unwrap_or("");
679 let file_name = gitkraft_core::path_basename(path);
680
681 let close_btn = button(
682 row![
683 text("✕").size(12).color(c.text_primary),
684 Space::new().width(4),
685 text("Close").size(12).color(c.text_primary),
686 Space::new().width(4),
687 text("[Esc]").size(11).color(c.muted),
688 ]
689 .align_y(Alignment::Center),
690 )
691 .padding([4, 10])
692 .style(theme::toolbar_button)
693 .on_press(Message::CloseFileBlame);
694
695 let header = row![
696 icon!(icons::CLOCK, 14, c.accent),
697 Space::new().width(6),
698 text(format!("Blame: {file_name}"))
699 .size(14)
700 .color(c.text_primary),
701 Space::new().width(Length::Fill),
702 close_btn,
703 ]
704 .align_y(Alignment::Center)
705 .padding([8, 10]);
706
707 let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
708 let msg = if tab.is_loading {
709 "Loading…"
710 } else {
711 "No blame data."
712 };
713 container(text(msg).size(13).color(c.muted))
714 .width(Length::Fill)
715 .padding(20)
716 .center_x(Length::Fill)
717 .into()
718 } else {
719 let mut list = column![].width(Length::Fill);
720 for line in &tab.blame_lines {
721 let rel_time = line.relative_time();
722 let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
723 let line_content = container(
724 text(line.content.as_str())
725 .size(11)
726 .color(c.text_primary)
727 .font(Font::MONOSPACE)
728 .wrapping(iced::widget::text::Wrapping::None),
729 )
730 .width(Length::Fill)
731 .clip(true);
732
733 let blame_row = row![
734 text(line.short_oid())
735 .size(10)
736 .color(c.accent)
737 .font(Font::MONOSPACE),
738 Space::new().width(6),
739 container(
740 text(author)
741 .size(10)
742 .color(c.text_secondary)
743 .wrapping(iced::widget::text::Wrapping::None),
744 )
745 .width(80)
746 .clip(true),
747 Space::new().width(4),
748 container(
749 text(rel_time)
750 .size(10)
751 .color(c.muted)
752 .wrapping(iced::widget::text::Wrapping::None),
753 )
754 .width(54)
755 .clip(true),
756 Space::new().width(4),
757 container(
758 text(format!("{:4}", line.line_number))
759 .size(10)
760 .color(c.muted)
761 .font(Font::MONOSPACE),
762 )
763 .width(32),
764 Space::new().width(6),
765 line_content,
766 ]
767 .align_y(Alignment::Center)
768 .padding([1, 8]);
769
770 list = list.push(blame_row);
771 }
772
773 scrollable(list)
774 .height(Length::Fill)
775 .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
776 .direction(view_utils::thin_scrollbar())
777 .style(theme::overlay_scrollbar)
778 .into()
779 };
780
781 let content = column![header, body]
782 .width(Length::Fill)
783 .height(Length::Fill);
784
785 view_utils::surface_panel(content, Length::Fill)
786}