From 6114402e1b444d51650dfd4ad657d1630c93180d Mon Sep 17 00:00:00 2001 From: radu2lupu Date: Tue, 20 Jan 2026 09:58:37 +0100 Subject: [PATCH 1/6] feat: add git commit and push UI with AI commit message generation - Add backend commands for git commit, push, pull, and sync operations - Implement AI-powered commit message generation using background threads - Add commit button with ghost/outlined styling matching the design system - Add push button that appears when there are commits ahead of remote - Position generate button inside the commit message textarea - Use custom sparkles icon for AI generation and custom checkmark for commit - Use rotating loader icon for commit message generation loading state - Add multi-select support for staged/unstaged files with shift+click and cmd+click - Context menu actions apply to all selected files Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 17 +- src-tauri/src/backend/app_server.rs | 53 ++- src-tauri/src/codex.rs | 164 +++++++++ src-tauri/src/git.rs | 130 ++++++++ src-tauri/src/lib.rs | 6 + src/App.tsx | 179 +++++++++- src/features/git/components/GitDiffPanel.tsx | 330 ++++++++++++++++++- src/features/layout/hooks/useLayoutNodes.tsx | 34 ++ src/services/tauri.ts | 31 ++ src/styles/diff.css | 220 +++++++++++++ 10 files changed, 1130 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8d2c5026..ba2bb6a1d 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..6229a29a6 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -9,7 +9,7 @@ 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}; @@ -21,6 +21,8 @@ pub(crate) struct WorkspaceSession { 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 +210,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 +240,58 @@ 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 = value + .get("params") + .and_then(|p| p.get("threadId")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + 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); } } }); diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index a09033e33..de41361fe 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; @@ -381,6 +382,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, @@ -421,3 +446,142 @@ 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" }, + }); + session.send_request("turn/start", turn_params).await?; + + // 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..d84128c4e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -378,6 +378,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)?; + run_git_command(&repo_root, &["push"]).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?; + run_git_command(&repo_root, &["push"]).await +} + #[tauri::command] pub(crate) async fn list_git_roots( workspace_id: String, @@ -395,6 +458,73 @@ 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)?; + 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(); + 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())?, + }; + + 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); + } + + Ok(combined_diff) +} + #[tauri::command] pub(crate) async fn get_git_diffs( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc3b23dda..2aa91891f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -243,6 +243,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, @@ -257,6 +259,10 @@ pub fn run() { 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 e3e8c7421..8da67db0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -92,6 +92,10 @@ import { useTerminalController } from "./features/terminal/hooks/useTerminalCont import { playNotificationSound } from "./utils/notificationSounds"; import { pickWorkspacePath, + generateCommitMessage, + commitGit, + pushGit, + syncGit, } from "./services/tauri"; import type { AccessMode, @@ -373,7 +377,8 @@ function MainApp() { behindEntries: gitLogBehindEntries, upstream: gitLogUpstream, isLoading: gitLogLoading, - error: gitLogError + error: gitLogError, + refresh: refreshGitLog, } = useGitLog(activeWorkspace, shouldLoadGitLog); const { issues: gitIssues, @@ -873,6 +878,161 @@ function MainApp() { [handleSend], ); + // Commit message generation state + const [commitMessage, setCommitMessage] = useState(""); + const [commitMessageLoading, setCommitMessageLoading] = useState(false); + const [commitMessageError, setCommitMessageError] = useState(null); + + const handleCommitMessageChange = useCallback((value: string) => { + setCommitMessage(value); + }, []); + + const handleGenerateCommitMessage = useCallback(async () => { + if (!activeWorkspace || commitMessageLoading) { + return; + } + setCommitMessageLoading(true); + setCommitMessageError(null); + try { + // Generate commit message in background + const message = await generateCommitMessage(activeWorkspace.id); + setCommitMessage(message); + } catch (error) { + setCommitMessageError( + error instanceof Error ? error.message : String(error) + ); + } finally { + setCommitMessageLoading(false); + } + }, [activeWorkspace, commitMessageLoading]); + + // Clear commit message state when workspace changes + useEffect(() => { + setCommitMessage(""); + setCommitMessageError(null); + }, [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()) { + return; + } + setCommitLoading(true); + setCommitError(null); + try { + 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, refreshGitStatus, refreshGitLog]); + + const handleCommitAndPush = useCallback(async () => { + if (!activeWorkspace || commitLoading || pushLoading || !commitMessage.trim()) { + return; + } + setCommitLoading(true); + setPushLoading(true); + setCommitError(null); + setPushError(null); + try { + await commitGit(activeWorkspace.id, commitMessage.trim()); + 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 (commitLoading) { + setCommitError(errorMsg); + } else { + setPushError(errorMsg); + } + } finally { + setCommitLoading(false); + setPushLoading(false); + } + }, [activeWorkspace, commitLoading, pushLoading, commitMessage, refreshGitStatus, refreshGitLog]); + + const handleCommitAndSync = useCallback(async () => { + if (!activeWorkspace || commitLoading || syncLoading || !commitMessage.trim()) { + return; + } + setCommitLoading(true); + setSyncLoading(true); + setCommitError(null); + setSyncError(null); + try { + await commitGit(activeWorkspace.id, commitMessage.trim()); + 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 (commitLoading) { + setCommitError(errorMsg); + } else { + setSyncError(errorMsg); + } + } finally { + setCommitLoading(false); + setSyncLoading(false); + } + }, [activeWorkspace, commitLoading, syncLoading, commitMessage, 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(); @@ -1439,6 +1599,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.tsx b/src/features/git/components/GitDiffPanel.tsx index ef53a490b..3d752a444 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 { 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,61 @@ function isMissingRepo(error: string | null | undefined) { ); } +type CommitButtonProps = { + commitMessage: string; + stagedFiles: { path: string }[]; + commitLoading: boolean; + onCommit?: () => void | Promise; +}; + +function CommitButton({ + commitMessage, + stagedFiles, + commitLoading, + onCommit, +}: CommitButtonProps) { + const hasMessage = commitMessage.trim().length > 0; + const hasStagedFiles = stagedFiles.length > 0; + const canCommit = hasMessage && hasStagedFiles && !commitLoading; + + const handleCommit = () => { + if (canCommit) { + void onCommit?.(); + } + }; + + return ( +
+ +
+ ); +} + export function GitDiffPanel({ mode, onModeChange, @@ -213,7 +288,84 @@ 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 = [...stagedFiles.map(f => ({ ...f, section: "staged" as const })), ...unstagedFiles.map(f => ({ ...f, section: "unstaged" as const }))]; + + 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 +447,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,6 +560,8 @@ export function GitDiffPanel({ const showRevertAll = mode === "diff" && Boolean(onRevertAllChanges) && hasAnyChanges; const showRevertAllInStaged = showRevertAll && stagedFiles.length > 0; const showRevertAllInUnstaged = showRevertAll && unstagedFiles.length > 0; + const showGenerateCommitMessage = + mode === "diff" && Boolean(onGenerateCommitMessage) && hasAnyChanges; const worktreeApplyButtonLabel = worktreeApplySuccess ? "applied" : worktreeApplyLoading @@ -560,7 +749,116 @@ export function GitDiffPanel({ )} )} - {!error && !stagedFiles.length && !unstagedFiles.length && ( + {showGenerateCommitMessage && ( +
+
+