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
448 table.extend(
449 self.lines
450 .iter()
451 .map(|line| line_to_table_row(hi, blobs, repo, &theme, line)),
452 );
453
454 vstack.push(table);
455 vstack
456 }
457}
458
459fn line_to_table_row<R: Repo>(
460 hi: &mut Highlighter,
461 blobs: &Blobs<Vec<radicle_term::Line>>,
462 repo: &R,
463 theme: &Theme,
464 line: &Modification,
465) -> [radicle_term::Filled<radicle_term::Line>; 5] {
466 match line {
467 Modification::Addition(a) => [
468 term::Label::space()
469 .pad(5)
470 .bg(theme.color("positive"))
471 .to_line()
472 .filled(theme.color("positive")),
473 term::label(a.line_no.to_string())
474 .pad(5)
475 .fg(theme.color("positive.light"))
476 .to_line()
477 .filled(theme.color("positive")),
478 term::label(" + ")
479 .fg(theme.color("positive.light"))
480 .to_line()
481 .filled(theme.color("positive.dark")),
482 line.pretty(hi, blobs, repo)
483 .filled(theme.color("positive.dark")),
484 term::Line::blank().filled(term::Color::default()),
485 ],
486 Modification::Deletion(a) => [
487 term::label(a.line_no.to_string())
488 .pad(5)
489 .fg(theme.color("negative.light"))
490 .to_line()
491 .filled(theme.color("negative")),
492 term::Label::space()
493 .pad(5)
494 .fg(theme.color("dim"))
495 .to_line()
496 .filled(theme.color("negative")),
497 term::label(" - ")
498 .fg(theme.color("negative.light"))
499 .to_line()
500 .filled(theme.color("negative.dark")),
501 line.pretty(hi, blobs, repo)
502 .filled(theme.color("negative.dark")),
503 term::Line::blank().filled(term::Color::default()),
504 ],
505 Modification::Context {
506 line_no_old,
507 line_no_new,
508 ..
509 } => [
510 term::label(line_no_old.to_string())
511 .pad(5)
512 .fg(theme.color("dim"))
513 .to_line()
514 .filled(theme.color("faint")),
515 term::label(line_no_new.to_string())
516 .pad(5)
517 .fg(theme.color("dim"))
518 .to_line()
519 .filled(theme.color("faint")),
520 term::label(" ").to_line().filled(term::Color::default()),
521 line.pretty(hi, blobs, repo).filled(term::Color::default()),
522 term::Line::blank().filled(term::Color::default()),
523 ],
524 }
525}
526
527impl ToPretty for Modification {
528 type Output = term::Line;
529 type Context = Blobs<Vec<term::Line>>;
530
531 fn pretty<R: Repo>(
532 &self,
533 _hi: &mut Highlighter,
534 blobs: &Blobs<Vec<term::Line>>,
535 _repo: &R,
536 ) -> Self::Output {
537 match self {
538 Modification::Deletion(diff::Deletion { line, line_no }) => {
539 if let Some(lines) = &blobs.old.as_ref() {
540 lines[*line_no as usize - 1].clone()
541 } else {
542 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
543 }
544 }
545 Modification::Addition(diff::Addition { line, line_no }) => {
546 if let Some(lines) = &blobs.new.as_ref() {
547 lines[*line_no as usize - 1].clone()
548 } else {
549 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
550 }
551 }
552 Modification::Context {
553 line, line_no_new, ..
554 } => {
555 if let Some(lines) = &blobs.new.as_ref() {
557 lines[*line_no_new as usize - 1].clone()
558 } else {
559 term::Line::new(String::from_utf8_lossy(line.as_bytes()).as_ref())
560 }
561 }
562 }
563 }
564}
565
566fn pretty_modification<R: Repo>(
568 header: &FileHeader,
569 diff: &DiffContent,
570 old: Option<(&Path, Oid)>,
571 new: Option<(&Path, Oid)>,
572 repo: &R,
573 hi: &mut Highlighter,
574) -> VStack<'static> {
575 let blobs = Blobs::from_paths(old, new, repo);
576 let header = header.pretty(hi, &diff.stats().copied(), repo);
577 let vstack = term::VStack::default()
578 .border(Some(term::colors::FAINT))
579 .padding(1)
580 .child(header);
581
582 let body = diff.pretty(hi, &blobs, repo);
583 if body.is_empty() {
584 vstack
585 } else {
586 vstack.divider().merge(body)
587 }
588}
589
590#[cfg(test)]
591mod test {
592 use std::ffi::OsStr;
593
594 use term::Constraint;
595 use term::Element;
596
597 use super::*;
598 use radicle::git::raw::RepositoryOpenFlags;
599 use radicle::git::raw::{Oid, Repository};
600
601 #[test]
602 #[ignore]
603 fn test_pretty() {
604 let repo = Repository::open_ext::<_, _, &[&OsStr]>(
605 env!("CARGO_MANIFEST_DIR"),
606 RepositoryOpenFlags::all(),
607 &[],
608 )
609 .unwrap();
610 let commit = repo
611 .find_commit(Oid::from_str("5078396028e2ec5660aa54a00208f6e11df84aa9").unwrap())
612 .unwrap();
613 let parent = commit.parents().next().unwrap();
614 let old_tree = parent.tree().unwrap();
615 let new_tree = commit.tree().unwrap();
616 let diff = repo
617 .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)
618 .unwrap();
619 let diff = Diff::try_from(diff).unwrap();
620
621 let mut hi = Highlighter::default();
622 let pretty = diff.pretty(&mut hi, &(), &repo);
623
624 pretty
625 .write(Constraint::from_env().unwrap_or_default())
626 .unwrap();
627 }
628}