diff --git a/radicle-cli/examples/rad-diff.md b/radicle-cli/examples/rad-diff.md index 3c07e63c..64ce618a 100644 --- a/radicle-cli/examples/rad-diff.md +++ b/radicle-cli/examples/rad-diff.md @@ -81,3 +81,79 @@ $ rad diff --staged ╰──────────────────────────────────────────────╯ ``` + +``` +$ git rm -f -q main.c +$ rad diff --staged +╭────────────────────────────────────────────╮ +│ main.c -6 ❲deleted❳ │ +├────────────────────────────────────────────┤ +│ @@ -1,6 +0,0 @@ │ +│ 1 - #include │ +│ 2 - │ +│ 3 - int main(void) { │ +│ 4 - printf("Hello World!/n"); │ +│ 5 - return 0; │ +│ 6 - } │ +╰────────────────────────────────────────────╯ + +``` + +For now, copies are not detected. + +``` +$ git reset --hard master -q +$ mkdir docs +$ cp README.md docs/README.md +$ git add docs +$ rad diff --staged +╭─────────────────────────────╮ +│ docs/README.md +1 ❲created❳ │ +├─────────────────────────────┤ +│ @@ -0,0 +1,1 @@ │ +│ 1 + Hello World! │ +╰─────────────────────────────╯ + +$ git reset +$ git checkout . +``` + +Empty file. + +``` +$ touch EMPTY +$ git add EMPTY +$ rad diff --staged +╭─────────────────╮ +│ EMPTY ❲created❳ │ +╰─────────────────╯ + +$ git reset +$ git checkout . +``` + +File mode change. + +``` +$ chmod +x README.md +$ rad diff +╭───────────────────────────────────────────╮ +│ README.md 100644 -> 100755 ❲mode changed❳ │ +╰───────────────────────────────────────────╯ + +$ git reset -q +$ git checkout . +``` + +Binary file. + +``` +$ touch file.bin +$ truncate -s 8 file.bin +$ git add file.bin +$ rad diff --staged +╭─────────────────────────────╮ +│ file.bin ❲binary❳ ❲created❳ │ +╰─────────────────────────────╯ + +``` diff --git a/radicle-cli/src/git/ddiff.rs b/radicle-cli/src/git/ddiff.rs index 0cc6b287..29761578 100644 --- a/radicle-cli/src/git/ddiff.rs +++ b/radicle-cli/src/git/ddiff.rs @@ -245,6 +245,7 @@ impl From<&FileDDiff> for unified_diff::FileHeader { path: value.path.clone(), old: value.old.clone(), new: value.new.clone(), + binary: false, } } } diff --git a/radicle-cli/src/git/pretty_diff.rs b/radicle-cli/src/git/pretty_diff.rs index 5098d457..22187398 100644 --- a/radicle-cli/src/git/pretty_diff.rs +++ b/radicle-cli/src/git/pretty_diff.rs @@ -1,11 +1,14 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use radicle::git; +use radicle_git_ext::Oid; use radicle_surf::diff; +use radicle_surf::diff::{Added, Copied, Deleted, FileStats, Hunks, Modified, Moved}; use radicle_surf::diff::{Diff, DiffContent, FileDiff, Hunk, Modification}; use radicle_term as term; use term::cell::Cell; +use term::VStack; use crate::git::unified_diff::FileHeader; use crate::terminal::highlight::{Highlighter, Theme}; @@ -62,10 +65,59 @@ impl Repo for git::raw::Repository { } /// Blobs passed down to the hunk renderer. -#[derive(Debug, Default)] -pub struct Blobs { - old: Option>, - new: Option>, +#[derive(Debug)] +pub struct Blobs { + pub old: Option, + pub new: Option, +} + +impl Blobs { + pub fn new(old: Option, new: Option) -> Self { + Self { old, new } + } +} + +impl Blobs<(PathBuf, Blob)> { + pub fn highlight(&self, hi: &mut Highlighter) -> Blobs> { + let mut blobs = Blobs::default(); + if let Some((path, Blob::Plain(content))) = &self.old { + blobs.old = hi.highlight(path, content).ok(); + } + if let Some((path, Blob::Plain(content))) = &self.new { + blobs.new = hi.highlight(path, content).ok(); + } + blobs + } + + pub fn from_paths( + old: Option<(&Path, Oid)>, + new: Option<(&Path, Oid)>, + repo: &R, + ) -> Blobs<(PathBuf, Blob)> { + Blobs::new( + old.and_then(|(path, oid)| { + repo.blob(oid) + .ok() + .or_else(|| repo.file(path)) + .map(|blob| (path.to_path_buf(), blob)) + }), + new.and_then(|(path, oid)| { + repo.blob(oid) + .ok() + .or_else(|| repo.file(path)) + .map(|blob| (path.to_path_buf(), blob)) + }), + ) + } +} + +impl Default for Blobs { + fn default() -> Self { + Self { + old: None, + new: None, + } + } } /// Types that can be rendered as pretty diffs. @@ -96,39 +148,108 @@ impl ToPretty for Diff { ) -> Self::Output { term::VStack::default() .padding(0) - .children(self.files().map(|f| f.pretty(hi, context, repo).boxed())) + .children(self.files().flat_map(|f| { + [ + f.pretty(hi, context, repo).boxed(), + term::Line::blank().boxed(), // Blank line between files. + ] + })) } } impl ToPretty for FileHeader { type Output = term::Line; - type Context = (); + type Context = Option; fn pretty( &self, _hi: &mut Highlighter, - _context: &Self::Context, + stats: &Self::Context, _repo: &R, ) -> Self::Output { - match self { - FileHeader::Added { path, .. } => term::Line::new(path.display().to_string()), + let theme = Theme::default(); + let (mut header, badge, binary) = match self { + FileHeader::Added { path, binary, .. } => ( + term::Line::new(path.display().to_string()), + Some(term::format::badge_positive("created")), + *binary, + ), FileHeader::Moved { old_path, new_path, .. - } => term::Line::spaced([ - term::label(old_path.display().to_string()), - term::label("->".to_string()), - term::label(new_path.display().to_string()), - ]), - FileHeader::Deleted { path, .. } => term::Line::new(path.display().to_string()), - FileHeader::Modified { path, .. } => term::Line::new(path.display().to_string()), + } => ( + term::Line::spaced([ + term::label(old_path.display().to_string()), + term::label("->".to_string()), + term::label(new_path.display().to_string()), + ]), + Some(term::format::badge_secondary("moved")), + false, + ), + FileHeader::Deleted { path, binary, .. } => ( + term::Line::new(path.display().to_string()), + Some(term::format::badge_negative("deleted")), + *binary, + ), + FileHeader::Modified { + path, + old, + new, + binary, + .. + } => { + if old.mode != new.mode { + ( + term::Line::spaced([ + term::label(path.display().to_string()), + term::label(format!("{:o}", u32::from(old.mode.clone()))) + .fg(term::Color::Blue), + term::label("->".to_string()), + term::label(format!("{:o}", u32::from(new.mode.clone()))) + .fg(term::Color::Blue), + ]), + Some(term::format::badge_secondary("mode changed")), + *binary, + ) + } else { + (term::Line::new(path.display().to_string()), None, *binary) + } + } FileHeader::Copied { old_path, new_path, .. - } => term::Line::spaced([ - term::label(old_path.display().to_string()), - term::label("->".to_string()), - term::label(new_path.display().to_string()), - ]), + } => ( + term::Line::spaced([ + term::label(old_path.display().to_string()), + term::label("->".to_string()), + term::label(new_path.display().to_string()), + ]), + Some(term::format::badge_secondary("copied")), + false, + ), + }; + + if binary { + header.push(term::Label::space()); + header.push(term::label(term::format::badge_yellow("binary"))); } + + let (additions, deletions) = if let Some(stats) = stats { + (stats.additions, stats.deletions) + } else { + (0, 0) + }; + if deletions > 0 { + header.push(term::Label::space()); + header.push(term::label(format!("-{deletions}")).fg(theme.color("negative.light"))); + } + if additions > 0 { + header.push(term::Label::space()); + header.push(term::label(format!("+{additions}")).fg(theme.color("positive.light"))); + } + if let Some(badge) = badge { + header.push(term::Label::space()); + header.push(badge); + } + header } } @@ -142,131 +263,144 @@ impl ToPretty for FileDiff { _context: &Self::Context, repo: &R, ) -> Self::Output { - let content = match self { - FileDiff::Added(f) => f.diff.pretty(hi, self, repo), - FileDiff::Moved(f) => f.diff.pretty(hi, self, repo), - FileDiff::Deleted(f) => f.diff.pretty(hi, self, repo), - FileDiff::Modified(f) => f.diff.pretty(hi, self, repo), - FileDiff::Copied(f) => f.diff.pretty(hi, self, repo), - }; - term::VStack::default() - .padding(0) - .child(content) - .child(term::Line::blank()) + let header = FileHeader::from(self); + + match self { + FileDiff::Added(f) => f.pretty(hi, &header, repo), + FileDiff::Deleted(f) => f.pretty(hi, &header, repo), + FileDiff::Modified(f) => f.pretty(hi, &header, repo), + FileDiff::Moved(f) => f.pretty(hi, &header, repo), + FileDiff::Copied(f) => f.pretty(hi, &header, repo), + } } } impl ToPretty for DiffContent { type Output = term::VStack<'static>; - type Context = FileDiff; + type Context = Blobs<(PathBuf, Blob)>; fn pretty( &self, hi: &mut Highlighter, - context: &Self::Context, + blobs: &Self::Context, repo: &R, ) -> Self::Output { - let header = FileHeader::from(context); - let theme = Theme::default(); + let mut vstack = term::VStack::default().padding(0); - let (old, new, badge) = match context { - FileDiff::Added(f) => ( - None, - Some((f.new.oid, f.path.clone())), - Some(term::format::badge_positive("created")), - ), - FileDiff::Moved(f) => ( - Some((f.old.oid, f.old_path.clone())), - Some((f.new.oid, f.new_path.clone())), - Some(term::format::badge_secondary("moved")), - ), - FileDiff::Deleted(f) => ( - Some((f.old.oid, f.path.clone())), - None, - Some(term::format::badge_negative("deleted")), - ), - FileDiff::Modified(f) => ( - Some((f.old.oid, f.path.clone())), - Some((f.new.oid, f.path.clone())), - None, - ), - FileDiff::Copied(f) => ( - Some((f.old.oid, f.old_path.clone())), - Some((f.old.oid, f.new_path.clone())), - Some(term::format::badge_secondary("copied")), - ), - }; - let mut header = header.pretty(hi, &(), repo); + match self { + DiffContent::Plain { + hunks: Hunks(hunks), + .. + } => { + let blobs = blobs.highlight(hi); - let (additions, deletions) = if let Some(stats) = self.stats() { - (stats.additions, stats.deletions) - } else { - (0, 0) - }; - - if deletions > 0 { - header.push(term::Label::space()); - header.push(term::label(format!("-{deletions}")).fg(theme.color("negative.light"))); - } - if additions > 0 { - header.push(term::Label::space()); - header.push(term::label(format!("+{additions}")).fg(theme.color("positive.light"))); - } - if let Some(badge) = badge { - header.push(term::Label::space()); - header.push(badge); - } - - let old = old.and_then(|(oid, path)| repo.blob(oid).ok().or_else(|| repo.file(&path))); - let new = new.and_then(|(oid, path)| repo.blob(oid).ok().or_else(|| repo.file(&path))); - let mut blobs = Blobs::default(); - - if let Some(Blob::Plain(content)) = old { - blobs.old = hi.highlight(context.path(), &content).ok(); - } - if let Some(Blob::Plain(content)) = new { - blobs.new = hi.highlight(context.path(), &content).ok(); - } - let mut vstack = term::VStack::default() - .border(Some(term::colors::FAINT)) - .padding(1) - .child(term::Line::default().extend(header)); - - match context { - FileDiff::Moved(_) | FileDiff::Copied(_) => {} - FileDiff::Added(_) if blobs.new.is_none() => { - vstack = vstack.divider(); - vstack.push(term::Line::new(term::format::italic("Empty file"))); - } - FileDiff::Deleted(_) if blobs.old.is_none() => { - vstack = vstack.divider(); - vstack.push(term::Line::new(term::format::italic("Empty file"))); - } - FileDiff::Added(_) | FileDiff::Deleted(_) | FileDiff::Modified(_) => { - vstack = vstack.divider(); - - match self { - DiffContent::Plain { hunks, .. } => { - for (i, h) in hunks.iter().enumerate() { - vstack.push(h.pretty(hi, &blobs, repo)); - if i != hunks.0.len() - 1 { - vstack = vstack.divider(); - } - } - } - DiffContent::Empty => { - vstack.push(term::Line::new(term::format::italic("Empty file"))); - } - DiffContent::Binary => { - vstack.push(term::Line::new(term::format::italic("Binary file"))); + for (i, h) in hunks.iter().enumerate() { + vstack.push(h.pretty(hi, &blobs, repo)); + if i != hunks.len() - 1 { + vstack = vstack.divider(); } } } + DiffContent::Empty => {} + DiffContent::Binary => {} } vstack } } +impl ToPretty for Moved { + type Output = term::VStack<'static>; + type Context = FileHeader; + + fn pretty( + &self, + hi: &mut Highlighter, + header: &Self::Context, + repo: &R, + ) -> Self::Output { + let header = header.pretty(hi, &self.diff.stats().copied(), repo); + + term::VStack::default() + .border(Some(term::colors::FAINT)) + .padding(1) + .child(term::Line::default().extend(header)) + } +} + +impl ToPretty for Added { + type Output = term::VStack<'static>; + type Context = FileHeader; + + fn pretty( + &self, + hi: &mut Highlighter, + header: &Self::Context, + repo: &R, + ) -> Self::Output { + let old = None; + let new = Some((self.path.as_path(), self.new.oid)); + + pretty_modification(header, &self.diff, old, new, repo, hi) + } +} + +impl ToPretty for Deleted { + type Output = term::VStack<'static>; + type Context = FileHeader; + + fn pretty( + &self, + hi: &mut Highlighter, + header: &Self::Context, + repo: &R, + ) -> Self::Output { + let old = Some((self.path.as_path(), self.old.oid)); + let new = None; + + pretty_modification(header, &self.diff, old, new, repo, hi) + } +} + +impl ToPretty for Modified { + type Output = term::VStack<'static>; + type Context = FileHeader; + + fn pretty( + &self, + hi: &mut Highlighter, + header: &Self::Context, + repo: &R, + ) -> Self::Output { + let old = Some((self.path.as_path(), self.old.oid)); + let new = Some((self.path.as_path(), self.new.oid)); + + pretty_modification(header, &self.diff, old, new, repo, hi) + } +} + +impl ToPretty for Copied { + type Output = term::VStack<'static>; + type Context = FileHeader; + + fn pretty( + &self, + hi: &mut Highlighter, + _context: &Self::Context, + repo: &R, + ) -> Self::Output { + let header = FileHeader::Copied { + old_path: self.old_path.clone(), + new_path: self.old_path.clone(), + } + .pretty(hi, &self.diff.stats().copied(), repo); + + term::VStack::default() + .border(Some(term::colors::FAINT)) + .padding(1) + .child(header) + } +} + impl ToPretty for HunkHeader { type Output = term::Line; type Context = (); @@ -291,9 +425,14 @@ impl ToPretty for HunkHeader { impl ToPretty for Hunk { type Output = term::VStack<'static>; - type Context = Blobs; + type Context = Blobs>; - fn pretty(&self, hi: &mut Highlighter, blobs: &Blobs, repo: &R) -> Self::Output { + fn pretty( + &self, + hi: &mut Highlighter, + blobs: &Self::Context, + repo: &R, + ) -> Self::Output { let mut vstack = term::VStack::default().padding(0); let mut table = term::Table::<5, term::Filled>::new(term::TableOptions { overflow: false, @@ -379,9 +518,14 @@ impl ToPretty for Hunk { impl ToPretty for Modification { type Output = term::Line; - type Context = Blobs; + type Context = Blobs>; - fn pretty(&self, _hi: &mut Highlighter, blobs: &Blobs, _repo: &R) -> Self::Output { + fn pretty( + &self, + _hi: &mut Highlighter, + blobs: &Blobs>, + _repo: &R, + ) -> Self::Output { match self { Modification::Deletion(diff::Deletion { line, line_no }) => { if let Some(lines) = &blobs.old.as_ref() { @@ -411,6 +555,30 @@ impl ToPretty for Modification { } } +/// Render a file added, deleted or modified. +fn pretty_modification( + header: &FileHeader, + diff: &DiffContent, + old: Option<(&Path, Oid)>, + new: Option<(&Path, Oid)>, + repo: &R, + hi: &mut Highlighter, +) -> VStack<'static> { + let blobs = Blobs::from_paths(old, new, repo); + let header = header.pretty(hi, &diff.stats().copied(), repo); + let vstack = term::VStack::default() + .border(Some(term::colors::FAINT)) + .padding(1) + .child(header); + + let body = diff.pretty(hi, &blobs, repo); + if body.is_empty() { + vstack + } else { + vstack.divider().merge(body) + } +} + #[cfg(test)] mod test { use std::ffi::OsStr; diff --git a/radicle-cli/src/git/unified_diff.rs b/radicle-cli/src/git/unified_diff.rs index e1d5a023..d7b698d5 100644 --- a/radicle-cli/src/git/unified_diff.rs +++ b/radicle-cli/src/git/unified_diff.rs @@ -50,6 +50,7 @@ pub enum FileHeader { Added { path: PathBuf, new: DiffFile, + binary: bool, }, Copied { old_path: PathBuf, @@ -58,11 +59,13 @@ pub enum FileHeader { Deleted { path: PathBuf, old: DiffFile, + binary: bool, }, Modified { path: PathBuf, old: DiffFile, new: DiffFile, + binary: bool, }, Moved { old_path: PathBuf, @@ -78,15 +81,21 @@ impl std::convert::From<&FileDiff> for FileHeader { path: v.path.clone(), old: v.old.clone(), new: v.new.clone(), + binary: matches!(v.diff, DiffContent::Binary), }, FileDiff::Added(v) => FileHeader::Added { path: v.path.clone(), new: v.new.clone(), + binary: matches!(v.diff, DiffContent::Binary), + }, + FileDiff::Copied(c) => FileHeader::Copied { + old_path: c.old_path.clone(), + new_path: c.new_path.clone(), }, - FileDiff::Copied(_) => todo!(), FileDiff::Deleted(v) => FileHeader::Deleted { path: v.path.clone(), old: v.old.clone(), + binary: matches!(v.diff, DiffContent::Binary), }, FileDiff::Moved(v) => FileHeader::Moved { old_path: v.old_path.clone(), @@ -288,7 +297,7 @@ impl Encode for FileDiff { impl Encode for FileHeader { fn encode(&self, w: &mut Writer) -> Result<(), Error> { match self { - FileHeader::Modified { path, old, new } => { + FileHeader::Modified { path, old, new, .. } => { w.meta(format!( "diff --git a/{} b/{}", path.display(), @@ -315,7 +324,7 @@ impl Encode for FileHeader { w.meta(format!("--- a/{}", path.display()))?; w.meta(format!("+++ b/{}", path.display()))?; } - FileHeader::Added { path, new } => { + FileHeader::Added { path, new, .. } => { w.meta(format!( "diff --git a/{} b/{}", path.display(), @@ -333,7 +342,7 @@ impl Encode for FileHeader { w.meta(format!("+++ b/{}", path.display()))?; } FileHeader::Copied { .. } => todo!(), - FileHeader::Deleted { path, old } => { + FileHeader::Deleted { path, old, .. } => { w.meta(format!( "diff --git a/{} b/{}", path.display(), diff --git a/radicle-term/src/format.rs b/radicle-term/src/format.rs index e91f8d69..50f33665 100644 --- a/radicle-term/src/format.rs +++ b/radicle-term/src/format.rs @@ -52,6 +52,14 @@ pub fn badge_primary(input: D) -> Paint { } } +pub fn badge_yellow(input: D) -> Paint { + if Paint::is_enabled() { + Paint::yellow(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } +} + pub fn badge_positive(input: D) -> Paint { if Paint::is_enabled() { Paint::green(format!(" {input} ")).invert() diff --git a/radicle-term/src/vstack.rs b/radicle-term/src/vstack.rs index 86332dd7..844ca402 100644 --- a/radicle-term/src/vstack.rs +++ b/radicle-term/src/vstack.rs @@ -66,6 +66,11 @@ impl<'a> VStack<'a> { self } + /// Check if this stack is empty. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + /// Add multiple elements to the stack. pub fn children(self, children: I) -> Self where @@ -79,6 +84,14 @@ impl<'a> VStack<'a> { vstack } + /// Merge with another `VStack`. + pub fn merge(mut self, other: Self) -> Self { + for row in other.rows { + self.rows.push(row); + } + self + } + /// Set or unset the outer border. pub fn border(mut self, color: Option) -> Self { self.opts.border = color;