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 = if is_selected {
282 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
283 } else if is_multi_selected {
284 theme::highlight_row_style as fn(&iced::Theme) -> iced::widget::container::Style
285 } else {
286 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
287 };
288
289 let file_btn = button(row_content)
290 .padding(0)
291 .width(Length::Fill)
292 .style(theme::ghost_button)
293 .on_press(Message::SelectDiffByIndex(idx));
294
295 let file_row = mouse_area(
296 container(file_btn)
297 .width(Length::Fill)
298 .height(Length::Fixed(26.0))
299 .clip(true)
300 .style(style_fn),
301 )
302 .on_right_press(Message::OpenCommitFileContextMenu(
303 oid_for_menu.clone(),
304 diff.display_path().to_string(),
305 ));
306
307 file_list_col = file_list_col.push(file_row);
308 }
309
310 let scrollable_files = scrollable(file_list_col)
311 .height(Length::Fill)
312 .direction(view_utils::thin_scrollbar())
313 .style(crate::theme::overlay_scrollbar);
314
315 container(
316 column![header_row, scrollable_files]
317 .width(Length::Fill)
318 .height(Length::Fill),
319 )
320 .width(Length::Fixed(width))
321 .height(Length::Fill)
322 .style(theme::sidebar_style)
323 .into()
324}
325
326fn multi_diff_content<'a>(
329 diffs: &'a [DiffInfo],
330 c: &ThemeColors,
331 _scroll_offset: f32,
332) -> Element<'a, Message> {
333 let mut col = column![].width(Length::Fill);
334
335 for diff in diffs {
336 let status_color = theme::status_color(&diff.status, c);
338 let header = container(
339 row![
340 text(format!(" {} ", diff.status))
341 .size(12)
342 .color(status_color)
343 .font(Font::MONOSPACE),
344 Space::new().width(8),
345 text(diff.display_path().to_string())
346 .size(13)
347 .color(c.text_primary)
348 .font(Font::MONOSPACE),
349 ]
350 .align_y(Alignment::Center),
351 )
352 .padding([6, 12])
353 .width(Length::Fill)
354 .style(theme::header_style);
355
356 col = col.push(header);
357
358 if diff.hunks.is_empty() {
359 col = col.push(
360 container(
361 text("No diff content.")
362 .size(13)
363 .color(c.muted)
364 .font(Font::MONOSPACE),
365 )
366 .padding([4, 12]),
367 );
368 } else {
369 for hunk in &diff.hunks {
370 for line in &hunk.lines {
371 col = col.push(render_line(line, c));
372 }
373 }
374 }
375
376 col = col.push(Space::new().height(8));
378 }
379
380 scrollable(col)
381 .height(Length::Fill)
382 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
383 .direction(view_utils::thin_scrollbar())
384 .style(crate::theme::overlay_scrollbar)
385 .into()
386}
387
388fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
390 view_utils::centered_placeholder(
391 icons::FILE_DIFF,
392 32,
393 "Select a commit or file to view diff",
394 c.muted,
395 )
396}
397
398fn loading_diff_view<'a>(c: &ThemeColors, animation_tick: u64) -> Element<'a, Message> {
400 let frames = tui_spinner::FluxFrames::CORNERS;
401 let frame = frames[animation_tick as usize % frames.len()].to_string();
402 let label = format!("{frame} Loading diff…");
403 container(text(label).size(14).color(c.muted).font(Font::MONOSPACE))
404 .width(Length::Fill)
405 .height(Length::Fill)
406 .center_x(Length::Fill)
407 .center_y(Length::Fill)
408 .into()
409}
410
411fn diff_content<'a>(
415 diff: &'a DiffInfo,
416 c: &ThemeColors,
417 scroll_offset: f32,
418) -> Element<'a, Message> {
419 let file_path_display = diff.display_path().to_string();
420
421 let status_color = theme::status_color(&diff.status, c);
422
423 let status_badge = text(format!(" {} ", diff.status))
424 .size(12)
425 .color(status_color)
426 .font(Font::MONOSPACE);
427
428 let file_label = text(file_path_display)
429 .size(14)
430 .color(c.text_primary)
431 .font(Font::MONOSPACE);
432
433 let file_header = container(
434 row![status_badge, Space::new().width(8), file_label].align_y(iced::Alignment::Center),
435 )
436 .padding([8, 12])
437 .width(Length::Fill)
438 .style(theme::header_style);
439
440 let mut lines_col = column![].width(Length::Fill);
441
442 if diff.hunks.is_empty() {
443 let empty_msg = text("No diff content available.")
444 .size(13)
445 .color(c.muted)
446 .font(Font::MONOSPACE);
447 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
448 } else {
449 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
451
452 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
453 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
454
455 let top_space = first as f32 * DIFF_LINE_HEIGHT;
456 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
457
458 if top_space > 0.0 {
459 lines_col = lines_col.push(Space::new().height(top_space));
460 }
461
462 let mut global_idx = 0usize;
464 for hunk in &diff.hunks {
465 for line in &hunk.lines {
466 if global_idx >= first && global_idx < last {
467 lines_col = lines_col.push(render_line(line, c));
468 }
469 global_idx += 1;
470 if global_idx >= last {
471 break;
472 }
473 }
474 if global_idx >= last {
475 break;
476 }
477 }
478
479 if bottom_space > 0.0 {
480 lines_col = lines_col.push(Space::new().height(bottom_space));
481 }
482 }
483
484 let scrollable_content = scrollable(lines_col)
485 .height(Length::Fill)
486 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
487 .direction(view_utils::thin_scrollbar())
488 .style(crate::theme::overlay_scrollbar);
489
490 column![file_header, scrollable_content]
491 .width(Length::Fill)
492 .height(Length::Fill)
493 .into()
494}
495
496fn diff_line_widget<'a>(
498 prefix: &'static str,
499 content_str: &str,
500 color: iced::Color,
501 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
502) -> Element<'a, Message> {
503 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
504 let content = text(content_str.to_string())
505 .size(13)
506 .font(Font::MONOSPACE)
507 .color(color);
508 let c = container(row![prefix_w, Space::new().width(4), content])
509 .padding([1, 12])
510 .width(Length::Fill);
511 match style {
512 Some(s) => c.style(s).into(),
513 None => c.into(),
514 }
515}
516
517fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
519 match line {
520 DiffLine::HunkHeader(header) => {
521 let content = text(header.clone())
522 .size(13)
523 .font(Font::MONOSPACE)
524 .color(c.accent);
525 container(content)
526 .padding([4, 12])
527 .width(Length::Fill)
528 .style(theme::diff_hunk_style)
529 .into()
530 }
531 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
532 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
533 DiffLine::Context(s) => diff_line_widget(" ", s, c.diff_context, None),
534 }
535}
536
537pub(crate) fn file_history_view(state: &GitKraft) -> Element<'_, Message> {
541 let tab = state.active_tab();
542 let c = state.colors();
543 let path = tab.file_history_path.as_deref().unwrap_or("");
544 let file_name = gitkraft_core::path_basename(path);
545
546 let close_btn = button(text("✕").size(13).color(c.muted))
547 .padding([2, 8])
548 .style(theme::ghost_button)
549 .on_press(Message::CloseFileHistory);
550
551 let header = row![
552 icon!(icons::CLOCK, 14, c.accent),
553 Space::new().width(6),
554 text(format!("File History: {file_name}"))
555 .size(14)
556 .color(c.text_primary),
557 Space::new().width(Length::Fill),
558 close_btn,
559 ]
560 .align_y(Alignment::Center)
561 .padding([8, 10]);
562
563 let body: Element<'_, Message> = if tab.file_history_commits.is_empty() {
564 let msg = if tab.is_loading {
565 "Loading…"
566 } else {
567 "No commits touch this file."
568 };
569 container(text(msg).size(13).color(c.muted))
570 .width(Length::Fill)
571 .padding(20)
572 .center_x(Length::Fill)
573 .into()
574 } else {
575 let mut list = column![].width(Length::Fill);
576 for commit in &tab.file_history_commits {
577 let short = commit.short_oid.as_str();
578 let summary = view_utils::truncate_to_fit(&commit.summary, 280.0, 7.0);
579 let rel_time = commit.relative_time();
580
581 let row_content = row![
582 text(short).size(11).color(c.accent).font(Font::MONOSPACE),
583 Space::new().width(8),
584 container(
585 text(summary)
586 .size(12)
587 .color(c.text_primary)
588 .wrapping(iced::widget::text::Wrapping::None),
589 )
590 .width(Length::Fill)
591 .clip(true),
592 Space::new().width(8),
593 text(rel_time).size(11).color(c.muted),
594 ]
595 .align_y(Alignment::Center)
596 .padding([3, 8]);
597
598 let oid = commit.oid.clone();
599 list = list.push(
600 button(row_content)
601 .padding(0)
602 .width(Length::Fill)
603 .style(theme::ghost_button)
604 .on_press(Message::SelectFileHistoryCommit(oid)),
605 );
606 }
607
608 scrollable(list)
609 .height(Length::Fill)
610 .on_scroll(|vp| Message::FileHistoryScrolled(vp.absolute_offset().y))
611 .direction(view_utils::thin_scrollbar())
612 .style(theme::overlay_scrollbar)
613 .into()
614 };
615
616 let content = column![header, body]
617 .width(Length::Fill)
618 .height(Length::Fill);
619
620 view_utils::surface_panel(content, Length::Fill)
621}
622
623pub(crate) fn blame_view(state: &GitKraft) -> Element<'_, Message> {
627 let tab = state.active_tab();
628 let c = state.colors();
629 let path = tab.blame_path.as_deref().unwrap_or("");
630 let file_name = gitkraft_core::path_basename(path);
631
632 let close_btn = button(
633 row![
634 text("✕").size(12).color(c.text_primary),
635 Space::new().width(4),
636 text("Close").size(12).color(c.text_primary),
637 Space::new().width(4),
638 text("[Esc]").size(11).color(c.muted),
639 ]
640 .align_y(Alignment::Center),
641 )
642 .padding([4, 10])
643 .style(theme::toolbar_button)
644 .on_press(Message::CloseFileBlame);
645
646 let header = row![
647 icon!(icons::CLOCK, 14, c.accent),
648 Space::new().width(6),
649 text(format!("Blame: {file_name}"))
650 .size(14)
651 .color(c.text_primary),
652 Space::new().width(Length::Fill),
653 close_btn,
654 ]
655 .align_y(Alignment::Center)
656 .padding([8, 10]);
657
658 let body: Element<'_, Message> = if tab.blame_lines.is_empty() {
659 let msg = if tab.is_loading {
660 "Loading…"
661 } else {
662 "No blame data."
663 };
664 container(text(msg).size(13).color(c.muted))
665 .width(Length::Fill)
666 .padding(20)
667 .center_x(Length::Fill)
668 .into()
669 } else {
670 let mut list = column![].width(Length::Fill);
671 for line in &tab.blame_lines {
672 let rel_time = line.relative_time();
673 let author = view_utils::truncate_to_fit(&line.author_name, 80.0, 7.0);
674 let line_content = container(
675 text(line.content.as_str())
676 .size(11)
677 .color(c.text_primary)
678 .font(Font::MONOSPACE)
679 .wrapping(iced::widget::text::Wrapping::None),
680 )
681 .width(Length::Fill)
682 .clip(true);
683
684 let blame_row = row![
685 text(line.short_oid.as_str())
686 .size(10)
687 .color(c.accent)
688 .font(Font::MONOSPACE),
689 Space::new().width(6),
690 container(
691 text(author)
692 .size(10)
693 .color(c.text_secondary)
694 .wrapping(iced::widget::text::Wrapping::None),
695 )
696 .width(80)
697 .clip(true),
698 Space::new().width(4),
699 container(
700 text(rel_time)
701 .size(10)
702 .color(c.muted)
703 .wrapping(iced::widget::text::Wrapping::None),
704 )
705 .width(54)
706 .clip(true),
707 Space::new().width(4),
708 container(
709 text(format!("{:4}", line.line_number))
710 .size(10)
711 .color(c.muted)
712 .font(Font::MONOSPACE),
713 )
714 .width(32),
715 Space::new().width(6),
716 line_content,
717 ]
718 .align_y(Alignment::Center)
719 .padding([1, 8]);
720
721 list = list.push(blame_row);
722 }
723
724 scrollable(list)
725 .height(Length::Fill)
726 .on_scroll(|vp| Message::BlameScrolled(vp.absolute_offset().y))
727 .direction(view_utils::thin_scrollbar())
728 .style(theme::overlay_scrollbar)
729 .into()
730 };
731
732 let content = column![header, body]
733 .width(Length::Fill)
734 .height(Length::Fill);
735
736 view_utils::surface_panel(content, Length::Fill)
737}