From 97e788f0b72f4f09ceeb2504ddf7a51a8be5a164 Mon Sep 17 00:00:00 2001 From: Amogh Rijal Date: Wed, 21 Jan 2026 17:17:21 +0545 Subject: [PATCH 1/2] feat(git): speed up diff workflow with inline stage/discard Reduce friction when preparing commits by keeping common git actions in the diff panel, and guard discards with a confirmation step. --- src-tauri/src/git.rs | 165 ++++++++-- src/App.tsx | 2 + .../git/components/GitDiffPanel.test.tsx | 4 + src/features/git/components/GitDiffPanel.tsx | 304 +++++++++++++----- src/features/git/hooks/useGitActions.ts | 18 ++ src/features/layout/hooks/useLayoutNodes.tsx | 2 + src/styles/diff.css | 257 +++++++++++++-- 7 files changed, 616 insertions(+), 136 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index f6363a388..d29ee1123 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -44,6 +44,65 @@ async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> Err(detail.to_string()) } +fn action_paths_for_file(repo_root: &Path, path: &str) -> Vec { + let target = normalize_git_path(path).trim().to_string(); + if target.is_empty() { + return Vec::new(); + } + + let repo = match Repository::open(repo_root) { + Ok(repo) => repo, + Err(_) => return vec![target], + }; + + let mut status_options = StatusOptions::new(); + status_options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true) + .include_ignored(false); + + let statuses = match repo.statuses(Some(&mut status_options)) { + Ok(statuses) => statuses, + Err(_) => return vec![target], + }; + + for entry in statuses.iter() { + let status = entry.status(); + if !(status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED)) { + continue; + } + let delta = entry.index_to_workdir().or_else(|| entry.head_to_index()); + let Some(delta) = delta else { + continue; + }; + let (Some(old_path), Some(new_path)) = + (delta.old_file().path(), delta.new_file().path()) + else { + continue; + }; + let old_path = normalize_git_path(old_path.to_string_lossy().as_ref()); + let new_path = normalize_git_path(new_path.to_string_lossy().as_ref()); + if old_path != target && new_path != target { + continue; + } + if old_path == new_path || new_path.is_empty() { + return vec![target]; + } + let mut result = Vec::new(); + if !old_path.is_empty() { + result.push(old_path); + } + if !new_path.is_empty() && !result.contains(&new_path) { + result.push(new_path); + } + return if result.is_empty() { vec![target] } else { result }; + } + + vec![target] +} + 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, '/'); @@ -459,14 +518,21 @@ pub(crate) async fn stage_git_file( 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 entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or("workspace not found")? + }; let repo_root = resolve_git_root(&entry)?; - run_git_command(&repo_root, &["add", "--", &path]).await + // If libgit2 reports a rename, we want a single UI action to stage both the + // old + new paths so the change actually moves to the staged section. + for path in action_paths_for_file(&repo_root, &path) { + run_git_command(&repo_root, &["add", "-A", "--", &path]).await?; + } + Ok(()) } #[tauri::command] @@ -474,11 +540,13 @@ 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 entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or("workspace not found")? + }; let repo_root = resolve_git_root(&entry)?; run_git_command(&repo_root, &["add", "-A"]).await @@ -490,14 +558,19 @@ pub(crate) async fn unstage_git_file( 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 entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or("workspace not found")? + }; let repo_root = resolve_git_root(&entry)?; - run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await + for path in action_paths_for_file(&repo_root, &path) { + run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await?; + } + Ok(()) } #[tauri::command] @@ -506,20 +579,28 @@ pub(crate) async fn revert_git_file( 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 entry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(&workspace_id) + .cloned() + .ok_or("workspace not found")? + }; let repo_root = resolve_git_root(&entry)?; - if run_git_command(&repo_root, &["restore", "--staged", "--worktree", "--", &path]) + for path in action_paths_for_file(&repo_root, &path) { + if run_git_command( + &repo_root, + &["restore", "--staged", "--worktree", "--", &path], + ) .await .is_ok() - { - return Ok(()); + { + continue; + } + run_git_command(&repo_root, &["clean", "-f", "--", &path]).await?; } - run_git_command(&repo_root, &["clean", "-f", "--", &path]).await + Ok(()) } #[tauri::command] @@ -1244,4 +1325,36 @@ mod tests { assert!(diff.contains("unstaged.txt")); assert!(diff.contains("unstaged")); } + + #[test] + fn action_paths_for_file_expands_renames() { + let (root, repo) = create_temp_repo(); + fs::write(root.join("a.txt"), "hello\n").expect("write file"); + + let mut index = repo.index().expect("repo index"); + index + .add_path(Path::new("a.txt")) + .expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = + git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + fs::rename(root.join("a.txt"), root.join("b.txt")).expect("rename file"); + + // Stage the rename so libgit2 reports it as an INDEX_RENAMED entry. + let mut index = repo.index().expect("repo index"); + index + .remove_path(Path::new("a.txt")) + .expect("remove old path"); + index + .add_path(Path::new("b.txt")) + .expect("add new path"); + index.write().expect("write index"); + + let paths = action_paths_for_file(&root, "b.txt"); + assert_eq!(paths, vec!["a.txt".to_string(), "b.txt".to_string()]); + } } diff --git a/src/App.tsx b/src/App.tsx index 47e1452a5..f67946715 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -579,6 +579,7 @@ function MainApp() { applyWorktreeChanges: handleApplyWorktreeChanges, revertAllGitChanges: handleRevertAllGitChanges, revertGitFile: handleRevertGitFile, + stageGitAll: handleStageGitAll, stageGitFile: handleStageGitFile, unstageGitFile: handleUnstageGitFile, worktreeApplyError, @@ -2251,6 +2252,7 @@ function MainApp() { void handleSetGitRoot(null); }, onPickGitRoot: handlePickGitRoot, + onStageGitAll: handleStageGitAll, onStageGitFile: handleStageGitFile, onUnstageGitFile: handleUnstageGitFile, onRevertGitFile: handleRevertGitFile, diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index f251fae43..3fd7145fb 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -28,6 +28,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(), })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: vi.fn(async () => true), +})); + const logEntries: GitLogEntry[] = []; const baseProps = { diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 78f193d22..39ef1a19f 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -1,14 +1,17 @@ import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; -import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { ask } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ArrowLeftRight, Check, FileText, GitBranch, + Minus, + Plus, RotateCcw, ScrollText, Search, @@ -79,6 +82,7 @@ type GitDiffPanelProps = { additions: number; deletions: number; }[]; + onStageAllChanges?: () => void | Promise; onStageFile?: (path: string) => Promise | void; onUnstageFile?: (path: string) => Promise | void; onRevertFile?: (path: string) => Promise | void; @@ -258,23 +262,34 @@ type DiffFileRowProps = { file: DiffFile; isSelected: boolean; isActive: boolean; + section: "staged" | "unstaged"; onClick: (event: ReactMouseEvent) => void; onKeySelect: () => void; onContextMenu: (event: ReactMouseEvent) => void; + onStageFile?: (path: string) => Promise | void; + onUnstageFile?: (path: string) => Promise | void; + onDiscardFile?: (path: string) => Promise | void; }; function DiffFileRow({ file, isSelected, isActive, + section, onClick, onKeySelect, onContextMenu, + onStageFile, + onUnstageFile, + onDiscardFile, }: DiffFileRowProps) { const { name, dir } = splitPath(file.path); const { base, extension } = splitNameAndExtension(name); const statusSymbol = getStatusSymbol(file.status); const statusClass = getStatusClass(file.status); + const showStage = section === "unstaged" && Boolean(onStageFile); + const showUnstage = section === "staged" && Boolean(onUnstageFile); + const showDiscard = section === "unstaged" && Boolean(onDiscardFile); return (
{base} {extension && .{extension}} - - +{file.additions} - / - -{file.deletions} -
{dir &&
{dir}
} +
+ + +{file.additions} + / + -{file.deletions} + +
+ {showStage && ( + + )} + {showUnstage && ( + + )} + {showDiscard && ( + + )} +
+
); } @@ -316,16 +380,12 @@ type DiffSectionProps = { section: "staged" | "unstaged"; selectedFiles: Set; selectedPath: string | null; - showRevertAll: boolean; - showApplyWorktree: boolean; - worktreeApplyTitle?: string | null; - worktreeApplyLoading: boolean; - worktreeApplySuccess: boolean; - worktreeApplyButtonLabel: string; - worktreeApplyIcon: ReactNode; - onRevertAllChanges?: () => void | Promise; - onApplyWorktreeChanges?: () => void | Promise; onSelectFile?: (path: string) => void; + onStageAllChanges?: () => Promise | void; + onStageFile?: (path: string) => Promise | void; + onUnstageFile?: (path: string) => Promise | void; + onDiscardFile?: (path: string) => Promise | void; + onDiscardFiles?: (paths: string[]) => Promise | void; onFileClick: ( event: ReactMouseEvent, path: string, @@ -344,51 +404,88 @@ function DiffSection({ section, selectedFiles, selectedPath, - showRevertAll, - showApplyWorktree, - worktreeApplyTitle, - worktreeApplyLoading, - worktreeApplySuccess, - worktreeApplyButtonLabel, - worktreeApplyIcon, - onRevertAllChanges, - onApplyWorktreeChanges, onSelectFile, + onStageAllChanges, + onStageFile, + onUnstageFile, + onDiscardFile, + onDiscardFiles, onFileClick, onShowFileMenu, }: DiffSectionProps) { + const filePaths = files.map((file) => file.path); + const canStageAll = + section === "unstaged" && + (Boolean(onStageAllChanges) || Boolean(onStageFile)) && + filePaths.length > 0; + const canUnstageAll = section === "staged" && Boolean(onUnstageFile) && filePaths.length > 0; + const canDiscardAll = section === "unstaged" && Boolean(onDiscardFiles) && filePaths.length > 0; + const showSectionActions = canStageAll || canUnstageAll || canDiscardAll; + return (
{title} ({files.length}) - {showRevertAll && ( - - )} - {showApplyWorktree && ( - + {canStageAll && ( + + )} + {canUnstageAll && ( + + )} + {canDiscardAll && ( + + )} +
)}
@@ -401,9 +498,13 @@ function DiffSection({ file={file} isSelected={isSelected} isActive={isActive} + section={section} onClick={(event) => onFileClick(event, file.path, section)} onKeySelect={() => onSelectFile?.(file.path)} onContextMenu={(event) => onShowFileMenu(event, file.path, section)} + onStageFile={onStageFile} + onUnstageFile={onUnstageFile} + onDiscardFile={onDiscardFile} /> ); })} @@ -460,13 +561,11 @@ export function GitDiffPanel({ onModeChange, filePanelMode, onFilePanelModeChange, - worktreeApplyLabel = "apply", worktreeApplyTitle = null, worktreeApplyLoading = false, worktreeApplyError = null, worktreeApplySuccess = false, onApplyWorktreeChanges, - onRevertAllChanges, branchName, totalAdditions, totalDeletions, @@ -504,6 +603,7 @@ export function GitDiffPanel({ selectedPath = null, stagedFiles = [], unstagedFiles = [], + onStageAllChanges, onStageFile, onUnstageFile, onRevertFile, @@ -701,6 +801,40 @@ export function GitDiffPanel({ [], ); + const discardFiles = useCallback( + async (paths: string[]) => { + if (!onRevertFile) { + return; + } + const isSingle = paths.length === 1; + const previewLimit = 6; + const preview = paths.slice(0, previewLimit).join("\n"); + const more = + paths.length > previewLimit ? `\n… and ${paths.length - previewLimit} more` : ""; + const message = isSingle + ? `Discard changes in:\n\n${paths[0]}\n\nThis cannot be undone.` + : `Discard changes in these files?\n\n${preview}${more}\n\nThis cannot be undone.`; + const confirmed = await ask(message, { + title: "Discard changes", + kind: "warning", + }); + if (!confirmed) { + return; + } + for (const path of paths) { + await onRevertFile(path); + } + }, + [onRevertFile], + ); + + const discardFile = useCallback( + async (path: string) => { + await discardFiles([path]); + }, + [discardFiles], + ); + const showFileMenu = useCallback( async ( event: ReactMouseEvent, @@ -770,11 +904,9 @@ export function GitDiffPanel({ if (onRevertFile) { items.push( await MenuItem.new({ - text: `Revert change${plural}${countSuffix}`, + text: `Discard change${plural}${countSuffix}`, action: async () => { - for (const p of targetPaths) { - await onRevertFile(p); - } + await discardFiles(targetPaths); }, }), ); @@ -788,7 +920,15 @@ export function GitDiffPanel({ const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window); }, - [selectedFiles, stagedFiles, unstagedFiles, onUnstageFile, onStageFile, onRevertFile], + [ + selectedFiles, + stagedFiles, + unstagedFiles, + onUnstageFile, + onStageFile, + onRevertFile, + discardFiles, + ], ); const logCountLabel = logTotal ? `${logTotal} commit${logTotal === 1 ? "" : "s"}` @@ -818,21 +958,12 @@ export function GitDiffPanel({ Boolean(gitRootScanError) || gitRootCandidates.length > 0; const normalizedGitRoot = normalizeRootPath(gitRoot); - const hasWorktreeChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; - const showApplyWorktree = - mode === "diff" && Boolean(onApplyWorktreeChanges) && hasWorktreeChanges; const hasAnyChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; - const showRevertAll = mode === "diff" && Boolean(onRevertAllChanges) && hasAnyChanges; - const showRevertAllInStaged = showRevertAll && stagedFiles.length > 0; - const showRevertAllInUnstaged = showRevertAll && unstagedFiles.length > 0; + const showApplyWorktree = + mode === "diff" && Boolean(onApplyWorktreeChanges) && hasAnyChanges; const canGenerateCommitMessage = hasAnyChanges; const showGenerateCommitMessage = mode === "diff" && Boolean(onGenerateCommitMessage) && hasAnyChanges; - const worktreeApplyButtonLabel = worktreeApplySuccess - ? "applied" - : worktreeApplyLoading - ? "applying..." - : worktreeApplyLabel; const worktreeApplyIcon = worktreeApplySuccess ? ( ) : ( @@ -861,6 +992,20 @@ export function GitDiffPanel({
+ {showApplyWorktree && ( + + )} {mode === "diff" ? ( @@ -1145,16 +1290,10 @@ export function GitDiffPanel({ section="staged" selectedFiles={selectedFiles} selectedPath={selectedPath} - showRevertAll={showRevertAllInStaged} - showApplyWorktree={showApplyWorktree && unstagedFiles.length === 0} - worktreeApplyTitle={worktreeApplyTitle} - worktreeApplyLoading={worktreeApplyLoading} - worktreeApplySuccess={worktreeApplySuccess} - worktreeApplyButtonLabel={worktreeApplyButtonLabel} - worktreeApplyIcon={worktreeApplyIcon} - onRevertAllChanges={onRevertAllChanges} - onApplyWorktreeChanges={onApplyWorktreeChanges} onSelectFile={onSelectFile} + onUnstageFile={onUnstageFile} + onDiscardFile={onRevertFile ? discardFile : undefined} + onDiscardFiles={onRevertFile ? discardFiles : undefined} onFileClick={handleFileClick} onShowFileMenu={showFileMenu} /> @@ -1166,16 +1305,11 @@ export function GitDiffPanel({ section="unstaged" selectedFiles={selectedFiles} selectedPath={selectedPath} - showRevertAll={showRevertAllInUnstaged} - showApplyWorktree={showApplyWorktree} - worktreeApplyTitle={worktreeApplyTitle} - worktreeApplyLoading={worktreeApplyLoading} - worktreeApplySuccess={worktreeApplySuccess} - worktreeApplyButtonLabel={worktreeApplyButtonLabel} - worktreeApplyIcon={worktreeApplyIcon} - onRevertAllChanges={onRevertAllChanges} - onApplyWorktreeChanges={onApplyWorktreeChanges} onSelectFile={onSelectFile} + onStageAllChanges={onStageAllChanges} + onStageFile={onStageFile} + onDiscardFile={onRevertFile ? discardFile : undefined} + onDiscardFiles={onRevertFile ? discardFiles : undefined} onFileClick={handleFileClick} onShowFileMenu={showFileMenu} /> diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 5a191dc59..5c0c14fb6 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -4,6 +4,7 @@ import { applyWorktreeChanges as applyWorktreeChangesService, revertGitAll, revertGitFile as revertGitFileService, + stageGitAll as stageGitAllService, stageGitFile as stageGitFileService, unstageGitFile as unstageGitFileService, } from "../../../services/tauri"; @@ -68,6 +69,22 @@ export function useGitActions({ [onError, refreshGitData, workspaceId], ); + const stageGitAll = useCallback(async () => { + if (!workspaceId) { + return; + } + const actionWorkspaceId = workspaceId; + try { + await stageGitAllService(actionWorkspaceId); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } + }, [onError, refreshGitData, workspaceId]); + const unstageGitFile = useCallback( async (path: string) => { if (!workspaceId) { @@ -167,6 +184,7 @@ export function useGitActions({ applyWorktreeChanges, revertAllGitChanges, revertGitFile, + stageGitAll, stageGitFile, unstageGitFile, worktreeApplyError, diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 5cca4269f..0d47699b5 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -234,6 +234,7 @@ type LayoutNodesOptions = { onSelectGitRoot: (path: string) => void; onClearGitRoot: () => void; onPickGitRoot: () => void | Promise; + onStageGitAll: () => Promise; onStageGitFile: (path: string) => Promise; onUnstageGitFile: (path: string) => Promise; onRevertGitFile: (path: string) => Promise; @@ -651,6 +652,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onSelectGitRoot={options.onSelectGitRoot} onClearGitRoot={options.onClearGitRoot} onPickGitRoot={options.onPickGitRoot} + onStageAllChanges={options.onStageGitAll} onStageFile={options.onStageGitFile} onUnstageFile={options.onUnstageGitFile} onRevertFile={options.onRevertGitFile} diff --git a/src/styles/diff.css b/src/styles/diff.css index 01885fe11..d4d6df87b 100644 --- a/src/styles/diff.css +++ b/src/styles/diff.css @@ -254,7 +254,7 @@ .diff-list { display: flex; flex-direction: column; - gap: 3px; + gap: 8px; overflow-y: auto; flex: 1; padding-right: 2px; @@ -264,7 +264,11 @@ .diff-section { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; + padding: 6px; + border-radius: 12px; + background: color-mix(in srgb, var(--surface-card) 70%, transparent); + border: 1px solid var(--border-muted); } .diff-section-title { @@ -272,7 +276,8 @@ color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; - padding: 6px 0 2px; + padding: 4px 6px 2px; + font-weight: 600; } .diff-section-title--row { @@ -282,21 +287,17 @@ gap: 8px; } -.diff-section-action { +.diff-section-actions { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 10px; - font-size: 10px; - border-radius: 999px; - text-transform: uppercase; - letter-spacing: 0.04em; + gap: 4px; + margin-left: 6px; } .diff-section-list { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } .diff-empty { @@ -311,27 +312,39 @@ } .diff-row { - display: flex; - gap: 8px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + column-gap: 10px; align-items: center; - padding: 4px 6px; - border-radius: 8px; + padding: 6px 8px; + border-radius: 10px; cursor: pointer; - border: 1px solid transparent; + border: 1px solid var(--border-muted); + background: var(--surface-item); + transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.diff-row-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + justify-self: end; + min-width: 0; } .diff-row:hover { - background: var(--surface-hover); - border-color: var(--border-subtle); + background: var(--surface-control); + border-color: var(--border-strong); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12); } .diff-row.active { - background: var(--surface-active); + background: color-mix(in srgb, var(--surface-active) 70%, var(--surface-card)); border-color: var(--border-accent-soft); } .diff-row.selected { - background: var(--surface-hover); + background: var(--surface-control); border-color: var(--border-accent-soft); } @@ -339,6 +352,177 @@ background: var(--surface-active); } +.diff-row-actions { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 0; + overflow: hidden; + margin-left: 0; + opacity: 0; + pointer-events: none; + transform: translateX(6px); + transition: max-width 180ms ease, opacity 140ms ease, transform 140ms ease, + margin-left 180ms ease; +} + +.diff-row:hover .diff-row-actions, +.diff-row:focus-within .diff-row-actions { + max-width: 96px; + margin-left: 6px; + opacity: 1; + pointer-events: auto; + overflow: visible; + transform: translateX(0); +} + +.diff-row-action { + width: 24px; + height: 24px; + border-radius: 6px; + padding: 0; + border: 1px solid transparent; + background: transparent; + color: var(--text-faint); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; + position: relative; +} + +.diff-row:hover .diff-row-action, +.diff-row:focus-within .diff-row-action { + color: var(--text-muted); +} + +.diff-row-action:hover { + background: var(--surface-control-hover); + border-color: var(--border-subtle); + color: var(--text-emphasis); + transform: none; + box-shadow: none; +} + +.diff-row-action:focus-visible { + outline: 2px solid var(--border-accent-soft); + outline-offset: 2px; +} + +.diff-row-action--stage { + color: inherit; + border-color: transparent; +} + +.diff-row-action--stage:hover { + background: rgba(71, 212, 136, 0.14); + border-color: rgba(71, 212, 136, 0.35); + color: #47d488; +} + +.diff-row-action--unstage { + color: inherit; + border-color: transparent; +} + +.diff-row-action--unstage:hover { + background: rgba(245, 195, 99, 0.14); + border-color: rgba(245, 195, 99, 0.35); + color: #f5c363; +} + +.diff-row-action--discard { + color: inherit; + border-color: transparent; +} + +.diff-row-action--discard:hover { + background: rgba(255, 107, 107, 0.14); + border-color: rgba(255, 107, 107, 0.35); + color: #ff6b6b; +} + +.diff-row-action--apply:hover { + background: rgba(90, 169, 255, 0.14); + border-color: rgba(90, 169, 255, 0.35); + color: #5aa9ff; +} + +.diff-row-action[data-tooltip]::before, +.diff-row-action[data-tooltip]::after { + opacity: 0; + pointer-events: none; + transition: opacity 150ms ease, transform 150ms ease; + transform: translateY(4px); + z-index: 10; +} + +.diff-row-action[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%) translateY(4px); + padding: 4px 8px; + border-radius: 8px; + background: var(--surface-command); + color: var(--text-emphasis); + font-size: 10px; + line-height: 1.2; + white-space: nowrap; + border: 1px solid var(--border-subtle); + box-shadow: 0 14px 24px rgba(0, 0, 0, 0.22); +} + +.diff-row-action[data-tooltip]::before { + content: ""; + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%) translateY(4px) rotate(45deg); + width: 8px; + height: 8px; + background: var(--surface-command); + border-left: 1px solid var(--border-subtle); + border-top: 1px solid var(--border-subtle); +} + +.diff-row-action:last-child[data-tooltip]::after { + left: auto; + right: 0; + transform: translateX(0) translateY(4px); +} + +.diff-row-action:last-child[data-tooltip]::before { + left: auto; + right: 8px; + transform: translateX(0) translateY(4px) rotate(45deg); +} + +.diff-row-action:hover::before, +.diff-row-action:hover::after, +.diff-row-action:focus-visible::before, +.diff-row-action:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.diff-row-action:hover::before, +.diff-row-action:focus-visible::before { + transform: translateX(-50%) translateY(0) rotate(45deg); +} + +.diff-row-action:last-child:hover::after, +.diff-row-action:last-child:focus-visible::after { + transform: translateX(0) translateY(0); +} + +.diff-row-action:last-child:hover::before, +.diff-row-action:last-child:focus-visible::before { + transform: translateX(0) translateY(0) rotate(45deg); +} + .diff-icon { width: 16px; height: 16px; @@ -350,6 +534,7 @@ border: 1px solid transparent; line-height: 1; padding-bottom: 2px; + grid-column: 1; } .diff-icon-added { @@ -383,14 +568,13 @@ display: flex; flex-direction: column; gap: 2px; - flex: 1; min-width: 0; + grid-column: 2; } .diff-path { display: flex; - align-items: center; - justify-content: space-between; + align-items: baseline; gap: 8px; font-size: 11px; color: var(--text-emphasis); @@ -417,10 +601,21 @@ .diff-counts-inline { font-size: 10px; - color: var(--text-faint); white-space: nowrap; display: inline-flex; - gap: 2px; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border-muted); + background: var(--surface-control); + font-family: "SF Mono", Menlo, monospace; + font-variant-numeric: tabular-nums; +} + +.diff-row:hover .diff-counts-inline { + border-color: var(--border-subtle); + background: var(--surface-control-hover); } .diff-dir { @@ -443,6 +638,18 @@ color: var(--text-dim); } +:root[data-theme="light"] .diff-add { + color: var(--status-success); +} + +:root[data-theme="light"] .diff-del { + color: var(--status-error); +} + +:root[data-theme="light"] .diff-row:hover .diff-sep { + color: var(--text-faint); +} + .git-log-list { display: flex; flex-direction: column; From 0897a7cfa4aa91c3090787d3ad219fc72bddc1c4 Mon Sep 17 00:00:00 2001 From: Amogh Rijal Date: Wed, 21 Jan 2026 22:10:48 +0545 Subject: [PATCH 2/2] fix(git): restore revert-all control --- .../git/components/GitDiffPanel.test.tsx | 17 +++++++++++++++++ src/features/git/components/GitDiffPanel.tsx | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 3fd7145fb..a90eca946 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -69,4 +69,21 @@ describe("GitDiffPanel", () => { expect(onCommit).toHaveBeenCalledTimes(1); }); + it("exposes revert-all action from the header", () => { + const onRevertAllChanges = vi.fn(); + render( + , + ); + + const revertAllButton = screen.getByRole("button", { name: "Revert all changes" }); + fireEvent.click(revertAllButton); + expect(onRevertAllChanges).toHaveBeenCalledTimes(1); + }); + }); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 39ef1a19f..d3dd4cfba 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -566,6 +566,7 @@ export function GitDiffPanel({ worktreeApplyError = null, worktreeApplySuccess = false, onApplyWorktreeChanges, + onRevertAllChanges, branchName, totalAdditions, totalDeletions, @@ -961,6 +962,8 @@ export function GitDiffPanel({ const hasAnyChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; const showApplyWorktree = mode === "diff" && Boolean(onApplyWorktreeChanges) && hasAnyChanges; + const showRevertAll = + mode === "diff" && Boolean(onRevertAllChanges) && hasAnyChanges; const canGenerateCommitMessage = hasAnyChanges; const showGenerateCommitMessage = mode === "diff" && Boolean(onGenerateCommitMessage) && hasAnyChanges; @@ -1006,6 +1009,19 @@ export function GitDiffPanel({ {worktreeApplyIcon} )} + {showRevertAll && ( + + )} {mode === "diff" ? (