//! The middle's file, read off the UI thread (TREE-03). use std::{io, path::Path, rc::Rc, sync::Arc}; use ely_gpui_component::git::{ChangeSection, DiffLine, LineKind, Stretch, mark_words}; use gpui::{Image, ImageFormat}; use crate::{ diff::{self, Body, FileDiff, Space}, git::Git, status::{Entry, State}, }; /// Picture formats shown side by side, by extension. const PICTURES: [(&str, ImageFormat); 9] = [ ("png", ImageFormat::Png), ("jpg", ImageFormat::Jpeg), ("jpeg", ImageFormat::Jpeg), ("gif", ImageFormat::Gif), ("webp", ImageFormat::Webp), ("bmp", ImageFormat::Bmp), ("tif", ImageFormat::Tiff), ("tiff", ImageFormat::Tiff), ("ico", ImageFormat::Ico), ]; /// What a read found, before it becomes a view. pub enum Found { Text { diff: FileDiff, seen: Vec, old: Option, }, Picture { format: ImageFormat, old: Option>, new: Option>, }, Binary { old: Option, new: Option, }, Folder, Conflict(crate::conflict::Stages), } /// What the middle draws. pub enum View { Text { diff: Arc, /// The output read, to see a change before a patch. seen: Arc>, stretches: Rc>, /// Each stretch's hunk, if it is one. hunks: Vec>, }, Picture { old: Option>, new: Option>, }, Binary { old: Option, new: Option, }, Folder, Conflict(crate::conflict::Stages), } /// How every diff is asked for, before what it compares. pub fn base(space: Space) -> Vec { // Blank context lines keep their leading space. let mut args: Vec = [ "-c", "diff.suppressBlankEmpty=false", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", "-U3", ] .map(String::from) .to_vec(); args.extend(space.flag().map(String::from)); args } /// The arguments that read `entry` in `section`. pub fn args(entry: &Entry, section: ChangeSection, space: Space) -> Vec { let mut args = base(space); match section { ChangeSection::Staged => { args.extend(["--cached", "-M", "--"].map(String::from)); args.push(entry.path.clone()); args.extend(entry.from.clone()); } ChangeSection::Untracked => { args.extend(["--no-index", "--", "/dev/null"].map(String::from)); args.push(entry.path.clone()); } _ => { args.push("--".into()); args.push(entry.path.clone()); } } args } /// What Git compares: a link's own path, else the file. fn work_bytes(path: &Path) -> io::Result> { match std::fs::symlink_metadata(path)?.file_type().is_symlink() { true => Ok(std::fs::read_link(path)? .into_os_string() .into_encoded_bytes()), false => std::fs::read(path), } } fn blob(git: &Git, root: &Path, name: &str) -> io::Result> { git.run(root, &["cat-file", "blob", name]) } fn size(git: &Git, root: &Path, name: &str) -> io::Result { let said = git.run(root, &["cat-file", "-s", name])?; String::from_utf8_lossy(&said) .trim() .parse() .map_err(io::Error::other) } /// Old and new blobs; None means the work tree. fn sides( entry: &Entry, section: ChangeSection, born: bool, ) -> (Option, Option>) { let deleted = |letter| matches!(entry.state, State::Tracked { staged, unstaged } if (if section == ChangeSection::Staged { staged } else { unstaged }) == letter); let old = match section { ChangeSection::Staged if born && !deleted('A') => Some(format!( "HEAD:{}", entry.from.as_deref().unwrap_or(&entry.path) )), ChangeSection::Unstaged => Some(format!(":{}", entry.path)), _ => None, }; let new = match (section, deleted('D')) { (_, true) => None, (ChangeSection::Staged, false) => Some(Some(format!(":{}", entry.path))), _ => Some(None), }; (old, new) } /// Reads what `entry` changed, as Git sees it. pub fn read( git: &Git, root: &Path, entry: &Entry, section: ChangeSection, space: Space, born: bool, ) -> io::Result { if matches!(entry.state, State::Conflicted(_)) { return Ok(Found::Conflict(crate::conflict::read( git, root, &entry.path, )?)); } if entry.path.ends_with('/') { return Ok(Found::Folder); } let args = args(entry, section, space); let args: Vec<&str> = args.iter().map(String::as_str).collect(); let seen = match section { ChangeSection::Untracked => git.run_or_one(root, &args)?, _ => git.run(root, &args)?, }; let (old, new) = sides(entry, section, born); compare(git, root, seen, &entry.path, old, new) } /// Makes what Git printed for `path` into a view's parts. pub fn compare( git: &Git, root: &Path, seen: Vec, path: &str, old: Option, new: Option>, ) -> io::Result { let diff = diff::parse(&seen)?; let work = root.join(path); let extension = Path::new(path) .extension() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase); let picture = PICTURES .iter() .find(|(e, _)| extension.as_deref() == Some(*e)) .map(|(_, f)| *f); match (&diff.body, picture) { (Body::Binary, Some(format)) => { let old = old.map(|name| blob(git, root, &name)).transpose()?; let new = match new { Some(Some(name)) => Some(blob(git, root, &name)?), Some(None) => Some(work_bytes(&work)?), None => None, }; Ok(Found::Picture { format, old, new }) } (Body::Binary | Body::Undecodable, _) => { let old = old.map(|name| size(git, root, &name)).transpose()?; let new = match new { Some(Some(name)) => Some(size(git, root, &name)?), Some(None) => Some(std::fs::symlink_metadata(&work)?.len()), None => None, }; Ok(Found::Binary { old, new }) } (Body::Text(_), _) => { let old = old.map(|name| blob(git, root, &name)).transpose()?; let new = match new { Some(Some(name)) => Some(blob(git, root, &name)?), Some(None) => Some(work_bytes(&work)?), None => None, }; // Not UTF-8 throughout: shown by size. let text = |bytes: &Option>| { bytes .as_deref() .is_none_or(|b| std::str::from_utf8(b).is_ok()) }; if !text(&old) || !text(&new) { let size = |bytes: Option>| bytes.map(|b| b.len() as u64); return Ok(Found::Binary { old: size(old), new: size(new), }); } let old = old .map(String::from_utf8) .transpose() .map_err(io::Error::other)?; Ok(Found::Text { diff, seen, old }) } } } fn line(raw: &diff::Raw, old: Option, new: Option) -> DiffLine { DiffLine { kind: raw.kind, old, new, text: raw.text.trim_end_matches('\r').to_string().into(), words: Vec::new(), } } /// Hunks from Git, the unchanged lines between folded. pub fn stretches( diff: &FileDiff, old: Option<&str>, ) -> io::Result<(Vec, Vec>)> { let Body::Text(hunks) = &diff.body else { return Ok((Vec::new(), Vec::new())); }; // An empty file has no lines, not one empty line. let olds: Vec<&str> = match old { Some(text) if !text.is_empty() => text .strip_suffix('\n') .unwrap_or(text) .split('\n') .collect(), _ => Vec::new(), }; let (mut out, mut places) = (Vec::new(), Vec::new()); // The next old line not yet shown, from one. let mut next = 1; let fold = |from: usize, to: usize, shift: isize| -> io::Result> { (from..to) .map(|n| { let text = olds .get(n - 1) .ok_or_else(|| io::Error::other("the old file is shorter than its diff"))?; Ok(DiffLine { kind: LineKind::Same, old: Some(n), new: n.checked_add_signed(shift), text: text.trim_end_matches('\r').to_string().into(), words: Vec::new(), }) }) .collect() }; // Two sections share no old text to fold. let folds = old.is_some() && diff.sections() < 2; // New less old line numbers, after the last hunk. let mut shift = 0isize; for (at, hunk) in hunks.iter().enumerate() { let (mut o, mut n) = (diff_start(hunk.old), diff_start(hunk.new)); let before = n as isize - o as isize; if folds && o > next { out.push(Stretch::Folded(fold(next, o, before)?)); places.push(None); } let mut lines = Vec::new(); for raw in &hunk.lines { lines.push(match raw.kind { LineKind::Same => line(raw, Some(o), Some(n)), LineKind::Removed => line(raw, Some(o), None), LineKind::Added => line(raw, None, Some(n)), }); o += usize::from(raw.kind != LineKind::Added); n += usize::from(raw.kind != LineKind::Removed); } mark_words(&mut lines); let header = format!( "@@ -{},{} +{},{} @@ {}", hunk.old.0, hunk.old.1, hunk.new.0, hunk.new.1, hunk.tail ); out.push(Stretch::Hunk { header: header.trim_end().to_string().into(), lines, }); places.push(Some(at)); next = o; shift = n as isize - o as isize; } if folds && next <= olds.len() { out.push(Stretch::Folded(fold(next, olds.len() + 1, shift)?)); places.push(None); } Ok((out, places)) } /// A range's first line number; an empty range starts after. fn diff_start((start, count): (usize, usize)) -> usize { if count == 0 { start + 1 } else { start } } /// Makes the view on the UI thread. pub fn view(found: Found) -> io::Result { Ok(match found { Found::Text { diff, seen, old } => { let (stretches, hunks) = stretches(&diff, old.as_deref())?; View::Text { diff: Arc::new(diff), seen: Arc::new(seen), stretches: Rc::new(stretches), hunks, } } Found::Picture { format, old, new } => { let image = |bytes: Vec| Arc::new(Image::from_bytes(format, bytes)); View::Picture { old: old.map(image), new: new.map(image), } } Found::Binary { old, new } => View::Binary { old, new }, Found::Folder => View::Folder, Found::Conflict(stages) => View::Conflict(stages), }) }