Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions crates/jayjay-core/src/repo/diff/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ use pollster::FutureExt as _;
use super::{
TreePair,
materialize::{
extract_image_preview, git_lfs_object_placeholder, git_lfs_pointer_placeholder,
is_image_path, materialized_to_string, parse_binary_placeholder_size,
parse_git_lfs_pointer, preview_placeholder,
ImagePreviewResult, extract_image_preview, git_lfs_object_placeholder,
git_lfs_pointer_placeholder, is_image_path, materialized_to_string,
parse_binary_placeholder_size, parse_git_lfs_pointer, preview_placeholder,
},
};
use crate::repo::support::block_on_result;
Expand Down Expand Up @@ -72,18 +72,12 @@ pub(super) fn materialize_diff_content(
)?;

if is_image_path(path.as_internal_file_string()) {
let old_preview = extract_image_preview(path, old_value)?;
let new_preview = extract_image_preview(path, new_value)?;
let old_content = match (&old_preview, hunk_type) {
(Some(preview), _) => Some(preview_placeholder(preview)),
(None, HunkType::Added) => None,
(None, _) => Some("<binary file>".to_owned()),
};
let new_content = match (&new_preview, hunk_type) {
(Some(preview), _) => Some(preview_placeholder(preview)),
(None, HunkType::Removed) => None,
(None, _) => Some("<binary file>".to_owned()),
};
let old_result = extract_image_preview(path, old_value)?;
let new_result = extract_image_preview(path, new_value)?;
let old_content = image_side_content(&old_result, hunk_type, Side::Old);
let new_content = image_side_content(&new_result, hunk_type, Side::New);
let old_preview = image_side_preview(old_result);
let new_preview = image_side_preview(new_result);
return Ok(DiffContent {
old_content,
new_content,
Expand All @@ -107,6 +101,35 @@ pub(super) fn materialize_diff_content(
})
}

#[derive(Clone, Copy)]
enum Side {
Old,
New,
}

fn image_side_content(
result: &ImagePreviewResult,
hunk_type: HunkType,
side: Side,
) -> Option<String> {
match result {
ImagePreviewResult::Image(preview) => Some(preview_placeholder(preview)),
ImagePreviewResult::GitLfsPointer(pointer) => Some(git_lfs_pointer_placeholder(pointer)),
ImagePreviewResult::None => match (side, hunk_type) {
(Side::Old, HunkType::Added) => None,
(Side::New, HunkType::Removed) => None,
_ => Some("<binary file>".to_owned()),
},
}
}

fn image_side_preview(result: ImagePreviewResult) -> Option<DiffPreview> {
match result {
ImagePreviewResult::Image(preview) => Some(preview),
ImagePreviewResult::GitLfsPointer(_) | ImagePreviewResult::None => None,
}
}

pub(super) fn first_diff_content(
trees: &TreePair,
matcher: &dyn Matcher,
Expand Down
45 changes: 40 additions & 5 deletions crates/jayjay-core/src/repo/diff/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,30 @@ pub(super) fn is_image_path(path: &str) -> bool {
.unwrap_or(false)
}

/// Caches image bytes to a content-addressed temp file; returns None for non-files, empty, or oversized.
pub(super) enum ImagePreviewResult {
Image(DiffPreview),
GitLfsPointer(GitLfsPointerInfo),
None,
}

/// Sniffs for LFS pointer bytes before caching; otherwise writes a content-addressed temp file.
pub(super) fn extract_image_preview(
path: &jj_lib::repo_path::RepoPath,
value: MaterializedTreeValue,
) -> CoreResult<Option<DiffPreview>> {
) -> CoreResult<ImagePreviewResult> {
let MaterializedTreeValue::File(mut file) = value else {
return Ok(None);
return Ok(ImagePreviewResult::None);
};
let bytes = block_on_result(
&format!("read image {}", path.as_internal_file_string()),
file.read_all(path),
)?;
if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES {
return Ok(None);
return Ok(ImagePreviewResult::None);
}

if let Some(pointer) = detect_git_lfs_pointer_bytes(&bytes) {
return Ok(ImagePreviewResult::GitLfsPointer(pointer));
}

let path_str = path.as_internal_file_string();
Expand Down Expand Up @@ -63,11 +73,16 @@ pub(super) fn extract_image_preview(
}
}

Ok(Some(DiffPreview::Image {
Ok(ImagePreviewResult::Image(DiffPreview::Image {
path: cache_path.to_string_lossy().into_owned(),
}))
}

fn detect_git_lfs_pointer_bytes(bytes: &[u8]) -> Option<GitLfsPointerInfo> {
let text = std::str::from_utf8(bytes).ok()?;
parse_git_lfs_pointer(text)
}

/// Text placeholder — needed so rename detection and hunk iteration see the entry.
pub(super) fn preview_placeholder(preview: &DiffPreview) -> String {
match preview {
Expand Down Expand Up @@ -216,6 +231,26 @@ mod tests {
assert!(is_image_path("sprite.Gif"));
}

#[test]
fn detects_git_lfs_pointer_bytes() {
let pointer_text = b"version https://git-lfs.github.com/spec/v1\n\
oid sha256:496634778d7b9bdbdb4b98b43a08a00ce8d794ed135a0cb1f345bf6febc5b9b4\n\
size 742800\n";
let pointer = detect_git_lfs_pointer_bytes(pointer_text).expect("should detect pointer");
assert_eq!(pointer.size, 742800);

// PNG magic bytes → not a pointer.
let png_magic = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert!(detect_git_lfs_pointer_bytes(&png_magic).is_none());

// Random binary noise → not a pointer.
let garbage = [0xFFu8; 32];
assert!(detect_git_lfs_pointer_bytes(&garbage).is_none());

// Empty → not a pointer.
assert!(detect_git_lfs_pointer_bytes(&[]).is_none());
}

#[test]
fn is_image_path_rejects_non_images() {
assert!(!is_image_path("main.rs"));
Expand Down
2 changes: 2 additions & 0 deletions crates/jayjay-uniffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,15 @@ pub struct DiffLine {
pub new_line_no: Option<u32>,
pub style: core::diff::DiffSpanStyle,
pub spans: Vec<core::diff::DiffSpan>,
pub no_eof_newline: bool,
}

#[uniffi::remote(Record)]
pub struct FileDiff {
pub path: String,
pub language: String,
pub lines: Vec<core::diff::DiffLine>,
pub whitespace_only_hidden: bool,
}

#[uniffi::remote(Record)]
Expand Down
70 changes: 70 additions & 0 deletions crates/jj-diff/src/compute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ fn compute_file_diff_impl(
path: path.to_owned(),
language: language.to_owned(),
lines: vec![],
whitespace_only_hidden: false,
};
}

Expand Down Expand Up @@ -78,6 +79,7 @@ fn compute_file_diff_impl(
new_line_no: Some(new_idx),
style: DiffSpanStyle::Context,
spans,
no_eof_newline: false,
});
}
old_idx += 1;
Expand Down Expand Up @@ -119,12 +121,14 @@ fn compute_file_diff_impl(
new_line_no: None,
style: DiffSpanStyle::Removed,
spans: rem_spans,
no_eof_newline: false,
});
result_lines.push(DiffLine {
old_line_no: None,
new_line_no: Some(new_ln),
style: DiffSpanStyle::Added,
spans: add_spans,
no_eof_newline: false,
});
}
}
Expand All @@ -142,6 +146,7 @@ fn compute_file_diff_impl(
new_line_no: None,
style: DiffSpanStyle::Removed,
spans,
no_eof_newline: false,
});
}
}
Expand All @@ -159,6 +164,7 @@ fn compute_file_diff_impl(
new_line_no: Some(new_ln),
style: DiffSpanStyle::Added,
spans,
no_eof_newline: false,
});
}
}
Expand All @@ -176,6 +182,7 @@ fn compute_file_diff_impl(
new_line_no: Some(new_idx),
style: DiffSpanStyle::Added,
spans,
no_eof_newline: false,
});
}
new_idx += 1;
Expand All @@ -184,6 +191,22 @@ fn compute_file_diff_impl(
}
}

// Rust's .lines() strips the trailing newline; reconcile bytes vs lines so EOF markers surface.
let no_eof_old = !old.is_empty() && !old.ends_with('\n');
let no_eof_new = !new.is_empty() && !new.ends_with('\n');
let eof_differs = no_eof_old != no_eof_new;
let any_change = result_lines
.iter()
.any(|l| matches!(l.style, DiffSpanStyle::Added | DiffSpanStyle::Removed));

let mut whitespace_only_hidden = false;

if eof_differs {
apply_eof_markers(&mut result_lines, no_eof_old, no_eof_new);
} else if !any_change && old != new && ignore_whitespace {
whitespace_only_hidden = true;
}

let lines = if collapse {
collapse_context(result_lines)
} else {
Expand All @@ -194,5 +217,52 @@ fn compute_file_diff_impl(
path: path.to_owned(),
language: language.to_owned(),
lines,
whitespace_only_hidden,
}
}

/// Mark each side's last line with `no_eof_newline`; split a shared-Context last line into a pair so the marker can attribute per side.
fn apply_eof_markers(lines: &mut Vec<DiffLine>, no_eof_old: bool, no_eof_new: bool) {
let last_old_idx = lines.iter().rposition(|l| l.old_line_no.is_some());
let last_new_idx = lines.iter().rposition(|l| l.new_line_no.is_some());

if let (Some(oi), Some(ni)) = (last_old_idx, last_new_idx) {
if oi == ni && lines[oi].style == DiffSpanStyle::Context {
split_context_for_eof(lines, oi, no_eof_old, no_eof_new);
return;
}
}

if no_eof_old {
if let Some(idx) = last_old_idx {
lines[idx].no_eof_newline = true;
}
}
if no_eof_new {
if let Some(idx) = last_new_idx {
lines[idx].no_eof_newline = true;
}
}
}

/// Spans stay as Context — the text is identical, so no word-level highlight.
fn split_context_for_eof(
lines: &mut Vec<DiffLine>,
idx: usize,
no_eof_old: bool,
no_eof_new: bool,
) {
let original = lines.remove(idx);
let mut removed = original.clone();
removed.new_line_no = None;
removed.style = DiffSpanStyle::Removed;
removed.no_eof_newline = no_eof_old;

let mut added = original;
added.old_line_no = None;
added.style = DiffSpanStyle::Added;
added.no_eof_newline = no_eof_new;

lines.insert(idx, removed);
lines.insert(idx + 1, added);
}
4 changes: 4 additions & 0 deletions crates/jj-diff/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub(super) fn collapse_context(lines: Vec<DiffLine>) -> Vec<DiffLine> {
path: String::new(),
language: String::new(),
lines,
whitespace_only_hidden: false,
};
collapse_context_with_mapping(&full).diff.lines
}
Expand Down Expand Up @@ -68,6 +69,7 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff {
path: full_diff.path.clone(),
language: full_diff.language.clone(),
lines: result,
whitespace_only_hidden: full_diff.whitespace_only_hidden,
},
display_to_full: mapping,
};
Expand Down Expand Up @@ -110,6 +112,7 @@ pub fn collapse_context_with_mapping(full_diff: &FileDiff) -> CollapsedDiff {
path: full_diff.path.clone(),
language: full_diff.language.clone(),
lines: result,
whitespace_only_hidden: full_diff.whitespace_only_hidden,
},
display_to_full: mapping,
}
Expand All @@ -125,5 +128,6 @@ pub(super) fn separator_line(hidden_count: usize) -> DiffLine {
style: DiffSpanStyle::Separator,
token: SyntaxToken::Plain,
}],
no_eof_newline: false,
}
}
Loading
Loading