Skip to content

Commit 5959476

Browse files
committed
refactor: implement git staging/unstaging and fix status updates
- Backend (Rust): - Refactored GitManager to support individual file stage/unstage events. - Added check in stage() to handle deleted files by removing them from index. - Improved check_status_changed_for_paths to handle file status errors gracefully. - Implemented background status watcher that emits 'git:update' events. - Frontend (React): - Redesigned ChangesPanel to list changes in Merged, Staged, and Changes groups. - Added stage/unstage buttons and actions. - Fixed keyboard navigation, selection, and ref scrolling bugs for files with both staged and unstaged changes using unique row IDs ('mode::path').
1 parent 6247a69 commit 5959476

10 files changed

Lines changed: 552 additions & 206 deletions

File tree

anycode-backend/src/app_state.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ impl AppState {
5353
lsp_manager.set_diagnostics_sender(diagnostic_tx);
5454

5555
let acp_manager = AcpManager::new(acp_permission_mode, acp_fs_tx);
56-
let git_manager = GitManager::new(crate::utils::current_dir());
56+
let mut git_manager = GitManager::new(crate::utils::current_dir());
57+
let _ = git_manager.refresh_status_cache();
5758

5859
Self {
5960
config: Arc::new(config),

anycode-backend/src/background_tasks.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use notify::{Event, RecursiveMode, Watcher, recommended_watcher};
77
use socketioxide::SocketIo;
88
use tokio::sync::mpsc::Receiver;
99
use tokio::sync::{Mutex, mpsc};
10+
use tokio::time::{self, Duration};
1011

1112
use crate::acp_fs;
1213
use crate::app_state::AppState;
@@ -25,10 +26,35 @@ pub fn spawn_all(
2526
) -> Result<notify::RecommendedWatcher> {
2627
spawn_acp_fs(state, io, acp_fs_rx);
2728
spawn_diagnostics(io, diagnostics_rx);
29+
spawn_git_status_watcher(state, io);
2830
let watcher = spawn_file_watcher(state, io)?;
2931
Ok(watcher)
3032
}
3133

34+
fn spawn_git_status_watcher(state: &AppState, io: &Arc<SocketIo>) {
35+
let git_manager = state.git_manager.clone();
36+
let socket = io.clone();
37+
38+
tokio::spawn(async move {
39+
let mut ticker = time::interval(Duration::from_secs(1));
40+
loop {
41+
ticker.tick().await;
42+
43+
let update = {
44+
let mut git = git_manager.lock().await;
45+
git.check_status_changed().map(|status| status.to_json())
46+
};
47+
48+
if let Some(status_update) = update {
49+
let send_result = socket.emit("git:update", &status_update).await;
50+
if let Err(e) = send_result {
51+
tracing::error!("error while sending git:update {}", e);
52+
}
53+
}
54+
}
55+
});
56+
}
57+
3258
fn spawn_acp_fs(
3359
state: &AppState,
3460
io: &Arc<SocketIo>,

anycode-backend/src/git.rs

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub enum FileStatus {
2020
pub struct GitFileStatus {
2121
pub path: String,
2222
pub status: FileStatus,
23+
pub staged: bool,
24+
pub unstaged: bool,
25+
pub conflicted: bool,
2326
pub added: usize,
2427
pub removed: usize,
2528
}
@@ -44,6 +47,9 @@ impl GitStatus {
4447
pub struct GitStatusPatchFile {
4548
pub path: String,
4649
pub status: String,
50+
pub staged: bool,
51+
pub unstaged: bool,
52+
pub conflicted: bool,
4753
pub added: usize,
4854
pub removed: usize,
4955
}
@@ -118,6 +124,19 @@ impl GitManager {
118124
Repository::discover(&self.workdir).context("Failed to discover git repository")
119125
}
120126

127+
fn sort_files(files: &mut [GitFileStatus]) {
128+
files.sort_by(|a, b| {
129+
a.path
130+
.cmp(&b.path)
131+
.then_with(|| Self::status_to_str(a.status).cmp(Self::status_to_str(b.status)))
132+
.then_with(|| a.staged.cmp(&b.staged))
133+
.then_with(|| a.unstaged.cmp(&b.unstaged))
134+
.then_with(|| a.conflicted.cmp(&b.conflicted))
135+
.then_with(|| a.added.cmp(&b.added))
136+
.then_with(|| a.removed.cmp(&b.removed))
137+
});
138+
}
139+
121140
/// Check if a path should be ignored (in .git or gitignored)
122141
pub fn should_ignore(&self, path: &Path) -> bool {
123142
let path_str = path.to_string_lossy();
@@ -225,11 +244,7 @@ impl GitManager {
225244
}
226245
}
227246

228-
info!(
229-
"Git status: {} files changed on branch {}",
230-
files.len(),
231-
branch
232-
);
247+
Self::sort_files(&mut files);
233248

234249
Ok(GitStatus { files, branch })
235250
}
@@ -261,6 +276,12 @@ impl GitManager {
261276
}
262277

263278
pub fn check_status_changed_for_paths(&mut self, paths: &[PathBuf]) -> Option<GitStatusUpdate> {
279+
if self.status_cache.files.is_empty() && self.status_cache.branch.is_empty() {
280+
let full = self.status().ok()?;
281+
self.status_cache = full.clone();
282+
return Some(GitStatusUpdate::Full(full));
283+
}
284+
264285
let repo = self.repo().ok()?;
265286
let repo_root = repo.workdir().unwrap_or(Path::new("."));
266287
let branch = Self::branch_name(&repo);
@@ -290,7 +311,13 @@ impl GitManager {
290311
}
291312

292313
let abs_path = repo_root.join(&relative_path).to_string_lossy().to_string();
293-
let next_file = self.status_for_relative_path(&repo, &relative_path).ok()?;
314+
let next_file = match self.status_for_relative_path(&repo, &relative_path) {
315+
Ok(file) => file,
316+
Err(e) => {
317+
tracing::warn!("Failed to get status for {}: {}", abs_path, e);
318+
continue;
319+
}
320+
};
294321
let prev_index = self
295322
.status_cache
296323
.files
@@ -309,17 +336,24 @@ impl GitManager {
309336
if let Some(file) = next_file.clone() {
310337
self.status_cache.files.push(file);
311338
}
339+
Self::sort_files(&mut self.status_cache.files);
312340

313341
match next_file {
314342
Some(file) => patch_files.push(GitStatusPatchFile {
315343
path: file.path,
316344
status: Self::status_to_str(file.status).to_string(),
345+
staged: file.staged,
346+
unstaged: file.unstaged,
347+
conflicted: file.conflicted,
317348
added: file.added,
318349
removed: file.removed,
319350
}),
320351
None => patch_files.push(GitStatusPatchFile {
321352
path: abs_path,
322353
status: "removed".to_string(),
354+
staged: false,
355+
unstaged: false,
356+
conflicted: false,
323357
added: 0,
324358
removed: 0,
325359
}),
@@ -382,38 +416,12 @@ impl GitManager {
382416
})
383417
}
384418

385-
/// Commit files
386-
pub fn commit(&self, files: &[String], message: &str) -> Result<()> {
419+
/// Commit currently staged index entries (like `git commit`)
420+
pub fn commit(&self, message: &str) -> Result<()> {
387421
let repo = self.repo()?;
388422
let mut index = repo.index()?;
389423
let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
390424

391-
// Build commit index from HEAD tree so the commit contains only explicitly
392-
// selected paths from the UI, not previously staged leftovers.
393-
if let Some(head) = &head_commit {
394-
let head_tree = head.tree()?;
395-
index.read_tree(&head_tree)?;
396-
} else {
397-
index.clear()?;
398-
}
399-
400-
let repo_root = repo.workdir().unwrap_or(Path::new("."));
401-
for file_path in files {
402-
let path = Path::new(file_path);
403-
let relative_path = if path.is_absolute() {
404-
path.strip_prefix(repo_root).unwrap_or(path)
405-
} else {
406-
path
407-
};
408-
409-
let full_path = repo_root.join(relative_path);
410-
if full_path.exists() {
411-
index.add_path(relative_path)?;
412-
} else {
413-
index.remove_path(relative_path)?;
414-
}
415-
}
416-
417425
index.write()?;
418426

419427
let tree_id = index.write_tree()?;
@@ -426,7 +434,50 @@ impl GitManager {
426434
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents_refs)
427435
.context("Failed to commit")?;
428436

429-
info!("Committed {} files: {}", files.len(), message);
437+
info!("Committed staged index: {}", message);
438+
Ok(())
439+
}
440+
441+
/// Stage file in index (like `git add <path>`)
442+
pub fn stage(&self, path: &str) -> Result<()> {
443+
let repo = self.repo()?;
444+
let repo_root = repo.workdir().unwrap_or(Path::new("."));
445+
let file_path = Path::new(path);
446+
let relative_path = if file_path.is_absolute() {
447+
file_path.strip_prefix(repo_root).unwrap_or(file_path)
448+
} else {
449+
file_path
450+
};
451+
452+
let mut index = repo.index()?;
453+
if repo_root.join(relative_path).exists() {
454+
index.add_path(relative_path)?;
455+
} else {
456+
let _ = index.remove_path(relative_path);
457+
}
458+
index.write()?;
459+
Ok(())
460+
}
461+
462+
/// Unstage file from index (like `git restore --staged <path>`)
463+
pub fn unstage(&self, path: &str) -> Result<()> {
464+
let repo = self.repo()?;
465+
let repo_root = repo.workdir().unwrap_or(Path::new("."));
466+
let file_path = Path::new(path);
467+
let relative_path = if file_path.is_absolute() {
468+
file_path.strip_prefix(repo_root).unwrap_or(file_path)
469+
} else {
470+
file_path
471+
};
472+
473+
let head = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
474+
if let Some(commit) = head {
475+
repo.reset_default(Some(commit.as_object()), [relative_path])?;
476+
} else {
477+
let mut index = repo.index()?;
478+
let _ = index.remove_path(relative_path);
479+
index.write()?;
480+
}
430481
Ok(())
431482
}
432483

@@ -667,16 +718,32 @@ impl GitManager {
667718
added: usize,
668719
removed: usize,
669720
) -> Option<GitFileStatus> {
670-
let file_status = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
721+
let conflicted = status.contains(Status::CONFLICTED);
722+
let staged = status.intersects(
723+
Status::INDEX_NEW
724+
| Status::INDEX_MODIFIED
725+
| Status::INDEX_DELETED
726+
| Status::INDEX_RENAMED
727+
| Status::INDEX_TYPECHANGE,
728+
);
729+
let unstaged = status.intersects(
730+
Status::WT_NEW
731+
| Status::WT_MODIFIED
732+
| Status::WT_DELETED
733+
| Status::WT_RENAMED
734+
| Status::WT_TYPECHANGE,
735+
);
736+
737+
let file_status = if conflicted {
738+
FileStatus::Conflict
739+
} else if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
671740
FileStatus::Added
672741
} else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) {
673742
FileStatus::Deleted
674743
} else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) {
675744
FileStatus::Modified
676745
} else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) {
677746
FileStatus::Renamed
678-
} else if status.contains(Status::CONFLICTED) {
679-
FileStatus::Conflict
680747
} else {
681748
return None;
682749
};
@@ -694,6 +761,9 @@ impl GitManager {
694761
Some(GitFileStatus {
695762
path: repo_root.join(relative_path).to_string_lossy().to_string(),
696763
status: file_status,
764+
staged,
765+
unstaged,
766+
conflicted,
697767
added,
698768
removed,
699769
})

anycode-backend/src/handlers/connection_handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ pub async fn handle_connect(socket: SocketRef, _state: State<AppState>) {
5858
socket.on("git:branches", handle_git_branches);
5959
socket.on("git:checkout", handle_git_checkout);
6060
socket.on("git:revert", handle_git_revert);
61+
socket.on("git:stage", handle_git_stage);
62+
socket.on("git:unstage", handle_git_unstage);
6163

6264
socket.on("theme:list", handle_theme_list);
6365
socket.on("theme:get", handle_theme_get);

anycode-backend/src/handlers/git_handler.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ pub struct GitFileOriginalRequest {
1111

1212
#[derive(Debug, Serialize, Deserialize, Clone)]
1313
pub struct GitCommitRequest {
14-
pub files: Vec<String>,
1514
pub message: String,
1615
}
1716

@@ -58,11 +57,11 @@ pub async fn handle_git_commit(
5857
ack: AckSender,
5958
state: State<AppState>,
6059
) {
61-
info!("Received git:commit: {} files", request.files.len());
60+
info!("Received git:commit");
6261

6362
let (result, changes_update) = {
6463
let mut git = state.git_manager.lock().await;
65-
match git.commit(&request.files, &request.message) {
64+
match git.commit(&request.message) {
6665
Ok(_) => {
6766
let status = git.refresh_status_cache().map(|s| s.to_json());
6867
(Ok(json!({})), status.ok())
@@ -74,8 +73,8 @@ pub async fn handle_git_commit(
7473
send_response(ack, result);
7574

7675
if let Some(update) = changes_update {
77-
let _ = socket.emit("changes:update", &update);
78-
let _ = socket.broadcast().emit("changes:update", &update).await;
76+
let _ = socket.emit("git:update", &update);
77+
let _ = socket.broadcast().emit("git:update", &update).await;
7978
}
8079
}
8180

@@ -133,3 +132,34 @@ pub async fn handle_git_revert(
133132
};
134133
send_response(ack, result);
135134
}
135+
136+
#[derive(Debug, Serialize, Deserialize, Clone)]
137+
pub struct GitStageRequest {
138+
pub path: String,
139+
}
140+
141+
pub async fn handle_git_stage(
142+
Data(request): Data<GitStageRequest>,
143+
ack: AckSender,
144+
state: State<AppState>,
145+
) {
146+
info!("Received git:stage: {:?}", request.path);
147+
let result = {
148+
let git = state.git_manager.lock().await;
149+
git.stage(&request.path).map(|_| json!({}))
150+
};
151+
send_response(ack, result);
152+
}
153+
154+
pub async fn handle_git_unstage(
155+
Data(request): Data<GitStageRequest>,
156+
ack: AckSender,
157+
state: State<AppState>,
158+
) {
159+
info!("Received git:unstage: {:?}", request.path);
160+
let result = {
161+
let git = state.git_manager.lock().await;
162+
git.unstage(&request.path).map(|_| json!({}))
163+
};
164+
send_response(ack, result);
165+
}

anycode-backend/src/handlers/watch_handler.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,7 @@ async fn process_watch_event(
200200
let is_parent_opened = is_parent_dir_opened(path, socket2data).await;
201201

202202
let watch_action = classify_watch_transition(last_state, current_state, is_opened_file);
203-
info!(
204-
"watch action: is_opened_file:{is_opened_file} is_cached_in_file2code:{is_cached_in_file2code} is_parent_opened:{is_parent_opened} {:?} for path: {:?}",
205-
watch_action, path
206-
);
203+
info!("watch action: {:?} for path: {:?}", watch_action, path);
207204

208205
match watch_action {
209206
WatchAction::Create => {
@@ -304,7 +301,7 @@ async fn handle_changes_update(
304301
};
305302

306303
if let Some(update) = update {
307-
let _ = socket.emit("changes:update", &update.to_json()).await;
304+
let _ = socket.emit("git:update", &update.to_json()).await;
308305
}
309306
}
310307

0 commit comments

Comments
 (0)