gitkraft_gui/features/diff/
view.rs1use gitkraft_core::{DiffInfo, DiffLine};
13use iced::widget::{button, column, container, 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.len() > 1 {
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::with_width(4),
113 header_text,
114 Space::with_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
122 for (idx, diff) in tab.commit_files.iter().enumerate() {
123 let file_name = diff.file_name();
125
126 let is_selected = tab.selected_file_index == Some(idx);
128
129 let status_color = theme::status_color(&diff.status, c);
130
131 let status_char = format!("{}", diff.status);
132
133 let status_badge = text(status_char)
134 .size(11)
135 .font(Font::MONOSPACE)
136 .color(status_color);
137
138 let name_color = if is_selected {
139 c.text_primary
140 } else {
141 c.text_secondary
142 };
143
144 let name_label = text(file_name.to_string())
145 .size(12)
146 .color(name_color)
147 .wrapping(iced::widget::text::Wrapping::None);
148
149 let dir_hint: Element<'a, Message> = {
151 let short_dir = diff.short_parent_dir();
152 if short_dir.is_empty() {
153 Space::with_width(0).into()
154 } else {
155 text(format!("{short_dir}/"))
156 .size(10)
157 .color(c.muted)
158 .wrapping(iced::widget::text::Wrapping::None)
159 .into()
160 }
161 };
162
163 let row_content = row![
164 status_badge,
165 Space::with_width(4),
166 column![row![dir_hint, name_label].align_y(Alignment::Center),],
167 ]
168 .align_y(Alignment::Center)
169 .padding([4, 8])
170 .width(Length::Fill);
171
172 let style_fn = if is_selected {
173 theme::selected_row_style as fn(&iced::Theme) -> iced::widget::container::Style
174 } else {
175 theme::surface_style as fn(&iced::Theme) -> iced::widget::container::Style
176 };
177
178 let file_btn = button(row_content)
179 .padding(0)
180 .width(Length::Fill)
181 .style(theme::ghost_button)
182 .on_press(Message::SelectDiffByIndex(idx));
183
184 let file_row = container(file_btn)
185 .width(Length::Fill)
186 .height(Length::Fixed(26.0))
187 .clip(true)
188 .style(style_fn);
189
190 file_list_col = file_list_col.push(file_row);
191 }
192
193 let scrollable_files = scrollable(file_list_col)
194 .height(Length::Fill)
195 .direction(view_utils::thin_scrollbar())
196 .style(crate::theme::overlay_scrollbar);
197
198 container(
199 column![header_row, scrollable_files]
200 .width(Length::Fill)
201 .height(Length::Fill),
202 )
203 .width(Length::Fixed(width))
204 .height(Length::Fill)
205 .style(theme::sidebar_style)
206 .into()
207}
208
209fn placeholder_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
211 view_utils::centered_placeholder(
212 icons::FILE_DIFF,
213 32,
214 "Select a commit or file to view diff",
215 c.muted,
216 )
217}
218
219fn loading_diff_view<'a>(c: &ThemeColors) -> Element<'a, Message> {
221 view_utils::centered_placeholder(icons::ARROW_REPEAT, 24, "Loading diff…", c.muted)
222}
223
224fn diff_content<'a>(
228 diff: &'a DiffInfo,
229 c: &ThemeColors,
230 scroll_offset: f32,
231) -> Element<'a, Message> {
232 let file_path_display = diff.display_path().to_string();
233
234 let status_color = theme::status_color(&diff.status, c);
235
236 let status_badge = text(format!(" {} ", diff.status))
237 .size(12)
238 .color(status_color)
239 .font(Font::MONOSPACE);
240
241 let file_label = text(file_path_display)
242 .size(14)
243 .color(c.text_primary)
244 .font(Font::MONOSPACE);
245
246 let file_header = container(
247 row![status_badge, Space::with_width(8), file_label].align_y(iced::Alignment::Center),
248 )
249 .padding([8, 12])
250 .width(Length::Fill)
251 .style(theme::header_style);
252
253 let mut lines_col = column![].width(Length::Fill);
254
255 if diff.hunks.is_empty() {
256 let empty_msg = text("No diff content available.")
257 .size(13)
258 .color(c.muted)
259 .font(Font::MONOSPACE);
260 lines_col = lines_col.push(container(empty_msg).padding([8, 12]));
261 } else {
262 let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
264
265 let first = ((scroll_offset / DIFF_LINE_HEIGHT) as usize).saturating_sub(DIFF_OVERSCAN);
266 let last = (first + DIFF_VISIBLE_LINES + 2 * DIFF_OVERSCAN).min(total_lines);
267
268 let top_space = first as f32 * DIFF_LINE_HEIGHT;
269 let bottom_space = (total_lines - last) as f32 * DIFF_LINE_HEIGHT;
270
271 if top_space > 0.0 {
272 lines_col = lines_col.push(Space::with_height(top_space));
273 }
274
275 let mut global_idx = 0usize;
277 for hunk in &diff.hunks {
278 for line in &hunk.lines {
279 if global_idx >= first && global_idx < last {
280 lines_col = lines_col.push(render_line(line, c));
281 }
282 global_idx += 1;
283 if global_idx >= last {
284 break;
285 }
286 }
287 if global_idx >= last {
288 break;
289 }
290 }
291
292 if bottom_space > 0.0 {
293 lines_col = lines_col.push(Space::with_height(bottom_space));
294 }
295 }
296
297 let scrollable_content = scrollable(lines_col)
298 .height(Length::Fill)
299 .on_scroll(|vp| Message::DiffViewScrolled(vp.absolute_offset().y))
300 .direction(view_utils::thin_scrollbar())
301 .style(crate::theme::overlay_scrollbar);
302
303 column![file_header, scrollable_content]
304 .width(Length::Fill)
305 .height(Length::Fill)
306 .into()
307}
308
309fn diff_line_widget<'a>(
311 prefix: &'static str,
312 content_str: &str,
313 color: iced::Color,
314 style: Option<fn(&iced::Theme) -> iced::widget::container::Style>,
315) -> Element<'a, Message> {
316 let prefix_w = text(prefix).size(13).font(Font::MONOSPACE).color(color);
317 let content = text(content_str.to_string())
318 .size(13)
319 .font(Font::MONOSPACE)
320 .color(color);
321 let c = container(row![prefix_w, Space::with_width(4), content])
322 .padding([1, 12])
323 .width(Length::Fill);
324 match style {
325 Some(s) => c.style(s).into(),
326 None => c.into(),
327 }
328}
329
330fn render_line<'a>(line: &DiffLine, c: &ThemeColors) -> Element<'a, Message> {
332 match line {
333 DiffLine::HunkHeader(header) => {
334 let content = text(header.clone())
335 .size(13)
336 .font(Font::MONOSPACE)
337 .color(c.accent);
338 container(content)
339 .padding([4, 12])
340 .width(Length::Fill)
341 .style(theme::diff_hunk_style)
342 .into()
343 }
344 DiffLine::Addition(s) => diff_line_widget("+", s, c.green, Some(theme::diff_add_style)),
345 DiffLine::Deletion(s) => diff_line_widget("-", s, c.red, Some(theme::diff_del_style)),
346 DiffLine::Context(s) => diff_line_widget(" ", s, c.text_secondary, None),
347 }
348}