Skip to content

Commit e029b46

Browse files
committed
feat(multibuffer): fast server-side raw diff with client-side unified diff parser & DiffCode
1 parent a4ebc7b commit e029b46

14 files changed

Lines changed: 1021 additions & 67 deletions

File tree

‎anycode-backend/src/git.rs‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,74 @@ impl GitManager {
11281128
})
11291129
}
11301130

1131+
fn diff_to_patch_string(diff: &mut git2::Diff) -> Result<String> {
1132+
let mut patch_bytes = Vec::new();
1133+
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
1134+
let origin = line.origin();
1135+
match origin {
1136+
'+' | '-' | ' ' => {
1137+
patch_bytes.push(origin as u8);
1138+
patch_bytes.extend_from_slice(line.content());
1139+
}
1140+
_ => {
1141+
patch_bytes.extend_from_slice(line.content());
1142+
}
1143+
}
1144+
true
1145+
})?;
1146+
Ok(String::from_utf8_lossy(&patch_bytes).to_string())
1147+
}
1148+
1149+
/// Return raw unified diff output for the working directory / staged changes using in-memory libgit2.
1150+
pub fn raw_diff(&self, staged: Option<bool>) -> Result<String> {
1151+
let repo = self.repo()?;
1152+
let mut diff_opts = DiffOptions::new();
1153+
let mut find_opts = DiffFindOptions::new();
1154+
find_opts.renames(true);
1155+
1156+
let mut diff = match staged {
1157+
Some(true) => {
1158+
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
1159+
let mut d = repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?;
1160+
d.find_similar(Some(&mut find_opts))?;
1161+
d
1162+
}
1163+
Some(false) => {
1164+
let mut d = repo.diff_index_to_workdir(None, Some(&mut diff_opts))?;
1165+
d.find_similar(Some(&mut find_opts))?;
1166+
d
1167+
}
1168+
None => {
1169+
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
1170+
let mut d = repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut diff_opts))?;
1171+
d.find_similar(Some(&mut find_opts))?;
1172+
d
1173+
}
1174+
};
1175+
1176+
Self::diff_to_patch_string(&mut diff)
1177+
}
1178+
1179+
/// Return raw unified diff output for a specific commit hash using in-memory libgit2.
1180+
pub fn raw_commit_diff(&self, hash: &str) -> Result<String> {
1181+
let repo = self.repo()?;
1182+
let commit = repo.find_commit(git2::Oid::from_str(hash.trim())?)?;
1183+
let new_tree = commit.tree()?;
1184+
let old_tree = if commit.parent_count() > 0 {
1185+
Some(commit.parent(0)?.tree()?)
1186+
} else {
1187+
None
1188+
};
1189+
let mut diff_opts = DiffOptions::new();
1190+
let mut find_opts = DiffFindOptions::new();
1191+
find_opts.renames(true);
1192+
1193+
let mut diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_opts))?;
1194+
diff.find_similar(Some(&mut find_opts))?;
1195+
1196+
Self::diff_to_patch_string(&mut diff)
1197+
}
1198+
11311199
/// Commit currently staged index entries (like `git commit`)
11321200
pub fn commit(&self, message: &str) -> Result<()> {
11331201
let repo = self.repo()?;

‎anycode-backend/src/handlers/connection_handler.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ pub async fn handle_connect(socket: SocketRef, _state: State<AppState>) {
6666
socket.on("git:revert", handle_git_revert);
6767
socket.on("git:stage", handle_git_stage);
6868
socket.on("git:unstage", handle_git_unstage);
69+
socket.on("git:diff-raw", handle_git_diff_raw);
70+
socket.on("git:commit-diff-raw", handle_git_commit_diff_raw);
6971

7072
socket.on("theme:list", handle_theme_list);
7173
socket.on("theme:get", handle_theme_get);

‎anycode-backend/src/handlers/git_handler.rs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,43 @@ pub async fn handle_git_unstage(
416416
send_response(ack, result);
417417
}
418418

419+
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
420+
pub struct GitDiffRawRequest {
421+
#[serde(default)]
422+
pub staged: Option<bool>,
423+
}
424+
425+
pub async fn handle_git_diff_raw(
426+
Data(request): Data<GitDiffRawRequest>,
427+
ack: AckSender,
428+
state: State<AppState>,
429+
) {
430+
info!("Received git:diff-raw: staged={:?}", request.staged);
431+
let result = {
432+
let git = state.git_manager.lock().await;
433+
git.raw_diff(request.staged).map(|diff| json!({ "diff": diff }))
434+
};
435+
send_response(ack, result);
436+
}
437+
438+
#[derive(Debug, Serialize, Deserialize, Clone)]
439+
pub struct GitCommitDiffRawRequest {
440+
pub hash: String,
441+
}
442+
443+
pub async fn handle_git_commit_diff_raw(
444+
Data(request): Data<GitCommitDiffRawRequest>,
445+
ack: AckSender,
446+
state: State<AppState>,
447+
) {
448+
info!("Received git:commit-diff-raw: hash={}", request.hash);
449+
let result = {
450+
let git = state.git_manager.lock().await;
451+
git.raw_commit_diff(&request.hash).map(|diff| json!({ "diff": diff }))
452+
};
453+
send_response(ack, result);
454+
}
455+
419456
pub async fn is_file_tracked(abs_path: &str, state: &AppState) -> bool {
420457
let git = state.git_manager.lock().await;
421458
match git.file_original(abs_path) {

‎anycode-backend/src/tests/git.rs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,53 @@ fn benchmark_status_real() {
604604
"Real custom version must be faster than CLI!"
605605
);
606606
}
607+
608+
#[test]
609+
fn test_raw_diff_and_commit_diff() {
610+
let temp_dir = tempfile::tempdir().unwrap();
611+
let repo_dir = temp_dir.path().to_path_buf();
612+
let repo = git2::Repository::init(&repo_dir).unwrap();
613+
let manager = GitManager::new(repo_dir.clone());
614+
615+
// Create file1 and commit
616+
let file1_path = repo_dir.join("file1.txt");
617+
std::fs::write(&file1_path, "hello\nworld\n").unwrap();
618+
let mut index = repo.index().unwrap();
619+
index.add_path(std::path::Path::new("file1.txt")).unwrap();
620+
index.write().unwrap();
621+
let tree_id = index.write_tree().unwrap();
622+
let tree = repo.find_tree(tree_id).unwrap();
623+
let sig = git2::Signature::now("Tester", "test@test.com").unwrap();
624+
let commit1_oid = repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[]).unwrap();
625+
let commit1_hash = commit1_oid.to_string();
626+
627+
// Modify file1
628+
std::fs::write(&file1_path, "hello\nbrave\nnew\nworld\n").unwrap();
629+
630+
// Test unstaged raw diff
631+
let raw_unstaged = manager.raw_diff(Some(false)).unwrap();
632+
assert!(raw_unstaged.contains("diff --git a/file1.txt b/file1.txt"));
633+
assert!(raw_unstaged.contains("+brave"));
634+
assert!(raw_unstaged.contains("+new"));
635+
636+
// Stage and test staged raw diff
637+
manager.stage("file1.txt").unwrap();
638+
let raw_staged = manager.raw_diff(Some(true)).unwrap();
639+
assert!(raw_staged.contains("diff --git a/file1.txt b/file1.txt"));
640+
assert!(raw_staged.contains("+brave"));
641+
642+
// Commit change
643+
manager.commit("Second commit").unwrap();
644+
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
645+
let commit2_hash = head_commit.id().to_string();
646+
647+
// Test raw commit diff for second commit
648+
let raw_commit = manager.raw_commit_diff(&commit2_hash).unwrap();
649+
assert!(raw_commit.contains("diff --git a/file1.txt b/file1.txt"));
650+
assert!(raw_commit.contains("+brave"));
651+
652+
// Test raw commit diff for initial root commit
653+
let raw_root_commit = manager.raw_commit_diff(&commit1_hash).unwrap();
654+
assert!(raw_root_commit.contains("diff --git a/file1.txt b/file1.txt") || raw_root_commit.contains("file1.txt"));
655+
assert!(raw_root_commit.contains("+hello"));
656+
}

‎anycode-base/src/diffCode.ts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { HighlighedNode, Position } from './code';
2+
import { DiffModel } from './diff';
3+
import { ParsedDiffFile } from './diffParser';
4+
5+
export class DiffCode {
6+
public readonly path: string;
7+
public readonly filename: string;
8+
public readonly language: string;
9+
public readonly lines: string[];
10+
public readonly displayLineNumbers: (number | undefined)[];
11+
public readonly diffs: DiffModel;
12+
public readonly added: number;
13+
public readonly removed: number;
14+
public readonly status: 'modified' | 'added' | 'deleted' | 'renamed';
15+
public readonly oldPath?: string;
16+
17+
private lineOffsets: number[] = [];
18+
private totalLength: number = 0;
19+
private contentCache: string | null = null;
20+
21+
constructor(
22+
path: string,
23+
lines: string[],
24+
displayLineNumbers: (number | undefined)[] = [],
25+
diffs: DiffModel = DiffModel.empty(),
26+
added: number = 0,
27+
removed: number = 0,
28+
status: 'modified' | 'added' | 'deleted' | 'renamed' = 'modified',
29+
oldPath?: string
30+
) {
31+
this.path = path;
32+
this.filename = path.split('/').pop() || path;
33+
this.language = this.detectLanguage(path);
34+
this.lines = lines;
35+
this.displayLineNumbers = displayLineNumbers;
36+
this.diffs = diffs;
37+
this.added = added;
38+
this.removed = removed;
39+
this.status = status;
40+
this.oldPath = oldPath;
41+
42+
this.computeOffsets();
43+
}
44+
45+
public static fromParsedFile(file: ParsedDiffFile, isOriginal: boolean = false): DiffCode {
46+
const lines = isOriginal ? file.oldLines : file.newLines;
47+
const lineNumbers = isOriginal ? file.oldLineNumbers : file.newLineNumbers;
48+
return new DiffCode(
49+
file.path,
50+
lines,
51+
lineNumbers,
52+
file.diffs,
53+
file.added,
54+
file.removed,
55+
file.status,
56+
file.oldPath
57+
);
58+
}
59+
60+
private detectLanguage(filePath: string): string {
61+
const ext = filePath.split('.').pop()?.toLowerCase();
62+
switch (ext) {
63+
case 'ts':
64+
case 'tsx':
65+
return 'typescript';
66+
case 'js':
67+
case 'jsx':
68+
return 'javascript';
69+
case 'rs':
70+
return 'rust';
71+
case 'py':
72+
return 'python';
73+
case 'json':
74+
return 'json';
75+
case 'css':
76+
return 'css';
77+
case 'html':
78+
return 'html';
79+
case 'md':
80+
return 'markdown';
81+
default:
82+
return 'plaintext';
83+
}
84+
}
85+
86+
private computeOffsets(): void {
87+
this.lineOffsets = [];
88+
let currentOffset = 0;
89+
for (let i = 0; i < this.lines.length; i++) {
90+
this.lineOffsets.push(currentOffset);
91+
currentOffset += this.lines[i].length + 1; // +1 for newline
92+
}
93+
this.totalLength = Math.max(0, currentOffset > 0 ? currentOffset - 1 : 0);
94+
}
95+
96+
public getContent(): string {
97+
if (this.contentCache === null) {
98+
this.contentCache = this.lines.join('\n');
99+
}
100+
return this.contentCache;
101+
}
102+
103+
public getContentLength(): number {
104+
return this.totalLength;
105+
}
106+
107+
public getLines(): string[] {
108+
return this.lines;
109+
}
110+
111+
public line(index: number): string {
112+
return this.lines[index] ?? '';
113+
}
114+
115+
public linesLength(): number {
116+
return this.lines.length;
117+
}
118+
119+
public getPosition(offset: number): Position {
120+
if (this.lines.length === 0) return { line: 0, column: 0 };
121+
if (offset <= 0) return { line: 0, column: 0 };
122+
123+
let low = 0;
124+
let high = this.lineOffsets.length - 1;
125+
let line = 0;
126+
127+
while (low <= high) {
128+
const mid = (low + high) >> 1;
129+
if (this.lineOffsets[mid] <= offset) {
130+
line = mid;
131+
low = mid + 1;
132+
} else {
133+
high = mid - 1;
134+
}
135+
}
136+
137+
const lineStart = this.lineOffsets[line] ?? 0;
138+
const column = Math.max(0, offset - lineStart);
139+
return { line, column };
140+
}
141+
142+
public getOffset(line: number, column: number): number {
143+
if (line < 0) return 0;
144+
if (line >= this.lines.length) return this.totalLength;
145+
const lineStart = this.lineOffsets[line] ?? 0;
146+
const lineLen = this.lines[line]?.length ?? 0;
147+
return lineStart + Math.min(Math.max(0, column), lineLen);
148+
}
149+
150+
public getIntervalContent2(start: number, end: number): string {
151+
const content = this.getContent();
152+
return content.substring(Math.max(0, start), Math.min(content.length, end));
153+
}
154+
155+
public getLineNodes(_index: number): HighlighedNode[] {
156+
return [];
157+
}
158+
159+
public getLineBinaryTokens(_index: number): Uint32Array | undefined {
160+
return undefined;
161+
}
162+
163+
public getDiffs(): DiffModel {
164+
return this.diffs;
165+
}
166+
}

0 commit comments

Comments
 (0)