Skip to content

Commit 6c6cdc8

Browse files
committed
feat(git): add Changes panel with full commit/push workflow
Add a new Source Control-like panel for managing Git changes: - Add ChangesPanel component with file list, commit input, and push button - Add ChangesPanel.css with VS Code-inspired styling - Add Git branch icon to Icons collection - Add leftPanelMode state for switching between files/search/changes views - Implement fetchGitStatus() to get changed files and current branch - Implement handleOpenChangedFile() with diff mode support - Implement handleGitCommit() for staging and committing selected files - Implement handleGitPush() for pushing to remote - Refactor left panel rendering to use switch statement - Update toolbar with mode toggle buttons
1 parent 31322f8 commit 6c6cdc8

13 files changed

Lines changed: 1076 additions & 25 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
target
22
node_modules
33
.DS_Store
4+
.anycode
45
*copy*.ts
56
anycode/dist
67
anycode-base/dist

anycode-backend/src/app_state.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ macro_rules! error_ack {
4848
}};
4949
}
5050

51+
/// Helper: send response based on Result
52+
/// Used with _impl pattern: handler calls send_response(ack, some_impl())
53+
/// where some_impl() returns anyhow::Result<Value> and can use ? operator with .context()
54+
pub fn send_response(ack: socketioxide::extract::AckSender, result: anyhow::Result<serde_json::Value>) {
55+
use serde_json::json;
56+
match result {
57+
Ok(data) => {
58+
let mut response = data;
59+
if let Some(obj) = response.as_object_mut() {
60+
obj.insert("success".to_string(), json!(true));
61+
}
62+
ack.send(&response).ok();
63+
}
64+
Err(e) => {
65+
ack.send(&json!({ "success": false, "error": format!("{:#}", e) })).ok();
66+
}
67+
}
68+
}
69+
5170
pub fn get_or_create_code<'a>(
5271
f2c: &'a mut HashMap<String, Code>,
5372
path: &str,
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use serde_json::{json, Value};
2+
use socketioxide::extract::{AckSender, Data, State};
3+
use tracing::info;
4+
use crate::app_state::{AppState, send_response};
5+
use serde::{Deserialize, Serialize};
6+
use git2::{Repository, Status, StatusOptions};
7+
use std::path::Path;
8+
use anyhow::{Context, Result};
9+
10+
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
11+
#[serde(rename_all = "lowercase")]
12+
pub enum FileStatus {
13+
Modified, Added, Deleted, Renamed,
14+
}
15+
16+
#[derive(Debug, Serialize, Deserialize, Clone)]
17+
pub struct GitFileStatus {
18+
pub path: String,
19+
pub status: FileStatus,
20+
}
21+
22+
#[derive(Debug, Serialize, Deserialize, Clone)]
23+
pub struct GitFileOriginalRequest {
24+
pub path: String,
25+
}
26+
27+
#[derive(Debug, Serialize, Deserialize, Clone)]
28+
pub struct GitCommitRequest {
29+
pub files: Vec<String>,
30+
pub message: String,
31+
}
32+
33+
fn git_status_impl() -> Result<Value> {
34+
let workdir = crate::utils::current_dir();
35+
36+
let repo = Repository::discover(&workdir)?;
37+
38+
let branch = repo.head()
39+
.map(|h| h.shorthand().unwrap_or("HEAD").to_string())
40+
.unwrap_or_else(|_| "HEAD".to_string());
41+
42+
let mut opts = StatusOptions::new();
43+
opts.include_untracked(true)
44+
.recurse_untracked_dirs(true)
45+
.include_ignored(false);
46+
47+
let statuses = repo.statuses(Some(&mut opts))?;
48+
49+
let mut files: Vec<GitFileStatus> = Vec::new();
50+
51+
for entry in statuses.iter() {
52+
let path = entry.path().unwrap_or("").to_string();
53+
let status = entry.status();
54+
55+
let file_status = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
56+
FileStatus::Added
57+
} else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) {
58+
FileStatus::Deleted
59+
} else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) {
60+
FileStatus::Modified
61+
} else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) {
62+
FileStatus::Renamed
63+
} else {
64+
continue;
65+
};
66+
67+
files.push(GitFileStatus {
68+
path, status: file_status
69+
});
70+
}
71+
72+
info!("Git status: {} files changed on branch {}", files.len(), branch);
73+
74+
Ok(json!({ "files": files, "branch": branch }))
75+
}
76+
77+
pub async fn handle_git_status(ack: AckSender, _state: State<AppState>) {
78+
info!("Received git:status");
79+
send_response(ack, git_status_impl());
80+
}
81+
82+
fn git_file_original_impl(request: &GitFileOriginalRequest) -> Result<Value> {
83+
let workdir = crate::utils::current_dir();
84+
85+
let repo = Repository::discover(&workdir)?;
86+
let head = repo.head()?;
87+
let commit = head.peel_to_commit()?;
88+
let tree = commit.tree()?;
89+
90+
// Convert absolute path to relative path from repo root
91+
let repo_path = repo.workdir().unwrap_or(Path::new("."));
92+
let file_path = Path::new(&request.path);
93+
94+
let relative_path = if file_path.is_absolute() {
95+
file_path.strip_prefix(repo_path)
96+
.map(|p| p.to_string_lossy().to_string())
97+
.unwrap_or_else(|_| request.path.clone())
98+
} else {
99+
request.path.clone()
100+
};
101+
102+
// Get file from tree - if not found, it's a new file
103+
let entry = match tree.get_path(Path::new(&relative_path)) {
104+
Ok(e) => e,
105+
Err(_) => {
106+
return Ok(json!({ "content": "", "is_new": true }));
107+
}
108+
};
109+
110+
let blob = repo.find_blob(entry.id())?;
111+
let content = std::str::from_utf8(blob.content())?.to_string();
112+
113+
info!("Got original content for {}: {} bytes", relative_path, content.len());
114+
Ok(json!({ "content": content, "is_new": false }))
115+
}
116+
117+
pub async fn handle_git_file_original(
118+
Data(request): Data<GitFileOriginalRequest>,
119+
ack: AckSender,
120+
_state: State<AppState>,
121+
) {
122+
info!("Received git:file-original: {:?}", request.path);
123+
send_response(ack, git_file_original_impl(&request));
124+
}
125+
126+
fn git_commit_impl(request: &GitCommitRequest) -> Result<Value> {
127+
let workdir = crate::utils::current_dir();
128+
129+
let repo = Repository::discover(&workdir)?;
130+
let mut index = repo.index()?;
131+
132+
// Add files to index
133+
for path in &request.files {
134+
let path = Path::new(path);
135+
let relative_path = if path.is_absolute() {
136+
path.strip_prefix(repo.workdir().unwrap_or(Path::new(".")))
137+
.unwrap_or(path)
138+
} else {
139+
path
140+
};
141+
142+
index.add_path(relative_path)?;
143+
}
144+
145+
index.write()?;
146+
147+
let tree_id = index.write_tree()?;
148+
149+
let tree = repo.find_tree(tree_id)?;
150+
151+
let sig = repo.signature().or_else(|_| {
152+
git2::Signature::now("Anycode User", "user@anycode.dev")
153+
})?;
154+
155+
// Get HEAD as parent
156+
let parents: Vec<git2::Commit> = repo.head()
157+
.ok()
158+
.and_then(|h| h.peel_to_commit().ok())
159+
.map(|c| vec![c])
160+
.unwrap_or_default();
161+
162+
let parents_refs: Vec<&git2::Commit> = parents.iter().collect();
163+
164+
repo.commit(Some("HEAD"), &sig, &sig, &request.message, &tree, &parents_refs)
165+
.context("Failed to commit")?;
166+
167+
Ok(json!({}))
168+
}
169+
170+
pub async fn handle_git_commit(
171+
Data(request): Data<GitCommitRequest>,
172+
ack: AckSender,
173+
_state: State<AppState>,
174+
) {
175+
info!("Received git:commit: {} files", request.files.len());
176+
send_response(ack, git_commit_impl(&request));
177+
}
178+
179+
fn git_push_impl() -> Result<Value> {
180+
let workdir = crate::utils::current_dir();
181+
182+
let repo = Repository::discover(&workdir)?;
183+
let mut remote = repo.find_remote("origin")?;
184+
let head = repo.head()?;
185+
186+
let branch_name = head.shorthand()
187+
.context("Detached HEAD state")?;
188+
189+
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
190+
191+
let mut callbacks = git2::RemoteCallbacks::new();
192+
callbacks.credentials(|_url, username_from_url, _allowed_types| {
193+
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
194+
});
195+
196+
let mut push_opts = git2::PushOptions::new();
197+
push_opts.remote_callbacks(callbacks);
198+
199+
remote.push(&[&refspec], Some(&mut push_opts))?;
200+
201+
Ok(json!({}))
202+
}
203+
204+
pub async fn handle_git_push(ack: AckSender, _state: State<AppState>) {
205+
info!("Received git:push");
206+
send_response(ack, git_push_impl());
207+
}

anycode-backend/src/handlers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod search_handler;
44
pub mod terminal_handler;
55
pub mod watch_handler;
66
pub mod acp_handler;
7+
pub mod git_handler;
78

89
// pub use io_handler::*;
910
// pub use lsp_handler::*;

anycode-backend/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ use handlers::{
3939
terminal_handler::*,
4040
watch_handler::{handle_watch_event},
4141
acp_handler::*,
42+
git_handler::*,
4243
};
4344

4445
mod search;
@@ -81,6 +82,11 @@ async fn on_connect(socket: SocketRef, state: State<AppState>) {
8182
socket.on("acp:permission_response", handle_acp_permission_response);
8283
socket.on("acp:undo", handle_acp_undo);
8384

85+
socket.on("git:status", handle_git_status);
86+
socket.on("git:file-original", handle_git_file_original);
87+
socket.on("git:commit", handle_git_commit);
88+
socket.on("git:push", handle_git_push);
89+
8490
socket.on_disconnect(on_disconnect)
8591
}
8692

anycode-base/src/editor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,16 @@ export class AnycodeEditor {
11281128
}
11291129
}
11301130

1131+
public setOriginalCode(content: string): void {
1132+
this.originalCode = content;
1133+
if (this.diffEnabled) {
1134+
const currentText = this.code.getContent();
1135+
this.diffs = computeGitChanges(this.originalCode, currentText);
1136+
this.renderer.render(this.getEditorState(), this.search);
1137+
this.verifyDiffRendering();
1138+
}
1139+
}
1140+
11311141
private verifyDiffRendering(): void {
11321142
if (!this.diffEnabled || this.diffs === undefined) {
11331143
return;

anycode/App.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,23 @@
258258
text-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
259259
}
260260

261+
.toggle-mode-btn {
262+
background: transparent;
263+
border: none;
264+
color: #888;
265+
cursor: pointer;
266+
border-radius: 12px;
267+
transition: all 0.2s ease;
268+
display: flex;
269+
align-items: center;
270+
justify-content: center;
271+
padding: 4px;
272+
}
273+
274+
.toggle-mode-btn:hover {
275+
color: #fff;
276+
}
277+
261278
.editor-toggle-btn {
262279
background: none;
263280
border: none;
@@ -425,6 +442,16 @@
425442
background: linear-gradient(to bottom, transparent, #555, transparent);
426443
}
427444

445+
.changes-panel::after {
446+
content: '';
447+
position: absolute;
448+
right: 0;
449+
top: 0;
450+
bottom: 0;
451+
width: 1.5px;
452+
background: linear-gradient(to bottom, transparent, #555, transparent);
453+
}
454+
428455
.file-system-content {
429456
padding: 1px 4px 4px 4px;
430457
flex: 1;
@@ -455,6 +482,10 @@
455482
.app-container:not(.terminal-visible) .search-results {
456483
padding-bottom: var(--toolbar-total-space);
457484
}
485+
/* Add bottom padding only when terminal is closed - same approach as file-system-content */
486+
.app-container:not(.terminal-visible) .changes-list {
487+
padding-bottom: var(--toolbar-total-space);
488+
}
458489

459490
.acp-dialog::after {
460491
content: '';

0 commit comments

Comments
 (0)