Display edit diffs (#469)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user