From 43e81e3d8c4372c5be15e7675771d32f1988274e Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 18 Jan 2026 17:21:35 +0100 Subject: [PATCH] Add staging actions to git diff panel --- src-tauri/src/git.rs | 196 ++++++++++++++--- src-tauri/src/lib.rs | 3 + src/App.tsx | 37 +++- src/features/git/components/GitDiffPanel.tsx | 213 +++++++++++++++---- src/features/git/hooks/useGitStatus.ts | 4 + src/features/layout/hooks/useLayoutNodes.tsx | 11 +- src/services/tauri.ts | 14 ++ src/styles/diff.css | 20 ++ 8 files changed, 422 insertions(+), 76 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 12fb848d6..192429e9e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -16,6 +16,63 @@ use crate::types::{ }; use crate::utils::normalize_git_path; +async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git command failed.".to_string()); + } + Err(detail.to_string()) +} + +fn status_for_index(status: Status) -> Option<&'static str> { + if status.contains(Status::INDEX_NEW) { + Some("A") + } else if status.contains(Status::INDEX_MODIFIED) { + Some("M") + } else if status.contains(Status::INDEX_DELETED) { + Some("D") + } else if status.contains(Status::INDEX_RENAMED) { + Some("R") + } else if status.contains(Status::INDEX_TYPECHANGE) { + Some("T") + } else { + None + } +} + +fn status_for_workdir(status: Status) -> Option<&'static str> { + if status.contains(Status::WT_NEW) { + Some("A") + } else if status.contains(Status::WT_MODIFIED) { + Some("M") + } else if status.contains(Status::WT_DELETED) { + Some("D") + } else if status.contains(Status::WT_RENAMED) { + Some("R") + } else if status.contains(Status::WT_TYPECHANGE) { + Some("T") + } else { + None + } +} + 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())?; @@ -165,6 +222,8 @@ pub(crate) async fn get_git_status( let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let mut files = Vec::new(); + let mut staged_files = Vec::new(); + let mut unstaged_files = Vec::new(); let mut total_additions = 0i64; let mut total_deletions = 0i64; for entry in statuses.iter() { @@ -173,21 +232,6 @@ pub(crate) async fn get_git_status( continue; } let status = entry.status(); - let status_str = if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) { - "A" - } else if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) { - "M" - } else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) { - "D" - } else if status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED) { - "R" - } else if status.contains(Status::WT_TYPECHANGE) - || status.contains(Status::INDEX_TYPECHANGE) - { - "T" - } else { - "--" - }; let normalized_path = normalize_git_path(path); let include_index = status.intersects( Status::INDEX_NEW @@ -203,32 +247,122 @@ pub(crate) async fn get_git_status( | Status::WT_RENAMED | Status::WT_TYPECHANGE, ); - let (additions, deletions) = diff_stats_for_path( - &repo, - head_tree.as_ref(), - path, - include_index, - include_workdir, - ) - .map_err(|e| e.to_string())?; - total_additions += additions; - total_deletions += deletions; - files.push(GitFileStatus { - path: normalized_path, - status: status_str.to_string(), - additions, - deletions, - }); + let mut combined_additions = 0i64; + let mut combined_deletions = 0i64; + + if include_index { + let (additions, deletions) = + diff_stats_for_path(&repo, head_tree.as_ref(), path, true, false) + .map_err(|e| e.to_string())?; + if let Some(status_str) = status_for_index(status) { + staged_files.push(GitFileStatus { + path: normalized_path.clone(), + status: status_str.to_string(), + additions, + deletions, + }); + } + combined_additions += additions; + combined_deletions += deletions; + total_additions += additions; + total_deletions += deletions; + } + + if include_workdir { + let (additions, deletions) = + diff_stats_for_path(&repo, head_tree.as_ref(), path, false, true) + .map_err(|e| e.to_string())?; + if let Some(status_str) = status_for_workdir(status) { + unstaged_files.push(GitFileStatus { + path: normalized_path.clone(), + status: status_str.to_string(), + additions, + deletions, + }); + } + combined_additions += additions; + combined_deletions += deletions; + total_additions += additions; + total_deletions += deletions; + } + + if include_index || include_workdir { + let status_str = status_for_workdir(status) + .or_else(|| status_for_index(status)) + .unwrap_or("--"); + files.push(GitFileStatus { + path: normalized_path, + status: status_str.to_string(), + additions: combined_additions, + deletions: combined_deletions, + }); + } } Ok(json!({ "branchName": branch_name, "files": files, + "stagedFiles": staged_files, + "unstagedFiles": unstaged_files, "totalAdditions": total_additions, "totalDeletions": total_deletions, })) } +#[tauri::command] +pub(crate) async fn stage_git_file( + workspace_id: String, + path: 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", "--", &path]).await +} + +#[tauri::command] +pub(crate) async fn unstage_git_file( + workspace_id: String, + path: 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, &["restore", "--staged", "--", &path]).await +} + +#[tauri::command] +pub(crate) async fn revert_git_file( + workspace_id: String, + path: 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)?; + if run_git_command(&repo_root, &["restore", "--staged", "--worktree", "--", &path]) + .await + .is_ok() + { + return Ok(()); + } + run_git_command(&repo_root, &["clean", "-f", "--", &path]).await +} + #[tauri::command] pub(crate) async fn list_git_roots( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 435f14de4..315d7e9f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,9 @@ pub fn run() { git::get_git_diffs, git::get_git_log, git::get_git_remote, + git::stage_git_file, + git::unstage_git_file, + git::revert_git_file, 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 e115b8737..1c43f145e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -77,7 +77,12 @@ 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 { pickWorkspacePath } from "./services/tauri"; +import { + pickWorkspacePath, + revertGitFile, + stageGitFile, + unstageGitFile, +} from "./services/tauri"; import type { AccessMode, GitHubPullRequest, @@ -305,7 +310,8 @@ function MainApp() { const { diffs: gitDiffs, isLoading: isDiffLoading, - error: diffError + error: diffError, + refresh: refreshGitDiffs, } = useGitDiffs(activeWorkspace, gitStatus.files, shouldLoadDiffs); const { entries: gitLogEntries, @@ -387,6 +393,30 @@ function MainApp() { await createBranch(name); refreshGitStatus(); }; + const handleStageGitFile = async (path: string) => { + if (!activeWorkspace) { + return; + } + await stageGitFile(activeWorkspace.id, path); + refreshGitStatus(); + refreshGitDiffs(); + }; + const handleUnstageGitFile = async (path: string) => { + if (!activeWorkspace) { + return; + } + await unstageGitFile(activeWorkspace.id, path); + refreshGitStatus(); + refreshGitDiffs(); + }; + const handleRevertGitFile = async (path: string) => { + if (!activeWorkspace) { + return; + } + await revertGitFile(activeWorkspace.id, path); + refreshGitStatus(); + refreshGitDiffs(); + }; const resolvedModel = selectedModel?.model ?? null; const activeGitRoot = activeWorkspace?.settings.gitRoot ?? null; @@ -1047,6 +1077,9 @@ function MainApp() { void handleSetGitRoot(null); }, onPickGitRoot: handlePickGitRoot, + onStageGitFile: handleStageGitFile, + onUnstageGitFile: handleUnstageGitFile, + onRevertGitFile: handleRevertGitFile, gitDiffs: activeDiffs, gitDiffLoading: activeDiffLoading, gitDiffError: activeDiffError, diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index cc55a8348..f69cf8ecf 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -48,12 +48,21 @@ type GitDiffPanelProps = { onPickGitRoot?: () => void | Promise; selectedPath?: string | null; onSelectFile?: (path: string) => void; - files: { + stagedFiles: { path: string; status: string; additions: number; deletions: number; }[]; + unstagedFiles: { + path: string; + status: string; + additions: number; + deletions: number; + }[]; + onStageFile?: (path: string) => Promise | void; + onUnstageFile?: (path: string) => Promise | void; + onRevertFile?: (path: string) => Promise | void; logEntries: GitLogEntry[]; }; @@ -145,7 +154,6 @@ export function GitDiffPanel({ logTotal = 0, gitRemoteUrl = null, onSelectFile, - files, logEntries, logAhead = 0, logBehind = 0, @@ -169,6 +177,11 @@ export function GitDiffPanel({ gitRootScanError = null, gitRootScanHasScanned = false, selectedPath = null, + stagedFiles = [], + unstagedFiles = [], + onStageFile, + onUnstageFile, + onRevertFile, onGitRootScanDepthChange, onScanGitRoots, onSelectGitRoot, @@ -240,6 +253,53 @@ export function GitDiffPanel({ const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window); } + + async function showFileMenu( + event: ReactMouseEvent, + path: string, + mode: "staged" | "unstaged", + ) { + event.preventDefault(); + event.stopPropagation(); + const items: MenuItem[] = []; + if (mode === "staged" && onUnstageFile) { + items.push( + await MenuItem.new({ + text: "Unstage file", + action: async () => { + await onUnstageFile(path); + }, + }), + ); + } + if (mode === "unstaged" && onStageFile) { + items.push( + await MenuItem.new({ + text: "Stage file", + action: async () => { + await onStageFile(path); + }, + }), + ); + } + if (onRevertFile) { + items.push( + await MenuItem.new({ + text: "Revert changes", + action: async () => { + await onRevertFile(path); + }, + }), + ); + } + if (!items.length) { + return; + } + const menu = await Menu.new({ items }); + const window = getCurrentWindow(); + const position = new LogicalPosition(event.clientX, event.clientY); + await menu.popup(position, window); + } const logCountLabel = logTotal ? `${logTotal} commit${logTotal === 1 ? "" : "s"}` : logEntries.length @@ -451,50 +511,119 @@ export function GitDiffPanel({ )} )} - {!error && !files.length && ( + {!error && !stagedFiles.length && !unstagedFiles.length && (
No changes detected.
)} - {files.map((file) => { - const { name, dir } = splitPath(file.path); - const { base, extension } = splitNameAndExtension(name); - const statusSymbol = getStatusSymbol(file.status); - const statusClass = getStatusClass(file.status); - return ( -
onSelectFile?.(file.path)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectFile?.(file.path); - } - }} - > - - {statusSymbol} - -
-
- - {base} - {extension && ( - .{extension} - )} - - - +{file.additions} - / - -{file.deletions} - + {(stagedFiles.length > 0 || unstagedFiles.length > 0) && ( + <> + {stagedFiles.length > 0 && ( +
+
+ Staged ({stagedFiles.length}) +
+
+ {stagedFiles.map((file) => { + const { name, dir } = splitPath(file.path); + const { base, extension } = splitNameAndExtension(name); + const statusSymbol = getStatusSymbol(file.status); + const statusClass = getStatusClass(file.status); + return ( +
onSelectFile?.(file.path)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectFile?.(file.path); + } + }} + onContextMenu={(event) => + showFileMenu(event, file.path, "staged") + } + > + + {statusSymbol} + +
+
+ + {base} + {extension && ( + .{extension} + )} + + + +{file.additions} + / + -{file.deletions} + +
+ {dir &&
{dir}
} +
+
+ ); + })}
- {dir &&
{dir}
}
-
- ); - })} + )} + {unstagedFiles.length > 0 && ( +
+
+ Unstaged ({unstagedFiles.length}) +
+
+ {unstagedFiles.map((file) => { + const { name, dir } = splitPath(file.path); + const { base, extension } = splitNameAndExtension(name); + const statusSymbol = getStatusSymbol(file.status); + const statusClass = getStatusClass(file.status); + return ( +
onSelectFile?.(file.path)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectFile?.(file.path); + } + }} + onContextMenu={(event) => + showFileMenu(event, file.path, "unstaged") + } + > + + {statusSymbol} + +
+
+ + {base} + {extension && ( + .{extension} + )} + + + +{file.additions} + / + -{file.deletions} + +
+ {dir &&
{dir}
} +
+
+ ); + })} +
+
+ )} + + )}
) : mode === "log" ? (
diff --git a/src/features/git/hooks/useGitStatus.ts b/src/features/git/hooks/useGitStatus.ts index 2f3a1029c..f8aa83dd1 100644 --- a/src/features/git/hooks/useGitStatus.ts +++ b/src/features/git/hooks/useGitStatus.ts @@ -5,6 +5,8 @@ import { getGitStatus } from "../../../services/tauri"; type GitStatusState = { branchName: string; files: GitFileStatus[]; + stagedFiles: GitFileStatus[]; + unstagedFiles: GitFileStatus[]; totalAdditions: number; totalDeletions: number; error: string | null; @@ -13,6 +15,8 @@ type GitStatusState = { const emptyStatus: GitStatusState = { branchName: "", files: [], + stagedFiles: [], + unstagedFiles: [], totalAdditions: 0, totalDeletions: 0, error: null, diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 2d988f30f..ca162a275 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -137,6 +137,8 @@ type LayoutNodesOptions = { gitStatus: { branchName: string; files: GitFileStatus[]; + stagedFiles: GitFileStatus[]; + unstagedFiles: GitFileStatus[]; totalAdditions: number; totalDeletions: number; error: string | null; @@ -175,6 +177,9 @@ type LayoutNodesOptions = { onSelectGitRoot: (path: string) => void; onClearGitRoot: () => void; onPickGitRoot: () => void | Promise; + onStageGitFile: (path: string) => Promise; + onUnstageGitFile: (path: string) => Promise; + onRevertGitFile: (path: string) => Promise; gitDiffs: GitDiffViewerItem[]; gitDiffLoading: boolean; gitDiffError: string | null; @@ -462,7 +467,8 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { error={options.gitStatus.error} logError={options.gitLogError} logLoading={options.gitLogLoading} - files={options.gitStatus.files} + stagedFiles={options.gitStatus.stagedFiles} + unstagedFiles={options.gitStatus.unstagedFiles} onSelectFile={options.onSelectDiff} selectedPath={options.selectedDiffPath} logEntries={options.gitLogEntries} @@ -495,6 +501,9 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onSelectGitRoot={options.onSelectGitRoot} onClearGitRoot={options.onClearGitRoot} onPickGitRoot={options.onPickGitRoot} + onStageFile={options.onStageGitFile} + onUnstageFile={options.onUnstageGitFile} + onRevertFile={options.onRevertGitFile} /> ); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index d7c031d6b..76a2ff40b 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -154,6 +154,8 @@ export async function respondToServerRequest( export async function getGitStatus(workspace_id: string): Promise<{ branchName: string; files: GitFileStatus[]; + stagedFiles: GitFileStatus[]; + unstagedFiles: GitFileStatus[]; totalAdditions: number; totalDeletions: number; }> { @@ -184,6 +186,18 @@ export async function getGitRemote(workspace_id: string): Promise return invoke("get_git_remote", { workspaceId: workspace_id }); } +export async function stageGitFile(workspaceId: string, path: string) { + return invoke("stage_git_file", { workspaceId, path }); +} + +export async function unstageGitFile(workspaceId: string, path: string) { + return invoke("unstage_git_file", { workspaceId, path }); +} + +export async function revertGitFile(workspaceId: string, path: string) { + return invoke("revert_git_file", { workspaceId, path }); +} + export async function getGitHubIssues( workspace_id: string, ): Promise { diff --git a/src/styles/diff.css b/src/styles/diff.css index 88a59d5c3..67c7869e4 100644 --- a/src/styles/diff.css +++ b/src/styles/diff.css @@ -237,6 +237,26 @@ min-height: 0; } +.diff-section { + display: flex; + flex-direction: column; + gap: 3px; +} + +.diff-section-title { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 6px 6px 2px; +} + +.diff-section-list { + display: flex; + flex-direction: column; + gap: 3px; +} + .diff-empty { font-size: 12px; color: var(--text-faint);