1use std::fs;
2use std::path::{Path, PathBuf};
3
4use radicle::git;
5use radicle_git_ext::Oid;
6use radicle_surf::diff;
7use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved};
8use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification};
9use radicle_term as term;
10use term::cell::Cell;
11use term::VStack;
12
13use crate::git::unified_diff::FileHeader;
14use crate::terminal::highlight::{Highlighter, Theme};
15
16use super::unified_diff::{Decode, HunkHeader};
17
18#[derive(PartialEq, Eq, Debug)]
20pub enum Blob {
21 Binary,
22 Empty,
23 Plain(Vec<u8>),
24}
25
26pub trait Repo {
28 fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
30 fn file(&self, path: &Path) -> Option<Blob>;
32}
33
34impl Repo for git::raw::Repository {
35 fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
36 let blob = self.find_blob(*oid)?;
37
38 if blob.is_binary() {
39 Ok(Blob::Binary)
40 } else {
41 let content = blob.content();
42
43 if content.is_empty() {
44 Ok(Blob::Empty)
45 } else {
46 Ok(Blob::Plain(blob.content().to_vec()))
47 }
48 }
49 }
50
51 fn file(&self, path: &Path) -> Option<Blob> {
52 self.workdir()
53 .and_then(|dir| fs::read(dir.join(path)).ok())
54 .map(|content| {
55 let binary = content.iter().take(8192).any(|b| *b == 0);
58 if binary {
59 Blob::Binary
60 } else {
61 Blob::Plain(content)
62 }
63 })
64 }
65}
66
67#[derive(Debug)]
69pub struct Blobs<T> {
70 pub old: Option<T>,
71 pub new: Option<T>,
72}
73
74impl<T> Blobs<T> {
75 pub fn new(old: Option<T>, new: Option<T>) -> Self {
76 Self { old, new }
77 }
78}
79
80impl Blobs<(PathBuf, Blob)> {
81 pub fn highlight(&self, hi: &mut Highlighter) -> Blobs<Vec<term::Line>> {
82 let mut blobs = Blobs::default();
83 if let Some((path, Blob::Plain(content))) = &self.old {
84 blobs.old = hi.highlight(path, content).ok();
85 }
86 if let Some((path, Blob::Plain(content))) = &self.new {
87 blobs.new = hi.highlight(path, content).ok();
88 }
89 blobs
90 }
91
92 pub fn from_paths<R: Repo>(
93 old: Option<(&Path, Oid)>,
94 new: Option<(&Path, Oid)>,
95 repo: &R,
96 ) -> Blobs<(PathBuf, Blob)> {
97 Blobs::new(
98 old.and_then(|(path, oid)| {
99 repo.blob(oid)
100 .ok()
101 .or_else(|| repo.file(path))
102 .map(|blob| (path.to_path_buf(), blob))
103 }),
104 new.and_then(|(path, oid)| {
105 repo.blob(oid)
106 .ok()
107 .or_else(|| repo.file(path))
108 .map(|blob| (path.to_path_buf(), blob))
109 }),
110 )
111 }
112}
113
114impl<T> Default for Blobs<T> {
115 fn default() -> Self {
116 Self {
117 old: None,
118 new: None,
119 }
120 }
121}
122
123pub trait ToPretty {
125 type Output: term::Element;
127 type Context;
129
130 fn pretty<R: Repo>(
132 &self,
133 hi: &mut Highlighter,
134 context: &Self::Context,
135 repo: &R,
136 ) -> Self::Output;
137}
138
139impl ToPretty for Diff {
140 type Output = term::VStack<'static>;
141 type Context = ();
142
143 fn pretty<R: Repo>(
144 &self,
145 hi: &mut Highlighter,
146 context: &Self::Context,
147 repo: &R,
148 ) -> Self::Output {
149 term::VStack::default()
150 .padding(0)
151 .children(self.files().flat_map(|f| {
152 [
153 f.pretty(hi, context, repo).boxed(),
154 term::Line::blank().boxed(), ]
156 }))
157 }
158}
159
160impl ToPretty for FileHeader {
161 type Output = term::Line;
162 type Context = Option<FileStats>;
163
164 fn pretty<R: Repo>(
165 &self,
166 _hi: &mut Highlighter,
167 stats: &Self::Context,
168 _repo: &R,
169 ) -> Self::Output {
170 let theme = Theme::default();
171 let (mut header, badge, binary) = match self {
172 FileHeader::Added { path, binary, .. } => (
173 term::Line::new(path.display().to_string()),
174 Some(term::format::badge_positive("created")),
175 *binary,
176 ),
177 FileHeader::Moved {
178 old_path, new_path, ..
179 } => (
180 term::Line::spaced([
181 term::label(old_path.display().to_string()),
182 term::label("->".to_string()),
183 term::label(new_path.display().to_string()),
184 ]),
185 Some(term::format::badge_secondary("moved")),
186 false,
187 ),
188 FileHeader::Deleted { path, binary, .. } => (
189 term::Line::new(path.display().to_string()),
190 Some(term::format::badge_negative("deleted")),
191 *binary,
192 ),
193 FileHeader::Modified {
194 path,
195 old,
196 new,
197 binary,
198 ..
199 } => {
200 if old.mode != new.mode {
201 (
202 term::Line::spaced([
203 term::label(path.display().to_string()),
204 term::label(format!("{:o}", u32::from(old.mode.clone())))
205 .fg(term::Color::Blue),
206 term::label("->".to_string()),
207 term::label(format!("{:o}", u32::from(new.mode.clone())))
208 .fg(term::Color::Blue),
209 ]),
210 Some(term::format::badge_secondary("mode changed")),
211 *binary,
212 )
213 } else {
214 (term::Line::new(path.display().to_string()), None, *binary)
215 }
216 }
217 FileHeader::Copied {
218 old_path, new_path, ..
219 } => (
220 term::Line::spaced([
221 term::label(old_path.display().to_string()),
222 term::label("->".to_string()),
223 term::label(new_path.display().to_string()),
224 ]),
225 Some(term::format::badge_secondary("copied")),
226 false,
227 ),
228 };
229
230 if binary {
231 header.push(term::Label::space());
232 header.push(term::label(term::format::badge_yellow("binary")));
233 }
234
235 let (additions, deletions) = if let Some(stats) = stats {
236 (stats.additions, stats.deletions)
237 } else {
238 (0, 0)
239 };
240 if deletions > 0 {
241 header.push(term::Label::space());
242 header.push(term::label(format!("-{deletions}")).fg(theme.color("negative.light")));
243 }
244 if additions > 0 {
245 header.push(term::Label::space());
246 header.push(term::label(format!("+{additions}")).fg(theme.color("positive.light")));
247 }
248 if let Some(badge) = badge {
249 header.push(term::Label::space());
250 header.push(badge);
251 }
252 header
253 }
254}
255
256impl ToPretty for FileDiff {
257 type Output = term::VStack<'static>;
258 type Context = ();
259
260 fn pretty<R: Repo>(
261 &self,
262 hi: &mut Highlighter,
263 _context: &Self::Context,
264 repo: &R,
265 ) -> Self::Output {
266 let header = FileHeader::from(self);
267
268 match self {
269 FileDiff::Added(f) => f.pretty(hi, &header, repo),
270 FileDiff::Deleted(f) => f.pretty(hi, &header, repo),
271 FileDiff::Modified(f) => f.pretty(hi, &header, repo),
272 FileDiff::Moved(f) => f.pretty(hi, &header, repo),
273 FileDiff::Copied(f) => f.pretty(hi, &header, repo),
274 }
275 }
276}
277
278impl ToPretty for DiffContent {
279 type Output = term::VStack<'static>;
280 type Context = Blobs<(PathBuf, Blob)>;
281
282 fn pretty<R: Repo>(
283 &self,
284 hi: &mut Highlighter,
285 blobs: &Self::Context,
286 repo: &R,
287 ) -> Self::Output {
288 let mut vstack = term::VStack::default().padding(0);
289
290 match self {
291 DiffContent::Plain {
292 hunks: Hunks(hunks),
293 ..
294 } => {
295 let blobs = blobs.highlight(hi);
296
297 for (i, h) in hunks.iter().enumerate() {
298 vstack.push(h.pretty(hi, &blobs, repo));
299 if i != hunks.len() - 1 {
300 vstack = vstack.divider();
301 }
302 }
303 }
304 DiffContent::Empty => {}
305 DiffContent::Binary => {}
306 }
307 vstack
308 }
309}
310
311impl ToPretty for Moved {
312 type Output = term::VStack<'static>;
313 type Context = FileHeader;
314
315 fn pretty<R: Repo>(
316 &self,
317 hi: &mut Highlighter,
318 header: &Self::Context,
319 repo: &R,
320 ) -> Self::Output {
321 let header = header.pretty(hi, &self.diff.stats().copied(), repo);
322
323 term::VStack::default()
324 .border(Some(term::colors::FAINT))
325 .padding(1)
326 .child(term::Line::default().extend(header))
327 }
328}
329
330impl ToPretty for Added {
331 type Output = term::VStack<'static>;
332 type Context = FileHeader;
333
334 fn pretty<R: Repo>(
335 &self,
336 hi: &mut Highlighter,
337 header: &Self::Context,
338 repo: &R,
339 ) -> Self::Output {
340 let old = None;
341 let new = Some((self.path.as_path(), self.new.oid));
342
343 pretty_modification(header, &self.diff, old, new, repo, hi)
344 }
345}
346
347impl ToPretty for Deleted {
348 type Output = term::VStack<'static>;
349 type Context = FileHeader;
350
351 fn pretty<R: Repo>(
352 &self,
353 hi: &mut Highlighter,
354 header: &Self::Context,
355 repo: &R,
356 ) -> Self::Output {
357 let old = Some((self.path.as_path(), self.old.oid));
358 let new = None;
359
360 pretty_modification(header, &self.diff, old, new, repo, hi)
361 }
362}
363
364impl ToPretty for Modified {
365 type Output = term::VStack<'static>;
366 type Context = FileHeader;
367
368 fn pretty<R: Repo>(
369 &self,
370 hi: &mut Highlighter,
371 header: &Self::Context,
372 repo: &R,
373 ) -> Self::Output {
374 let old = Some((self.path.as_path(), self.old.oid));
375 let new = Some((self.path.as_path(), self.new.oid));
376
377 pretty_modification(header, &self.diff, old, new, repo, hi)
378 }
379}
380
381impl ToPretty for Copied {
382 type Output = term::VStack<'static>;
383 type Context = FileHeader;
384
385 fn pretty<R: Repo>(
386 &self,
387 hi: &mut Highlighter,
388 _context: &Self::Context,
389 repo: &R,
390 ) -> Self::Output {
391 let header = FileHeader::Copied {
392 old_path: self.old_path.clone(),
393 new_path: self.old_path.clone(),
394 }
395 .pretty(hi, &self.diff.stats().copied(), repo);
396
397 term::VStack::default()
398 .border(Some(term::colors::FAINT))
399 .padding(1)
400 .child(header)
401 }
402}
403
404impl ToPretty for HunkHeader {
405 type Output = term::Line;
406 type Context = ();
407
408 fn pretty<R: Repo>(
409 &self,
410 _hi: &mut Highlighter,
411 _context: &Self::Context,
412 _repo: &R,
413 ) -> Self::Output {
414 term::Line::spaced([
415 term::label(format!(
416 "@@ -{},{} +{},{} @@",
417 self.old_line_no, self.old_size, self.new_line_no, self.new_size,
418 ))
419 .fg(term::colors::fixed::FAINT),
420 term::label(String::from_utf8_lossy(&self.text).to_string())
421 .fg(term::colors::fixed::DIM),
422 ])
423 }
424}
425
426impl ToPretty for Hunk<Modification> {
427 type Output = term::VStack<'static>;
428 type Context = Blobs<Vec<term::Line>>;
429
430 fn pretty<R: Repo>(
431 &self,
432 hi: &mut Highlighter,
433 blobs: &Self::Context,
434 repo: &R,
435 ) -> Self::Output {
436 let mut vstack = term::VStack::default().padding(0);
437 let mut table = term::Table::<5, term::Filled<term::Line>>::new(term::TableOptions {
438 overflow: false,
439 spacing: 0,
440 border: None,
441 });
442 let theme = Theme::default();
443
444 if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
445 vstack.push(header.pretty(hi, &(), repo));
446 }
447 for line in &self.lines {
448 match line {
449 Modification::Addition(a) => {
450 table.push([
451 term::Label::space()
452 .pad(5)
453 .bg(theme.color("positive"))
454 .to_line()
455 .filled(theme.color("positive")),
456 term::label(a.line_no.to_string())
457 .pad(5)
458 .fg(theme.color("positive.light"))
459 .to_line()
460 .filled(theme.color("positive")),
461 term::label(" + ")
462 .fg(theme.color("positive.light"))
463 .to_line()
464 .filled(theme.color("positive.dark")),
465 line.pretty(hi, blobs, repo)
466 .filled(theme.color("positive.dark")),
467 term::Line::blank().filled(term::Color::default()),
468 ]);
469 }
470 Modification::Deletion(a) => {
471 table.push([
472 term::label(a.line_no.to_string())
473 .pad(5)
474 .fg(theme.color("negative.light"))
475 .to_line()
476 .filled(theme.color("negative")),
477 term::Label::space()
478 .pad(5)
479 .fg(theme.color("dim"))
480 .to_line()
481 .filled(theme.color("negative")),
482 term::label(" - ")
483 .fg(theme.color("negative.light"))
484 .to_line()
485 .filled(theme.color("negative.dark")),
486 line.pretty(hi, blobs, repo)
487 .filled(theme.color("negative.dark")),
488 term::Line::blank().filled(term::Color::default()),
489 ]);
490 }
491 Modification::Context {
492 line_no_old,
493 line_no_new,
494 ..
495 } => {
496 table.push([
497 term::label(line_no_old.to_string())
498 .pad(5)
499 .fg(theme.color("dim"))
500 .to_line()
501 .filled(theme.color("faint")),
502 term::label(line_no_new.to_string())
503 .pad(5)
504 .fg(theme.color("dim"))
505 .to_line()
506 .filled(theme.color("faint")),
507 term::label(" ").to_line().filled(term::Color::default()),
508 line.pretty(hi, blobs, repo).filled(term::Color::default()),
509 term::Line::blank().filled(term::Color::default()),
510 ]);
511 }
512 }
513 }
514 vstack.push(table);
515 vstack
516 }
517}
518
519impl ToPretty for Modification {
520 type Output = term::Line;
521 type Context = Blobs<Vec<term::Line>>;
522
523 fn pretty<R: Repo>(
524 &self,
525 _hi: &mut Highlighter,
526 blobs: &Blobs<Vec<term::Line>>,
527 _repo: &R,
528 ) -> Self::Output {
529 match self {
530 Modification::Deletion(diff::Deletion { line, line_no }) => {
531 if let Some(lines) = &blobs.old.as_ref() {
532 lines[*line_no as usize - 1].clone()
533 } else {
534 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
535 }
536 }
537 Modification::Addition(diff::Addition { line, line_no }) => {
538 if let Some(lines) = &blobs.new.as_ref() {
539 lines[*line_no as usize - 1].clone()
540 } else {
541 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
542 }
543 }
544 Modification::Context {
545 line, line_no_new, ..
546 } => {
547 if let Some(lines) = &blobs.new.as_ref() {
549 lines[*line_no_new as usize - 1].clone()
550 } else {
551 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
552 }
553 }
554 }
555 }
556}
557
558fn pretty_modification<R: Repo>(
560 header: &FileHeader,
561 diff: &DiffContent,
562 old: Option<(&Path, Oid)>,
563 new: Option<(&Path, Oid)>,
564 repo: &R,
565 hi: &mut Highlighter,
566) -> VStack<'static> {
567 let blobs = Blobs::from_paths(old, new, repo);
568 let header = header.pretty(hi, &diff.stats().copied(), repo);
569 let vstack = term::VStack::default()
570 .border(Some(term::colors::FAINT))
571 .padding(1)
572 .child(header);
573
574 let body = diff.pretty(hi, &blobs, repo);
575 if body.is_empty() {
576 vstack
577 } else {
578 vstack.divider().merge(body)
579 }
580}
581
582#[cfg(test)]
583mod test {
584 use std::ffi::OsStr;
585
586 use term::Constraint;
587 use term::Element;
588
589 use super::*;
590 use radicle::git::raw::RepositoryOpenFlags;
591 use radicle::git::raw::{Oid, Repository};
592
593 #[test]
594 #[ignore]
595 fn test_pretty() {
596 let repo = Repository::open_ext::<_, _, &[&OsStr]>(
597 env!("CARGO_MANIFEST_DIR"),
598 RepositoryOpenFlags::all(),
599 &[],
600 )
601 .unwrap();
602 let commit = repo
603 .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
604 .unwrap();
605 let parent = commit.parents().next().unwrap();
606 let old_tree = parent.tree().unwrap();
607 let new_tree = commit.tree().unwrap();
608 let diff = repo
609 .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
610 .unwrap();
611 let diff = Diff::try_from(diff).unwrap();
612
613 let mut hi = Highlighter::default();
614 let pretty = diff.pretty(&mut hi, &(), &repo);
615
616 pretty
617 .write(Constraint::from_env().unwrap_or_default())
618 .unwrap();
619 }
620}