diff --git a/package-lock.json b/package-lock.json index cc4b4a98d..1e379a91e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -478,6 +478,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -521,6 +522,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2001,8 +2003,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2138,6 +2139,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2603,7 +2605,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -3397,8 +3398,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5255,6 +5255,7 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -5460,7 +5461,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6821,7 +6821,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6837,7 +6836,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6850,8 +6848,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index acfbf331a..1d3ccba6c 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -9,18 +9,28 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{oneshot, Mutex}; +use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::time::timeout; use crate::backend::events::{AppServerEvent, EventSink}; use crate::types::WorkspaceEntry; +fn extract_thread_id(value: &Value) -> Option { + value + .get("params") + .and_then(|p| p.get("threadId").or_else(|| p.get("thread_id"))) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) +} + pub(crate) struct WorkspaceSession { pub(crate) entry: WorkspaceEntry, pub(crate) child: Mutex, pub(crate) stdin: Mutex, pub(crate) pending: Mutex>>, pub(crate) next_id: AtomicU64, + /// Callbacks for background threads - events for these threadIds are sent through the channel + pub(crate) background_thread_callbacks: Mutex>>, } impl WorkspaceSession { @@ -208,6 +218,7 @@ pub(crate) async fn spawn_workspace_session( stdin: Mutex::new(stdin), pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + background_thread_callbacks: Mutex::new(HashMap::new()), }); let session_clone = Arc::clone(&session); @@ -237,26 +248,54 @@ pub(crate) async fn spawn_workspace_session( let maybe_id = value.get("id").and_then(|id| id.as_u64()); let has_method = value.get("method").is_some(); let has_result_or_error = value.get("result").is_some() || value.get("error").is_some(); + + // Check if this event is for a background thread + let thread_id = extract_thread_id(&value); + if let Some(id) = maybe_id { if has_result_or_error { if let Some(tx) = session_clone.pending.lock().await.remove(&id) { let _ = tx.send(value); } } else if has_method { + // Check for background thread callback + let mut sent_to_background = false; + if let Some(ref tid) = thread_id { + let callbacks = session_clone.background_thread_callbacks.lock().await; + if let Some(tx) = callbacks.get(tid) { + let _ = tx.send(value.clone()); + sent_to_background = true; + } + } + // Don't emit to frontend if this is a background thread event + if !sent_to_background { + let payload = AppServerEvent { + workspace_id: workspace_id.clone(), + message: value, + }; + event_sink_clone.emit_app_server_event(payload); + } + } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { + let _ = tx.send(value); + } + } else if has_method { + // Check for background thread callback + let mut sent_to_background = false; + if let Some(ref tid) = thread_id { + let callbacks = session_clone.background_thread_callbacks.lock().await; + if let Some(tx) = callbacks.get(tid) { + let _ = tx.send(value.clone()); + sent_to_background = true; + } + } + // Don't emit to frontend if this is a background thread event + if !sent_to_background { let payload = AppServerEvent { workspace_id: workspace_id.clone(), message: value, }; event_sink_clone.emit_app_server_event(payload); - } else if let Some(tx) = session_clone.pending.lock().await.remove(&id) { - let _ = tx.send(value); } - } else if has_method { - let payload = AppServerEvent { - workspace_id: workspace_id.clone(), - message: value, - }; - event_sink_clone.emit_app_server_event(payload); } } }); @@ -317,3 +356,27 @@ pub(crate) async fn spawn_workspace_session( Ok(session) } + +#[cfg(test)] +mod tests { + use super::extract_thread_id; + use serde_json::json; + + #[test] + fn extract_thread_id_reads_camel_case() { + let value = json!({ "params": { "threadId": "thread-123" } }); + assert_eq!(extract_thread_id(&value), Some("thread-123".to_string())); + } + + #[test] + fn extract_thread_id_reads_snake_case() { + let value = json!({ "params": { "thread_id": "thread-456" } }); + assert_eq!(extract_thread_id(&value), Some("thread-456".to_string())); + } + + #[test] + fn extract_thread_id_returns_none_when_missing() { + let value = json!({ "params": {} }); + assert_eq!(extract_thread_id(&value), None); + } +} diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 21211396b..347bd6063 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -6,6 +6,7 @@ use std::time::Duration; use tauri::{AppHandle, State}; use tokio::process::Command; +use tokio::sync::mpsc; use tokio::time::timeout; pub(crate) use crate::backend::app_server::WorkspaceSession; @@ -529,6 +530,30 @@ pub(crate) async fn respond_to_server_request( session.send_response(request_id, result).await } +/// Gets the diff content for commit message generation +#[tauri::command] +pub(crate) async fn get_commit_message_prompt( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + // Get the diff from git + let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; + + if diff.trim().is_empty() { + return Err("No changes to generate commit message for".to_string()); + } + + let prompt = format!( + "Generate a concise git commit message for the following changes. \ +Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ +Focus on the 'why' rather than the 'what'. Keep the summary line under 72 characters. \ +Only output the commit message, nothing else.\n\n\ +Changes:\n{diff}" + ); + + Ok(prompt) +} + #[tauri::command] pub(crate) async fn remember_approval_rule( workspace_id: String, @@ -569,3 +594,169 @@ pub(crate) async fn remember_approval_rule( "rulesPath": rules_path, })) } + +/// Generates a commit message in the background without showing in the main chat +#[tauri::command] +pub(crate) async fn generate_commit_message( + workspace_id: String, + state: State<'_, AppState>, +) -> Result { + // Get the diff from git + let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; + + if diff.trim().is_empty() { + return Err("No changes to generate commit message for".to_string()); + } + + let prompt = format!( + "Generate a concise git commit message for the following changes. \ +Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ +Focus on the 'why' rather than the 'what'. Keep the summary line under 72 characters. \ +Only output the commit message, nothing else.\n\n\ +Changes:\n{diff}" + ); + + // Get the session + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(&workspace_id) + .ok_or("workspace not connected")? + .clone() + }; + + // Create a background thread + let thread_params = json!({ + "cwd": session.entry.path, + "approvalPolicy": "never" // Never ask for approval in background + }); + let thread_result = session.send_request("thread/start", thread_params).await?; + + // Handle error response + if let Some(error) = thread_result.get("error") { + let error_msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error starting thread"); + return Err(error_msg.to_string()); + } + + // Extract threadId - try multiple paths since response format may vary + let thread_id = thread_result + .get("result") + .and_then(|r| r.get("threadId")) + .or_else(|| thread_result.get("result").and_then(|r| r.get("thread")).and_then(|t| t.get("id"))) + .or_else(|| thread_result.get("threadId")) + .or_else(|| thread_result.get("thread").and_then(|t| t.get("id"))) + .and_then(|t| t.as_str()) + .ok_or_else(|| format!("Failed to get threadId from thread/start response: {:?}", thread_result))? + .to_string(); + + // Create channel for receiving events + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Register callback for this thread + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.insert(thread_id.clone(), tx); + } + + // Start a turn with the commit message prompt + let turn_params = json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": prompt }], + "cwd": session.entry.path, + "approvalPolicy": "never", + "sandboxPolicy": { "type": "readOnly" }, + }); + let turn_result = session.send_request("turn/start", turn_params).await; + let turn_result = match turn_result { + Ok(result) => result, + Err(error) => { + // Clean up if turn fails to start + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + let archive_params = json!({ "threadId": thread_id.as_str() }); + let _ = session.send_request("thread/archive", archive_params).await; + return Err(error); + } + }; + + if let Some(error) = turn_result.get("error") { + let error_msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error starting turn"); + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + let archive_params = json!({ "threadId": thread_id.as_str() }); + let _ = session.send_request("thread/archive", archive_params).await; + return Err(error_msg.to_string()); + } + + // Collect assistant text from events + let mut commit_message = String::new(); + let timeout_duration = Duration::from_secs(60); + let collect_result = timeout(timeout_duration, async { + while let Some(event) = rx.recv().await { + let method = event.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + match method { + "item/agentMessage/delta" => { + // Extract text delta from agent messages + if let Some(params) = event.get("params") { + if let Some(delta) = params.get("delta").and_then(|d| d.as_str()) { + commit_message.push_str(delta); + } + } + } + "turn/completed" => { + // Turn completed, we can stop listening + break; + } + "turn/error" => { + // Error occurred + let error_msg = event + .get("params") + .and_then(|p| p.get("error")) + .and_then(|e| e.as_str()) + .unwrap_or("Unknown error during commit message generation"); + return Err(error_msg.to_string()); + } + _ => { + // Ignore other events (turn/started, item/started, item/completed, reasoning events, etc.) + } + } + } + Ok(()) + }) + .await; + + // Unregister callback + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + + // Archive the thread to clean up + let archive_params = json!({ "threadId": thread_id }); + let _ = session.send_request("thread/archive", archive_params).await; + + // Handle timeout or collection error + match collect_result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_) => return Err("Timeout waiting for commit message generation".to_string()), + } + + let trimmed = commit_message.trim().to_string(); + if trimmed.is_empty() { + return Err("No commit message was generated".to_string()); + } + + Ok(trimmed) +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 5bf9d8444..18a4d6ba8 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -42,6 +42,57 @@ async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> Err(detail.to_string()) } +fn parse_upstream_ref(name: &str) -> Option<(String, String)> { + let trimmed = name.strip_prefix("refs/remotes/").unwrap_or(name); + let mut parts = trimmed.splitn(2, '/'); + let remote = parts.next()?; + let branch = parts.next()?; + if remote.is_empty() || branch.is_empty() { + return None; + } + Some((remote.to_string(), branch.to_string())) +} + +fn upstream_remote_and_branch(repo_root: &Path) -> Result, String> { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let head = match repo.head() { + Ok(head) => head, + Err(_) => return Ok(None), + }; + if !head.is_branch() { + return Ok(None); + } + let branch_name = match head.shorthand() { + Some(name) => name, + None => return Ok(None), + }; + let branch = repo + .find_branch(branch_name, BranchType::Local) + .map_err(|e| e.to_string())?; + let upstream_branch = match branch.upstream() { + Ok(upstream) => upstream, + Err(_) => return Ok(None), + }; + let upstream_ref = upstream_branch.get(); + let upstream_name = upstream_ref + .name() + .or_else(|| upstream_ref.shorthand()); + Ok(upstream_name.and_then(parse_upstream_ref)) +} + +async fn push_with_upstream(repo_root: &Path) -> Result<(), String> { + let upstream = upstream_remote_and_branch(repo_root)?; + if let Some((remote, branch)) = upstream { + let refspec = format!("HEAD:{branch}"); + return run_git_command( + repo_root, + &["push", remote.as_str(), refspec.as_str()], + ) + .await; + } + run_git_command(repo_root, &["push"]).await +} + fn status_for_index(status: Status) -> Option<&'static str> { if status.contains(Status::INDEX_NEW) { Some("A") @@ -74,6 +125,77 @@ fn status_for_workdir(status: Status) -> Option<&'static str> { } } +fn build_combined_diff(diff: &git2::Diff) -> String { + let mut combined_diff = String::new(); + for (index, delta) in diff.deltas().enumerate() { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()); + let Some(path) = path else { + continue; + }; + let patch = match git2::Patch::from_diff(diff, index) { + Ok(patch) => patch, + Err(_) => continue, + }; + let Some(mut patch) = patch else { + continue; + }; + let content = match diff_patch_to_string(&mut patch) { + Ok(content) => content, + Err(_) => continue, + }; + if content.trim().is_empty() { + continue; + } + if !combined_diff.is_empty() { + combined_diff.push_str("\n\n"); + } + combined_diff.push_str(&format!("=== {} ===\n", path.display())); + combined_diff.push_str(&content); + } + combined_diff +} + +fn collect_workspace_diff(repo_root: &Path) -> Result { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let head_tree = repo + .head() + .ok() + .and_then(|head| head.peel_to_tree().ok()); + + let mut options = DiffOptions::new(); + let index = repo.index().map_err(|e| e.to_string())?; + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_index(Some(tree), Some(&index), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_index(None, Some(&index), Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + let combined_diff = build_combined_diff(&diff); + if !combined_diff.trim().is_empty() { + return Ok(combined_diff); + } + + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_workdir_with_index(None, Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + Ok(build_combined_diff(&diff)) +} + fn github_repo_from_path(path: &Path) -> Result { let repo = Repository::open(path).map_err(|e| e.to_string())?; let remotes = repo.remotes().map_err(|e| e.to_string())?; @@ -326,6 +448,21 @@ pub(crate) async fn stage_git_file( run_git_command(&repo_root, &["add", "--", &path]).await } +#[tauri::command] +pub(crate) async fn stage_git_all( + workspace_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo_root = resolve_git_root(&entry)?; + run_git_command(&repo_root, &["add", "-A"]).await +} + #[tauri::command] pub(crate) async fn unstage_git_file( workspace_id: String, @@ -378,6 +515,69 @@ pub(crate) async fn revert_git_all( run_git_command(&repo_root, &["clean", "-f", "-d"]).await } +#[tauri::command] +pub(crate) async fn commit_git( + workspace_id: String, + message: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo_root = resolve_git_root(&entry)?; + run_git_command(&repo_root, &["commit", "-m", &message]).await +} + +#[tauri::command] +pub(crate) async fn push_git( + workspace_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo_root = resolve_git_root(&entry)?; + push_with_upstream(&repo_root).await +} + +#[tauri::command] +pub(crate) async fn pull_git( + workspace_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo_root = resolve_git_root(&entry)?; + run_git_command(&repo_root, &["pull"]).await +} + +#[tauri::command] +pub(crate) async fn sync_git( + workspace_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + + let repo_root = resolve_git_root(&entry)?; + // Pull first, then push (like VSCode sync) + run_git_command(&repo_root, &["pull"]).await?; + push_with_upstream(&repo_root).await +} + #[tauri::command] pub(crate) async fn list_git_roots( workspace_id: String, @@ -395,6 +595,22 @@ pub(crate) async fn list_git_roots( Ok(scan_git_roots(&root, depth, 200)) } +/// Helper function to get the combined diff for a workspace (used by commit message generation) +pub(crate) async fn get_workspace_diff( + workspace_id: &str, + state: &State<'_, AppState>, +) -> Result { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .ok_or("workspace not found")? + .clone(); + drop(workspaces); + + let repo_root = resolve_git_root(&entry)?; + collect_workspace_diff(&repo_root) +} + #[tauri::command] pub(crate) async fn get_git_diffs( workspace_id: String, @@ -907,3 +1123,44 @@ pub(crate) async fn create_git_branch( .map_err(|e| e.to_string())?; checkout_branch(&repo, &name).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_temp_repo() -> (PathBuf, Repository) { + let root = std::env::temp_dir().join(format!( + "codex-monitor-test-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&root).expect("create temp repo root"); + let repo = Repository::init(&root).expect("init repo"); + (root, repo) + } + + #[test] + fn collect_workspace_diff_prefers_staged_changes() { + let (root, repo) = create_temp_repo(); + let file_path = root.join("staged.txt"); + fs::write(&file_path, "staged\n").expect("write staged file"); + let mut index = repo.index().expect("index"); + index.add_path(Path::new("staged.txt")).expect("add path"); + index.write().expect("write index"); + + let diff = collect_workspace_diff(&root).expect("collect diff"); + assert!(diff.contains("staged.txt")); + assert!(diff.contains("staged")); + } + + #[test] + fn collect_workspace_diff_falls_back_to_workdir() { + let (root, _repo) = create_temp_repo(); + let file_path = root.join("unstaged.txt"); + fs::write(&file_path, "unstaged\n").expect("write unstaged file"); + + let diff = collect_workspace_diff(&root).expect("collect diff"); + assert!(diff.contains("unstaged.txt")); + assert!(diff.contains("unstaged")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b6f3cf17..f310f7a05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -295,6 +295,8 @@ pub fn run() { codex::start_review, codex::respond_to_server_request, codex::remember_approval_rule, + codex::get_commit_message_prompt, + codex::generate_commit_message, codex::resume_thread, codex::list_threads, codex::archive_thread, @@ -306,9 +308,14 @@ pub fn run() { git::get_git_log, git::get_git_remote, git::stage_git_file, + git::stage_git_all, git::unstage_git_file, git::revert_git_file, git::revert_git_all, + git::commit_git, + git::push_git, + git::pull_git, + git::sync_git, git::get_github_issues, git::get_github_pull_requests, git::get_github_pull_request_diff, diff --git a/src/App.tsx b/src/App.tsx index 6208efbad..79944dcda 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -92,8 +92,14 @@ import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { playNotificationSound } from "./utils/notificationSounds"; +import { shouldApplyCommitMessage } from "./utils/commitMessage"; import { pickWorkspacePath, + generateCommitMessage, + commitGit, + stageGitAll, + pushGit, + syncGit, } from "./services/tauri"; import { subscribeMenuAddWorkspace, @@ -411,7 +417,8 @@ function MainApp() { behindEntries: gitLogBehindEntries, upstream: gitLogUpstream, isLoading: gitLogLoading, - error: gitLogError + error: gitLogError, + refresh: refreshGitLog, } = useGitLog(activeWorkspace, shouldLoadGitLog); const { issues: gitIssues, @@ -911,6 +918,188 @@ function MainApp() { [handleSend], ); + // Commit message generation state + const [commitMessage, setCommitMessage] = useState(""); + const [commitMessageLoading, setCommitMessageLoading] = useState(false); + const [commitMessageError, setCommitMessageError] = useState(null); + const hasStagedChanges = gitStatus.stagedFiles.length > 0; + const hasUnstagedChanges = gitStatus.unstagedFiles.length > 0; + const hasWorktreeChanges = hasStagedChanges || hasUnstagedChanges; + + const ensureStagedForCommit = useCallback(async () => { + if (!activeWorkspace || hasStagedChanges || !hasUnstagedChanges) { + return; + } + await stageGitAll(activeWorkspace.id); + }, [activeWorkspace, hasStagedChanges, hasUnstagedChanges]); + + const handleCommitMessageChange = useCallback((value: string) => { + setCommitMessage(value); + }, []); + + const handleGenerateCommitMessage = useCallback(async () => { + if (!activeWorkspace || commitMessageLoading) { + return; + } + const workspaceId = activeWorkspace.id; + setCommitMessageLoading(true); + setCommitMessageError(null); + try { + // Generate commit message in background + const message = await generateCommitMessage(workspaceId); + if (!shouldApplyCommitMessage(activeWorkspaceIdRef.current, workspaceId)) { + return; + } + setCommitMessage(message); + } catch (error) { + if (!shouldApplyCommitMessage(activeWorkspaceIdRef.current, workspaceId)) { + return; + } + setCommitMessageError( + error instanceof Error ? error.message : String(error) + ); + } finally { + if (shouldApplyCommitMessage(activeWorkspaceIdRef.current, workspaceId)) { + setCommitMessageLoading(false); + } + } + }, [activeWorkspace, commitMessageLoading]); + + // Clear commit message state when workspace changes + useEffect(() => { + setCommitMessage(""); + setCommitMessageError(null); + setCommitMessageLoading(false); + }, [activeWorkspaceId]); + + // Git commit/push/sync state + const [commitLoading, setCommitLoading] = useState(false); + const [pushLoading, setPushLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); + const [commitError, setCommitError] = useState(null); + const [pushError, setPushError] = useState(null); + const [syncError, setSyncError] = useState(null); + + const handleCommit = useCallback(async () => { + if (!activeWorkspace || commitLoading || !commitMessage.trim() || !hasWorktreeChanges) { + return; + } + setCommitLoading(true); + setCommitError(null); + try { + await ensureStagedForCommit(); + await commitGit(activeWorkspace.id, commitMessage.trim()); + setCommitMessage(""); + // Refresh git status after commit + refreshGitStatus(); + refreshGitLog?.(); + } catch (error) { + setCommitError(error instanceof Error ? error.message : String(error)); + } finally { + setCommitLoading(false); + } + }, [activeWorkspace, commitLoading, commitMessage, ensureStagedForCommit, hasWorktreeChanges, refreshGitStatus, refreshGitLog]); + + const handleCommitAndPush = useCallback(async () => { + if (!activeWorkspace || commitLoading || pushLoading || !commitMessage.trim() || !hasWorktreeChanges) { + return; + } + let commitSucceeded = false; + setCommitLoading(true); + setPushLoading(true); + setCommitError(null); + setPushError(null); + try { + await ensureStagedForCommit(); + await commitGit(activeWorkspace.id, commitMessage.trim()); + commitSucceeded = true; + setCommitMessage(""); + setCommitLoading(false); + await pushGit(activeWorkspace.id); + // Refresh git status after push + refreshGitStatus(); + refreshGitLog?.(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (!commitSucceeded) { + setCommitError(errorMsg); + } else { + setPushError(errorMsg); + } + } finally { + setCommitLoading(false); + setPushLoading(false); + } + }, [activeWorkspace, commitLoading, pushLoading, commitMessage, ensureStagedForCommit, hasWorktreeChanges, refreshGitStatus, refreshGitLog]); + + const handleCommitAndSync = useCallback(async () => { + if (!activeWorkspace || commitLoading || syncLoading || !commitMessage.trim() || !hasWorktreeChanges) { + return; + } + let commitSucceeded = false; + setCommitLoading(true); + setSyncLoading(true); + setCommitError(null); + setSyncError(null); + try { + await ensureStagedForCommit(); + await commitGit(activeWorkspace.id, commitMessage.trim()); + commitSucceeded = true; + setCommitMessage(""); + setCommitLoading(false); + await syncGit(activeWorkspace.id); + // Refresh git status after sync + refreshGitStatus(); + refreshGitLog?.(); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (!commitSucceeded) { + setCommitError(errorMsg); + } else { + setSyncError(errorMsg); + } + } finally { + setCommitLoading(false); + setSyncLoading(false); + } + }, [activeWorkspace, commitLoading, syncLoading, commitMessage, ensureStagedForCommit, hasWorktreeChanges, refreshGitStatus, refreshGitLog]); + + const handlePush = useCallback(async () => { + if (!activeWorkspace || pushLoading) { + return; + } + setPushLoading(true); + setPushError(null); + try { + await pushGit(activeWorkspace.id); + // Refresh git status after push + refreshGitStatus(); + refreshGitLog?.(); + } catch (error) { + setPushError(error instanceof Error ? error.message : String(error)); + } finally { + setPushLoading(false); + } + }, [activeWorkspace, pushLoading, refreshGitStatus, refreshGitLog]); + + const handleSync = useCallback(async () => { + if (!activeWorkspace || syncLoading) { + return; + } + setSyncLoading(true); + setSyncError(null); + try { + await syncGit(activeWorkspace.id); + // Refresh git status after sync + refreshGitStatus(); + refreshGitLog?.(); + } catch (error) { + setSyncError(error instanceof Error ? error.message : String(error)); + } finally { + setSyncLoading(false); + } + }, [activeWorkspace, syncLoading, refreshGitStatus, refreshGitLog]); + const handleSendPromptToNewAgent = useCallback( async (text: string) => { const trimmed = text.trim(); @@ -1548,6 +1737,23 @@ function MainApp() { gitDiffLoading: activeDiffLoading, gitDiffError: activeDiffError, onDiffActivePathChange: handleActiveDiffPath, + commitMessage, + commitMessageLoading, + commitMessageError, + onCommitMessageChange: handleCommitMessageChange, + onGenerateCommitMessage: handleGenerateCommitMessage, + onCommit: handleCommit, + onCommitAndPush: handleCommitAndPush, + onCommitAndSync: handleCommitAndSync, + onPush: handlePush, + onSync: handleSync, + commitLoading, + pushLoading, + syncLoading, + commitError, + pushError, + syncError, + commitsAhead: gitLogAhead, onSendPrompt: handleSendPrompt, onSendPromptToNewAgent: handleSendPromptToNewAgent, onCreatePrompt: handleCreatePrompt, diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx new file mode 100644 index 000000000..99f053f11 --- /dev/null +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -0,0 +1,67 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { GitLogEntry } from "../../../types"; +import { GitDiffPanel } from "./GitDiffPanel"; + +vi.mock("@tauri-apps/api/menu", () => ({ + Menu: { new: vi.fn(async () => ({ popup: vi.fn() })) }, + MenuItem: { new: vi.fn(async () => ({})) }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ scaleFactor: () => 1 }), +})); + +vi.mock("@tauri-apps/api/dpi", () => ({ + LogicalPosition: class LogicalPosition { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + }, +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(), +})); + +const logEntries: GitLogEntry[] = []; + +const baseProps = { + mode: "diff" as const, + onModeChange: vi.fn(), + filePanelMode: "git" as const, + onFilePanelModeChange: vi.fn(), + branchName: "main", + totalAdditions: 0, + totalDeletions: 0, + fileStatus: "1 file changed", + logEntries, + stagedFiles: [], + unstagedFiles: [], +}; + +describe("GitDiffPanel", () => { + it("enables commit when message exists and only unstaged changes", () => { + const onCommit = vi.fn(); + render( + , + ); + + const commitButton = screen.getByRole("button", { name: "Commit" }); + expect((commitButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(commitButton); + expect(onCommit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index ef53a490b..f0b969297 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -14,6 +14,7 @@ import { Search, Upload, } from "lucide-react"; +import { useMemo, useState, useCallback } from "react"; import { formatRelativeTime } from "../../../utils/time"; import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; @@ -82,6 +83,25 @@ type GitDiffPanelProps = { onUnstageFile?: (path: string) => Promise | void; onRevertFile?: (path: string) => Promise | void; logEntries: GitLogEntry[]; + commitMessage?: string; + commitMessageLoading?: boolean; + commitMessageError?: string | null; + onCommitMessageChange?: (value: string) => void; + onGenerateCommitMessage?: () => void | Promise; + // Git operations + onCommit?: () => void | Promise; + onCommitAndPush?: () => void | Promise; + onCommitAndSync?: () => void | Promise; + onPush?: () => void | Promise; + onSync?: () => void | Promise; + commitLoading?: boolean; + pushLoading?: boolean; + syncLoading?: boolean; + commitError?: string | null; + pushError?: string | null; + syncError?: string | null; + // For showing push button when there are commits to push + commitsAhead?: number; }; function splitPath(path: string) { @@ -158,6 +178,71 @@ function isMissingRepo(error: string | null | undefined) { ); } +type CommitButtonProps = { + commitMessage: string; + hasStagedFiles: boolean; + hasUnstagedFiles: boolean; + commitLoading: boolean; + onCommit?: () => void | Promise; +}; + +function CommitButton({ + commitMessage, + hasStagedFiles, + hasUnstagedFiles, + commitLoading, + onCommit, +}: CommitButtonProps) { + const hasMessage = commitMessage.trim().length > 0; + const hasChanges = hasStagedFiles || hasUnstagedFiles; + const canCommit = hasMessage && hasChanges && !commitLoading; + + const handleCommit = () => { + if (canCommit) { + void onCommit?.(); + } + }; + + return ( +
+ +
+ ); +} + export function GitDiffPanel({ mode, onModeChange, @@ -213,7 +298,90 @@ export function GitDiffPanel({ onSelectGitRoot, onClearGitRoot, onPickGitRoot, + commitMessage = "", + commitMessageLoading = false, + commitMessageError = null, + onCommitMessageChange, + onGenerateCommitMessage, + onCommit, + onCommitAndPush: _onCommitAndPush, + onCommitAndSync: _onCommitAndSync, + onPush, + onSync: _onSync, + commitLoading = false, + pushLoading = false, + syncLoading: _syncLoading = false, + commitError = null, + pushError = null, + syncError = null, + commitsAhead = 0, }: GitDiffPanelProps) { + // Multi-select state for file list + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [lastClickedFile, setLastClickedFile] = useState(null); + + // Combine staged and unstaged files for range selection + const allFiles = useMemo( + () => [ + ...stagedFiles.map(f => ({ ...f, section: "staged" as const })), + ...unstagedFiles.map(f => ({ ...f, section: "unstaged" as const })), + ], + [stagedFiles, unstagedFiles], + ); + + const handleFileClick = useCallback(( + event: ReactMouseEvent, + path: string, + _section: "staged" | "unstaged", + ) => { + const isMetaKey = event.metaKey || event.ctrlKey; + const isShiftKey = event.shiftKey; + + if (isMetaKey) { + // Cmd/Ctrl+click: toggle selection + setSelectedFiles(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + setLastClickedFile(path); + } else if (isShiftKey && lastClickedFile) { + // Shift+click: select range + const currentIndex = allFiles.findIndex(f => f.path === path); + const lastIndex = allFiles.findIndex(f => f.path === lastClickedFile); + if (currentIndex !== -1 && lastIndex !== -1) { + const start = Math.min(currentIndex, lastIndex); + const end = Math.max(currentIndex, lastIndex); + const range = allFiles.slice(start, end + 1).map(f => f.path); + setSelectedFiles(prev => { + const next = new Set(prev); + for (const p of range) { + next.add(p); + } + return next; + }); + } + } else { + // Regular click: select single file and view it + setSelectedFiles(new Set([path])); + setLastClickedFile(path); + onSelectFile?.(path); + } + }, [lastClickedFile, allFiles, onSelectFile]); + + // Clear selection when files change + const filesKey = [...stagedFiles, ...unstagedFiles].map(f => f.path).join(","); + const [prevFilesKey, setPrevFilesKey] = useState(filesKey); + if (filesKey !== prevFilesKey) { + setPrevFilesKey(filesKey); + setSelectedFiles(new Set()); + setLastClickedFile(null); + } + const ModeIcon = (() => { switch (mode) { case "log": @@ -295,41 +463,76 @@ export function GitDiffPanel({ async function showFileMenu( event: ReactMouseEvent, path: string, - mode: "staged" | "unstaged", + _mode: "staged" | "unstaged", ) { event.preventDefault(); event.stopPropagation(); + + // Determine which files to operate on + // If clicked file is in selection, use all selected files; otherwise just this file + const isInSelection = selectedFiles.has(path); + const targetPaths = isInSelection && selectedFiles.size > 1 + ? Array.from(selectedFiles) + : [path]; + + // If clicking on unselected file, select it + if (!isInSelection) { + setSelectedFiles(new Set([path])); + setLastClickedFile(path); + } + + const fileCount = targetPaths.length; + const plural = fileCount > 1 ? "s" : ""; + const countSuffix = fileCount > 1 ? ` (${fileCount})` : ""; + + // Separate files by their section for stage/unstage operations + const stagedPaths = targetPaths.filter(p => stagedFiles.some(f => f.path === p)); + const unstagedPaths = targetPaths.filter(p => unstagedFiles.some(f => f.path === p)); + const items: MenuItem[] = []; - if (mode === "staged" && onUnstageFile) { + + // Unstage action for staged files + if (stagedPaths.length > 0 && onUnstageFile) { items.push( await MenuItem.new({ - text: "Unstage file", + text: `Unstage file${stagedPaths.length > 1 ? `s (${stagedPaths.length})` : ""}`, action: async () => { - await onUnstageFile(path); + for (const p of stagedPaths) { + await onUnstageFile(p); + } }, }), ); } - if (mode === "unstaged" && onStageFile) { + + // Stage action for unstaged files + if (unstagedPaths.length > 0 && onStageFile) { items.push( await MenuItem.new({ - text: "Stage file", + text: `Stage file${unstagedPaths.length > 1 ? `s (${unstagedPaths.length})` : ""}`, action: async () => { - await onStageFile(path); + for (const p of unstagedPaths) { + await onStageFile(p); + } }, }), ); } + + // Revert action for all selected files if (onRevertFile) { items.push( await MenuItem.new({ - text: "Revert changes", + text: `Revert change${plural}${countSuffix}`, action: async () => { - await onRevertFile(path); + for (const p of targetPaths) { + await onRevertFile(p); + } }, }), ); } + if (!items.length) { return; } @@ -373,10 +576,13 @@ export function GitDiffPanel({ const showRevertAll = mode === "diff" && Boolean(onRevertAllChanges) && hasAnyChanges; const showRevertAllInStaged = showRevertAll && stagedFiles.length > 0; const showRevertAllInUnstaged = showRevertAll && unstagedFiles.length > 0; + const canGenerateCommitMessage = hasAnyChanges; + const showGenerateCommitMessage = + mode === "diff" && Boolean(onGenerateCommitMessage) && hasAnyChanges; const worktreeApplyButtonLabel = worktreeApplySuccess ? "applied" : worktreeApplyLoading - ? "applying..." + ? "applying..." : worktreeApplyLabel; const worktreeApplyIcon = worktreeApplySuccess ? ( @@ -560,7 +766,124 @@ export function GitDiffPanel({ )} )} - {!error && !stagedFiles.length && !unstagedFiles.length && ( + {showGenerateCommitMessage && ( +
+
+