Skip to content

Commit 8bd0753

Browse files
committed
feat: improve git changes panel and search flow
1 parent c939434 commit 8bd0753

13 files changed

Lines changed: 976 additions & 312 deletions

File tree

anycode-backend/src/app_state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub struct SocketData {
2828
pub opened_files: HashSet<String>,
2929
pub opened_dirs: HashSet<String>,
3030
pub search_cancel: Option<CancellationToken>,
31+
pub search_pattern: Option<String>,
3132
}
3233

3334
#[derive(Clone)]

anycode-backend/src/git.rs

Lines changed: 278 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
22
use git2::{Repository, Status, StatusOptions};
33
use serde::{Deserialize, Serialize};
44
use serde_json::{Value, json};
5+
use std::collections::HashMap;
56
use std::path::{Path, PathBuf};
67
use tracing::info;
78

@@ -15,10 +16,13 @@ pub enum FileStatus {
1516
Conflict,
1617
}
1718

19+
1820
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
1921
pub struct GitFileStatus {
2022
pub path: String,
2123
pub status: FileStatus,
24+
pub added: usize,
25+
pub removed: usize,
2226
}
2327

2428
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -30,12 +34,43 @@ pub struct GitStatus {
3034
impl GitStatus {
3135
pub fn to_json(&self) -> Value {
3236
json!({
37+
"kind": "full",
3338
"files": self.files,
3439
"branch": self.branch
3540
})
3641
}
3742
}
3843

44+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
45+
pub struct GitStatusPatchFile {
46+
pub path: String,
47+
pub status: String,
48+
pub added: usize,
49+
pub removed: usize,
50+
}
51+
52+
#[derive(Debug, Clone, PartialEq, Eq)]
53+
pub enum GitStatusUpdate {
54+
Full(GitStatus),
55+
Patch {
56+
branch: String,
57+
files: Vec<GitStatusPatchFile>,
58+
},
59+
}
60+
61+
impl GitStatusUpdate {
62+
pub fn to_json(&self) -> Value {
63+
match self {
64+
Self::Full(status) => status.to_json(),
65+
Self::Patch { branch, files } => json!({
66+
"kind": "patch",
67+
"branch": branch,
68+
"files": files,
69+
}),
70+
}
71+
}
72+
}
73+
3974
#[derive(Debug, Clone)]
4075
pub struct FileOriginal {
4176
pub content: String,
@@ -79,7 +114,10 @@ impl GitManager {
79114
}
80115

81116
/// Check if a path should be ignored (in .git or gitignored)
82-
pub fn should_ignore(&self, path_str: &str) -> bool {
117+
pub fn should_ignore(&self, path: &Path) -> bool {
118+
let path_str = path.to_string_lossy();
119+
let path_str = path_str.as_ref();
120+
83121
// Skip .git directory
84122
if path_str.contains("/.git/") || path_str.ends_with("/.git") {
85123
return true;
@@ -107,15 +145,55 @@ impl GitManager {
107145
false
108146
}
109147

148+
fn collect_numstat(repo: &Repository) -> Result<HashMap<String, (usize, usize)>> {
149+
let mut opts = git2::DiffOptions::new();
150+
opts.include_untracked(true)
151+
.recurse_untracked_dirs(true)
152+
.include_typechange(true);
153+
154+
let head_tree = repo
155+
.head()
156+
.ok()
157+
.and_then(|head| head.peel_to_tree().ok());
158+
159+
let diff = repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts))?;
160+
161+
let mut numstat_by_path: HashMap<String, (usize, usize)> = HashMap::new();
162+
163+
diff.foreach(
164+
&mut |_delta, _progress| true,
165+
None,
166+
None,
167+
Some(&mut |delta, _hunk, line| {
168+
let Some(path) = delta
169+
.new_file()
170+
.path()
171+
.or_else(|| delta.old_file().path())
172+
.map(|p| p.to_string_lossy().to_string())
173+
else {
174+
return true;
175+
};
176+
177+
let entry = numstat_by_path.entry(path).or_insert((0, 0));
178+
match line.origin() {
179+
'+' => entry.0 += 1,
180+
'-' => entry.1 += 1,
181+
_ => {}
182+
}
183+
true
184+
}),
185+
)?;
186+
187+
Ok(numstat_by_path)
188+
}
189+
110190
/// Get current git status
111191
pub fn status(&self) -> Result<GitStatus> {
112192
let repo = self.repo()?;
113193
let repo_root = repo.workdir().unwrap_or(Path::new("."));
194+
let numstat_by_path = Self::collect_numstat(&repo)?;
114195

115-
let branch = repo
116-
.head()
117-
.map(|h| h.shorthand().unwrap_or("HEAD").to_string())
118-
.unwrap_or_else(|_| "HEAD".to_string());
196+
let branch = Self::branch_name(&repo);
119197

120198
let mut opts = StatusOptions::new();
121199
opts.include_untracked(true)
@@ -128,30 +206,21 @@ impl GitManager {
128206

129207
for entry in statuses.iter() {
130208
let relative_path = entry.path().unwrap_or("");
131-
let status = entry.status();
132-
133-
let file_status = if status.contains(Status::WT_NEW)
134-
|| status.contains(Status::INDEX_NEW)
135-
{
136-
FileStatus::Added
137-
} else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED)
138-
{
139-
FileStatus::Deleted
140-
} else if status.contains(Status::WT_MODIFIED)
141-
|| status.contains(Status::INDEX_MODIFIED)
142-
{
143-
FileStatus::Modified
144-
} else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED)
145-
{
146-
FileStatus::Renamed
147-
} else {
148-
continue;
149-
};
150-
151-
files.push(GitFileStatus {
152-
path: repo_root.join(relative_path).to_string_lossy().to_string(),
153-
status: file_status,
154-
});
209+
if let Some(file_status) = Self::status_from_entry(
210+
repo_root,
211+
relative_path,
212+
entry.status(),
213+
numstat_by_path
214+
.get(relative_path)
215+
.map(|(added, _)| *added)
216+
.unwrap_or(0),
217+
numstat_by_path
218+
.get(relative_path)
219+
.map(|(_, removed)| *removed)
220+
.unwrap_or(0),
221+
) {
222+
files.push(file_status);
223+
}
155224
}
156225

157226
info!(
@@ -183,6 +252,78 @@ impl GitManager {
183252
}
184253
}
185254

255+
pub fn check_status_changed_for_paths(&mut self, paths: &[PathBuf]) -> Option<GitStatusUpdate> {
256+
let repo = self.repo().ok()?;
257+
let repo_root = repo.workdir().unwrap_or(Path::new("."));
258+
let branch = Self::branch_name(&repo);
259+
260+
if self.status_cache.branch != branch {
261+
let full = self.status().ok()?;
262+
if self.status_cache != full {
263+
self.status_cache = full.clone();
264+
return Some(GitStatusUpdate::Full(full));
265+
}
266+
return None;
267+
}
268+
269+
let mut patch_files: Vec<GitStatusPatchFile> = Vec::new();
270+
271+
for path in paths {
272+
let Some(relative_path) = self.to_repo_relative_path(path, repo_root) else {
273+
let full = self.status().ok()?;
274+
if self.status_cache != full {
275+
self.status_cache = full.clone();
276+
return Some(GitStatusUpdate::Full(full));
277+
}
278+
return None;
279+
};
280+
if relative_path.is_empty() {
281+
continue;
282+
}
283+
284+
let abs_path = repo_root.join(&relative_path).to_string_lossy().to_string();
285+
let next_file = self.status_for_relative_path(&repo, &relative_path).ok()?;
286+
let prev_index = self.status_cache.files.iter().position(|f| f.path == abs_path);
287+
let prev_file = prev_index.and_then(|idx| self.status_cache.files.get(idx).cloned());
288+
289+
if prev_file == next_file {
290+
continue;
291+
}
292+
293+
if let Some(idx) = prev_index {
294+
self.status_cache.files.remove(idx);
295+
}
296+
if let Some(file) = next_file.clone() {
297+
self.status_cache.files.push(file);
298+
}
299+
300+
match next_file {
301+
Some(file) => patch_files.push(GitStatusPatchFile {
302+
path: file.path,
303+
status: Self::status_to_str(file.status).to_string(),
304+
added: file.added,
305+
removed: file.removed,
306+
}),
307+
None => patch_files.push(GitStatusPatchFile {
308+
path: abs_path,
309+
status: "removed".to_string(),
310+
added: 0,
311+
removed: 0,
312+
}),
313+
}
314+
}
315+
316+
if patch_files.is_empty() {
317+
return None;
318+
}
319+
320+
self.status_cache.branch = branch.clone();
321+
Some(GitStatusUpdate::Patch {
322+
branch,
323+
files: patch_files,
324+
})
325+
}
326+
186327
/// Get original file content from HEAD
187328
pub fn file_original(&self, path: &str) -> Result<FileOriginal> {
188329
let repo = self.repo()?;
@@ -439,4 +580,112 @@ impl GitManager {
439580

440581
Ok(())
441582
}
583+
fn branch_name(repo: &Repository) -> String {
584+
repo.head()
585+
.map(|h| h.shorthand().unwrap_or("HEAD").to_string())
586+
.unwrap_or_else(|_| "HEAD".to_string())
587+
}
588+
589+
fn status_from_entry(
590+
repo_root: &Path,
591+
relative_path: &str,
592+
status: Status,
593+
added: usize,
594+
removed: usize,
595+
) -> Option<GitFileStatus> {
596+
let file_status = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
597+
FileStatus::Added
598+
} else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) {
599+
FileStatus::Deleted
600+
} else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) {
601+
FileStatus::Modified
602+
} else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) {
603+
FileStatus::Renamed
604+
} else if status.contains(Status::CONFLICTED) {
605+
FileStatus::Conflict
606+
} else {
607+
return None;
608+
};
609+
610+
Some(GitFileStatus {
611+
path: repo_root.join(relative_path).to_string_lossy().to_string(),
612+
status: file_status,
613+
added,
614+
removed,
615+
})
616+
}
617+
618+
fn status_to_str(status: FileStatus) -> &'static str {
619+
match status {
620+
FileStatus::Modified => "modified",
621+
FileStatus::Added => "added",
622+
FileStatus::Deleted => "deleted",
623+
FileStatus::Renamed => "renamed",
624+
FileStatus::Conflict => "conflict",
625+
}
626+
}
627+
628+
fn to_repo_relative_path(&self, path: &Path, repo_root: &Path) -> Option<String> {
629+
let abs = if path.is_absolute() {
630+
path.to_path_buf()
631+
} else {
632+
self.workdir.join(path)
633+
};
634+
abs.strip_prefix(repo_root)
635+
.ok()
636+
.map(|p| p.to_string_lossy().replace('\\', "/"))
637+
}
638+
639+
fn numstat_for_path(repo: &Repository, relative_path: &str) -> Result<(usize, usize)> {
640+
let mut opts = git2::DiffOptions::new();
641+
opts.include_untracked(true)
642+
.recurse_untracked_dirs(true)
643+
.include_typechange(true)
644+
.pathspec(relative_path);
645+
646+
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
647+
let diff = repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts))?;
648+
649+
let mut added = 0usize;
650+
let mut removed = 0usize;
651+
diff.foreach(
652+
&mut |_delta, _progress| true,
653+
None,
654+
None,
655+
Some(&mut |_delta, _hunk, line| {
656+
match line.origin() {
657+
'+' => added += 1,
658+
'-' => removed += 1,
659+
_ => {}
660+
}
661+
true
662+
}),
663+
)?;
664+
Ok((added, removed))
665+
}
666+
667+
fn status_for_relative_path(&self, repo: &Repository, relative_path: &str) -> Result<Option<GitFileStatus>> {
668+
let repo_root = repo.workdir().unwrap_or(Path::new("."));
669+
let mut opts = StatusOptions::new();
670+
opts.include_untracked(true)
671+
.recurse_untracked_dirs(true)
672+
.include_ignored(false)
673+
.pathspec(relative_path);
674+
675+
let statuses = repo.statuses(Some(&mut opts))?;
676+
let (added, removed) = Self::numstat_for_path(repo, relative_path).unwrap_or((0, 0));
677+
678+
for entry in statuses.iter() {
679+
let Some(entry_path) = entry.path() else {
680+
continue;
681+
};
682+
if entry_path != relative_path {
683+
continue;
684+
}
685+
if let Some(file) = Self::status_from_entry(repo_root, entry_path, entry.status(), added, removed) {
686+
return Ok(Some(file));
687+
}
688+
}
689+
Ok(None)
690+
}
442691
}

anycode-backend/src/handlers/search_handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub async fn handle_search(
3636
let cancel = CancellationToken::new();
3737
// Save the cancel in the socket data
3838
data.search_cancel = Some(cancel.clone());
39+
data.search_pattern = Some(search_request.pattern.clone());
3940

4041
// Prepare search, get the current directory and create channel to collect results
4142
let current_dir = std::env::current_dir().unwrap();
@@ -93,5 +94,6 @@ pub async fn handle_search_cancel(socket: SocketRef, state: State<AppState>) {
9394
}
9495
// Clear the cancel token
9596
data.search_cancel = None;
97+
data.search_pattern = None;
9698
}
9799
}

0 commit comments

Comments
 (0)