Display edit diffs (#469)

This commit is contained in:
Solomon
2025-08-15 11:18:24 +01:00
committed by GitHub
parent 6d65ea18af
commit ca9504f84b
16 changed files with 842 additions and 143 deletions

View File

@@ -29,3 +29,4 @@ futures = "0.3.31"
tokio-stream = { version = "0.1.17", features = ["sync"] }
shellexpand = "3.1.1"
which = "8.0.0"
similar = "2"

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use ts_rs::TS;
// Structs compatable with props: https://github.com/MrWangJustToDo/git-diff-view
@@ -17,3 +18,178 @@ pub struct Diff {
pub new_file: Option<FileDiffDetails>,
pub hunks: Vec<String>,
}
// ==============================
// Unified diff utility functions
// ==============================
/// Converts a replace diff to a unified diff hunk without the hunk header.
/// The hunk returned will have valid hunk, and diff lines.
pub fn create_unified_diff_hunk(old: &str, new: &str) -> String {
// normalize ending line feed to optimize diff output
let mut old = old.to_string();
let mut new = new.to_string();
if !old.ends_with('\n') {
old.push('\n');
}
if !new.ends_with('\n') {
new.push('\n');
}
let diff = TextDiff::from_lines(&old, &new);
let mut out = String::new();
// We need a valud hunk header. assume lines are 0. but - + count will be correct.
let old_count = diff.old_slices().len();
let new_count = diff.new_slices().len();
out.push_str(&format!("@@ -0,{old_count} +0,{new_count} @@\n"));
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Equal => ' ',
ChangeTag::Delete => '-',
ChangeTag::Insert => '+',
};
let val = change.value();
out.push(sign);
out.push_str(val);
}
out
}
/// Creates a full unified diff with the file path in the header.
pub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String {
let mut out = String::new();
out.push_str(format!("--- a/{file_path}\n+++ b/{file_path}\n").as_str());
out.push_str(&create_unified_diff_hunk(old, new));
out
}
/// Extracts unified diff hunks from a string containing a full unified diff.
/// Tolerates non-diff lines and missing `@@`` hunk headers.
pub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec<String> {
let lines = unified_diff.split_inclusive('\n').collect::<Vec<_>>();
if !lines.iter().any(|l| l.starts_with("@@")) {
// No @@ hunk headers: treat as a single hunk
let hunk = lines
.iter()
.copied()
.filter(|line| line.starts_with([' ', '+', '-']))
.collect::<String>();
return if hunk.is_empty() {
vec![]
} else {
vec!["@@\n".to_string() + &hunk]
};
}
let mut hunks = vec![];
let mut current_hunk: Option<String> = None;
// Collect hunks starting with @@ headers
for line in lines {
if line.starts_with("@@") {
// new hunk starts
if let Some(hunk) = current_hunk.take() {
// flush current hunk
if !hunk.is_empty() {
hunks.push(hunk);
}
}
current_hunk = Some(line.to_string());
} else if let Some(ref mut hunk) = current_hunk {
if line.starts_with([' ', '+', '-']) {
// hunk content
hunk.push_str(line);
} else {
// unkown line, flush current hunk
if !hunk.is_empty() {
hunks.push(hunk.clone());
}
current_hunk = None;
}
}
}
// we have reached the end. flush the last hunk if it exists
if let Some(hunk) = current_hunk
&& !hunk.is_empty()
{
hunks.push(hunk);
}
// Fix hunk headers if they are empty @@\n
hunks = fix_hunk_headers(hunks);
hunks
}
// Helper function to ensure valid hunk headers
fn fix_hunk_headers(hunks: Vec<String>) -> Vec<String> {
if hunks.is_empty() {
return hunks;
}
let mut new_hunks = Vec::new();
// if hunk header is empty @@\n, ten we need to replace it with a valid header
for hunk in hunks {
let mut lines = hunk
.split_inclusive('\n')
.map(str::to_string)
.collect::<Vec<_>>();
if lines.len() < 2 {
// empty hunk, skip
continue;
}
let header = &lines[0];
if !header.starts_with("@@") {
// no header, skip
continue;
}
if header.trim() == "@@" {
// empty header, replace with a valid one
lines.remove(0);
let old_count = lines
.iter()
.filter(|line| line.starts_with(['-', ' ']))
.count();
let new_count = lines
.iter()
.filter(|line| line.starts_with(['+', ' ']))
.count();
let new_header = format!("@@ -0,{old_count} +0,{new_count} @@");
lines.insert(0, new_header);
new_hunks.push(lines.join(""));
} else {
// valid header, keep as is
new_hunks.push(hunk);
}
}
new_hunks
}
/// Creates a full unified diff with the file path in the header,
pub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String {
let mut unified_diff = String::new();
let header = format!("--- a/{file_path}\n+++ b/{file_path}\n");
unified_diff.push_str(&header);
if !hunks.is_empty() {
unified_diff.push_str(hunks.join("\n").as_str());
if !unified_diff.ends_with('\n') {
unified_diff.push('\n');
}
}
unified_diff
}

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Convert absolute paths to relative paths based on worktree path
/// This is a robust implementation that handles symlinks and edge cases
@@ -13,70 +13,94 @@ pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
return path.to_string();
}
// Try to make path relative to the worktree path
match path_obj.strip_prefix(worktree_path_obj) {
Ok(relative_path) => {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!("Successfully made relative: '{}' -> '{}'", path, result);
if result.is_empty() {
".".to_string()
} else {
result
}
let path_obj = normalize_macos_private_alias(path_obj);
let worktree_path_obj = normalize_macos_private_alias(worktree_path_obj);
if let Ok(relative_path) = path_obj.strip_prefix(&worktree_path_obj) {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!("Successfully made relative: '{}' -> '{}'", path, result);
if result.is_empty() {
return ".".to_string();
}
Err(_) => {
// Handle symlinks by resolving canonical paths
let canonical_path = std::fs::canonicalize(path);
let canonical_worktree = std::fs::canonicalize(worktree_path);
return result;
}
match (canonical_path, canonical_worktree) {
(Ok(canon_path), Ok(canon_worktree)) => {
if !path_obj.exists() || !worktree_path_obj.exists() {
return path.to_string();
}
// canonicalize may fail if paths don't exist
let canonical_path = std::fs::canonicalize(&path_obj);
let canonical_worktree = std::fs::canonicalize(&worktree_path_obj);
match (canonical_path, canonical_worktree) {
(Ok(canon_path), Ok(canon_worktree)) => {
tracing::debug!(
"Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'",
path,
canon_path.display(),
worktree_path,
canon_worktree.display()
);
match canon_path.strip_prefix(&canon_worktree) {
Ok(relative_path) => {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!(
"Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'",
"Successfully made relative with canonical paths: '{}' -> '{}'",
path,
canon_path.display(),
worktree_path,
canon_worktree.display()
result
);
match canon_path.strip_prefix(&canon_worktree) {
Ok(relative_path) => {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!(
"Successfully made relative with canonical paths: '{}' -> '{}'",
path,
result
);
if result.is_empty() {
".".to_string()
} else {
result
}
}
Err(e) => {
tracing::warn!(
"Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original",
canon_path.display(),
canon_worktree.display(),
e
);
path.to_string()
}
if result.is_empty() {
return ".".to_string();
}
result
}
_ => {
tracing::debug!(
"Could not canonicalize paths (paths may not exist): '{}', '{}', returning original",
path,
worktree_path
Err(e) => {
tracing::warn!(
"Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original",
canon_path.display(),
canon_worktree.display(),
e
);
path.to_string()
}
}
}
_ => {
tracing::debug!(
"Could not canonicalize paths (paths may not exist): '{}', '{}', returning original",
path,
worktree_path
);
path.to_string()
}
}
}
/// Normalize macOS prefix /private/var/ and /private/tmp/ to their public aliases without resolving paths.
/// This allows prefix normalization to work when the full paths don't exist.
fn normalize_macos_private_alias<P: AsRef<Path>>(p: P) -> PathBuf {
let p = p.as_ref();
if cfg!(target_os = "macos")
&& let Some(s) = p.to_str()
{
if s == "/private/var" {
return PathBuf::from("/var");
}
if let Some(rest) = s.strip_prefix("/private/var/") {
return PathBuf::from(format!("/var/{rest}"));
}
if s == "/private/tmp" {
return PathBuf::from("/tmp");
}
if let Some(rest) = s.strip_prefix("/private/tmp/") {
return PathBuf::from(format!("/tmp/{rest}"));
}
}
p.to_path_buf()
}
pub fn get_vibe_kanban_temp_dir() -> std::path::PathBuf {
let dir_name = if cfg!(debug_assertions) {
"vibe-kanban-dev"
@@ -125,4 +149,27 @@ mod tests {
"/other/path/file.js"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_make_path_relative_macos_private_alias() {
// Simulate a worktree under /var with a path reported under /private/var
let worktree = "/var/folders/zz/abc123/T/vibe-kanban-dev/worktrees/vk-test";
let path_under_private = format!(
"/private/var{}/hello-world.txt",
worktree.strip_prefix("/var").unwrap()
);
assert_eq!(
make_path_relative(&path_under_private, worktree),
"hello-world.txt"
);
// Also handle the inverse: worktree under /private and path under /var
let worktree_private = format!("/private{}", worktree);
let path_under_var = format!("{}/hello-world.txt", worktree);
assert_eq!(
make_path_relative(&path_under_var, &worktree_private),
"hello-world.txt"
);
}
}