From db74a4b0f6020623a21e93bc9dc7f4290aeaf985 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 10:31:44 -0500 Subject: [PATCH 01/18] Add backend line-selection stage/unstage command plumbing --- src-tauri/src/bin/codex_monitor_daemon.rs | 24 +- .../src/bin/codex_monitor_daemon/rpc/git.rs | 32 ++ src-tauri/src/git/mod.rs | 30 +- src-tauri/src/lib.rs | 1 + src-tauri/src/shared/git_ui_core.rs | 14 +- src-tauri/src/shared/git_ui_core/commands.rs | 340 +++++++++++++++++- src-tauri/src/types.rs | 21 ++ 7 files changed, 456 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 59bfc00bc..7863e2f36 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -87,8 +87,9 @@ use shared::{ use storage::{read_settings, read_workspaces}; use types::{ AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, LocalUsageSnapshot, - WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, WorktreeSetupStatus, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, LocalUsageSnapshot, WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, + WorktreeSetupStatus, }; use workspace_settings::apply_workspace_settings_update; @@ -1087,6 +1088,25 @@ impl DaemonState { git_ui_core::stage_git_all_core(&self.workspaces, workspace_id).await } + async fn stage_git_selection( + &self, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, + ) -> Result { + git_ui_core::stage_git_selection_core( + &self.workspaces, + workspace_id, + path, + op, + source, + lines, + ) + .await + } + async fn unstage_git_file(&self, workspace_id: String, path: String) -> Result<(), String> { git_ui_core::unstage_git_file_core(&self.workspaces, workspace_id, path).await } diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs index 2637b7e0d..fda783eaf 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -1,5 +1,6 @@ use super::*; use crate::shared::git_rpc; +use crate::types::GitSelectionLine; use serde::de::DeserializeOwned; use serde::Serialize; use std::future::Future; @@ -102,6 +103,37 @@ pub(super) async fn try_handle( let request = parse_request_or_err!(params, git_rpc::WorkspaceIdRequest); Some(serialize_ok(state.stage_git_all(request.workspace_id)).await) } + "stage_git_selection" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let op = match parse_string(params, "op") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let source = match parse_string(params, "source") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let lines = match parse_optional_value(params, "lines") + .map(serde_json::from_value::>) + .transpose() + { + Ok(value) => value.unwrap_or_default(), + Err(err) => return Some(Err(format!("invalid `lines`: {err}"))), + }; + Some( + state + .stage_git_selection(workspace_id, path, op, source, lines) + .await + .and_then(|result| serde_json::to_value(result).map_err(|err| err.to_string())), + ) + } git_rpc::METHOD_UNSTAGE_GIT_FILE => { let request = parse_request_or_err!(params, git_rpc::WorkspacePathRequest); Some(serialize_ok(state.unstage_git_file(request.workspace_id, request.path)).await) diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index b4321a5d5..c5bdba461 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -8,7 +8,8 @@ use crate::shared::{git_rpc, git_ui_core}; use crate::state::AppState; use crate::types::{ GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, }; fn git_remote_params(request: &T) -> Result { @@ -187,6 +188,33 @@ pub(crate) async fn stage_git_all( git_ui_core::stage_git_all_core(&state.workspaces, workspace_id).await } +#[tauri::command] +pub(crate) async fn stage_git_selection( + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + try_remote_typed!( + state, + app, + "stage_git_selection", + json!({ + "workspaceId": &workspace_id, + "path": &path, + "op": &op, + "source": &source, + "lines": &lines + }), + GitSelectionApplyResult + ); + git_ui_core::stage_git_selection_core(&state.workspaces, workspace_id, path, op, source, lines) + .await +} + #[tauri::command] pub(crate) async fn unstage_git_file( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83e9dacae..021ed8c2d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -240,6 +240,7 @@ pub fn run() { git::get_git_remote, git::stage_git_file, git::stage_git_all, + git::stage_git_selection, git::unstage_git_file, git::revert_git_file, git::revert_git_all, diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index 2697484de..8911afef1 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -6,7 +6,8 @@ use tokio::sync::Mutex; use crate::types::{ AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, WorkspaceEntry, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, WorkspaceEntry, }; #[path = "git_ui_core/commands.rs"] @@ -116,6 +117,17 @@ pub(crate) async fn stage_git_all_core( commands::stage_git_all_inner(workspaces, workspace_id).await } +pub(crate) async fn stage_git_selection_core( + workspaces: &Mutex>, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, +) -> Result { + commands::stage_git_selection_inner(workspaces, workspace_id, path, op, source, lines).await +} + pub(crate) async fn unstage_git_file_core( workspaces: &Mutex>, workspace_id: String, diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs index 90400d74b..9c6bc23dc 100644 --- a/src-tauri/src/shared/git_ui_core/commands.rs +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -1,16 +1,19 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Stdio; use git2::{BranchType, Repository, Status, StatusOptions}; use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; use crate::git_utils::{ checkout_branch, list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, }; +use crate::shared::git_core; use crate::shared::process_core::tokio_command; -use crate::types::{BranchInfo, WorkspaceEntry}; +use crate::types::{BranchInfo, GitSelectionApplyResult, GitSelectionLine, WorkspaceEntry}; use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; use super::context::workspace_entry_for_id; @@ -397,6 +400,339 @@ async fn pull_with_default_strategy(repo_root: &Path) -> Result<(), String> { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum SelectionLineType { + Add, + Del, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SelectionLineKey { + line_type: SelectionLineType, + old_line: Option, + new_line: Option, + text: String, +} + +impl TryFrom<&GitSelectionLine> for SelectionLineKey { + type Error = String; + + fn try_from(value: &GitSelectionLine) -> Result { + let line_type = match value.line_type.as_str() { + "add" => SelectionLineType::Add, + "del" => SelectionLineType::Del, + _ => { + return Err(format!( + "Unsupported selection line type `{}`. Expected `add` or `del`.", + value.line_type + )); + } + }; + if line_type == SelectionLineType::Add && value.new_line.is_none() { + return Err("Selected `add` line is missing `newLine`.".to_string()); + } + if line_type == SelectionLineType::Del && value.old_line.is_none() { + return Err("Selected `del` line is missing `oldLine`.".to_string()); + } + Ok(Self { + line_type, + old_line: value.old_line, + new_line: value.new_line, + text: value.text.clone(), + }) + } +} + +#[derive(Debug, Clone)] +struct ParsedPatchLine { + line_type: SelectionLineType, + old_line: Option, + new_line: Option, + old_anchor: usize, + new_anchor: usize, + text: String, +} + +#[derive(Debug, Clone)] +struct ParsedPatchHunk { + lines: Vec, +} + +#[derive(Debug, Clone)] +struct ParsedPatch { + headers: Vec, + hunks: Vec, +} + +fn parse_hunk_range(raw: &str) -> Option<(usize, usize)> { + if let Some((start, count)) = raw.split_once(',') { + Some((start.parse().ok()?, count.parse().ok()?)) + } else { + Some((raw.parse().ok()?, 1)) + } +} + +fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { + let suffix = line.strip_prefix("@@ -")?; + let (old_range_raw, rest) = suffix.split_once(" +")?; + let marker_index = rest.find(" @@")?; + let new_range_raw = &rest[..marker_index]; + let (old_start, old_count) = parse_hunk_range(old_range_raw)?; + let (new_start, new_count) = parse_hunk_range(new_range_raw)?; + Some((old_start, old_count, new_start, new_count)) +} + +fn parse_zero_context_patch(diff_patch: &str) -> Result { + let lines: Vec<&str> = diff_patch.lines().collect(); + if lines.is_empty() { + return Err("No patch content to apply.".to_string()); + } + + let mut headers = Vec::new(); + let mut hunks = Vec::new(); + let mut index = 0usize; + + while index < lines.len() { + let line = lines[index]; + if let Some((old_start, _old_count, new_start, _new_count)) = parse_hunk_header(line) { + let mut old_cursor = old_start; + let mut new_cursor = new_start; + let mut parsed_lines = Vec::new(); + let mut inner_index = index + 1; + while inner_index < lines.len() { + let body_line = lines[inner_index]; + if parse_hunk_header(body_line).is_some() || body_line.starts_with("diff --git ") { + break; + } + + if let Some(text) = body_line.strip_prefix('+') { + if !body_line.starts_with("+++") { + parsed_lines.push(ParsedPatchLine { + line_type: SelectionLineType::Add, + old_line: None, + new_line: Some(new_cursor), + old_anchor: old_cursor, + new_anchor: new_cursor, + text: text.to_string(), + }); + new_cursor += 1; + } + } else if let Some(text) = body_line.strip_prefix('-') { + if !body_line.starts_with("---") { + parsed_lines.push(ParsedPatchLine { + line_type: SelectionLineType::Del, + old_line: Some(old_cursor), + new_line: None, + old_anchor: old_cursor, + new_anchor: new_cursor, + text: text.to_string(), + }); + old_cursor += 1; + } + } else if body_line.starts_with(' ') { + old_cursor += 1; + new_cursor += 1; + } + inner_index += 1; + } + if !parsed_lines.is_empty() { + hunks.push(ParsedPatchHunk { lines: parsed_lines }); + } + index = inner_index; + continue; + } + + if hunks.is_empty() { + headers.push(line.to_string()); + } + index += 1; + } + + if headers.is_empty() || hunks.is_empty() { + return Err("Could not parse diff hunks for line selection.".to_string()); + } + + Ok(ParsedPatch { headers, hunks }) +} + +fn build_selected_patch( + diff_patch: &str, + selected_lines: &HashSet, +) -> Result<(String, usize), String> { + let parsed = parse_zero_context_patch(diff_patch)?; + let mut output = parsed.headers.clone(); + let mut applied_line_count = 0usize; + + for hunk in &parsed.hunks { + let mut group: Vec<&ParsedPatchLine> = Vec::new(); + let flush_group = |group: &mut Vec<&ParsedPatchLine>, output: &mut Vec| { + if group.is_empty() { + return; + } + let first = group[0]; + let old_count = group + .iter() + .filter(|line| line.line_type == SelectionLineType::Del) + .count(); + let new_count = group + .iter() + .filter(|line| line.line_type == SelectionLineType::Add) + .count(); + output.push(format!( + "@@ -{},{} +{},{} @@", + first.old_anchor, old_count, first.new_anchor, new_count + )); + for line in group.iter() { + let prefix = if line.line_type == SelectionLineType::Add { + '+' + } else { + '-' + }; + output.push(format!("{prefix}{}", line.text)); + } + group.clear(); + }; + + for line in &hunk.lines { + let key = SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }; + if selected_lines.contains(&key) { + group.push(line); + applied_line_count += 1; + } else { + flush_group(&mut group, &mut output); + } + } + flush_group(&mut group, &mut output); + } + + if applied_line_count == 0 { + return Err("Selected lines do not match the current diff. Refresh and try again.".to_string()); + } + + let mut patch = output.join("\n"); + if !patch.ends_with('\n') { + patch.push('\n'); + } + Ok((patch, applied_line_count)) +} + +async fn apply_cached_patch(repo_root: &Path, patch: &str, reverse: bool) -> Result<(), String> { + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let mut args = vec![ + "apply", + "--cached", + "--unidiff-zero", + "--whitespace=nowarn", + ]; + if reverse { + args.push("--reverse"); + } + args.push("-"); + + let mut child = tokio_command(git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to run git: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(patch.as_bytes()) + .await + .map_err(|e| format!("Failed to write git apply input: {e}"))?; + } + + let output = child + .wait_with_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 apply failed.".to_string()); + } + Err(detail.to_string()) +} + +pub(super) async fn stage_git_selection_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, +) -> Result { + if lines.is_empty() { + return Err("No selected lines provided.".to_string()); + } + + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let action_paths = action_paths_for_file(&repo_root, &path); + if action_paths.len() != 1 { + return Err("Line-level stage/unstage for renamed paths is not supported yet.".to_string()); + } + let action_path = action_paths[0].clone(); + + let (diff_args, reverse_apply): (&[&str], bool) = match (op.as_str(), source.as_str()) { + ("stage", "unstaged") => (&["diff", "--no-color", "-U0", "--"], false), + ("unstage", "staged") => (&["diff", "--cached", "--no-color", "-U0", "--"], true), + ("stage", "staged") => { + return Err("Staging selected lines requires source `unstaged`.".to_string()); + } + ("unstage", "unstaged") => { + return Err("Unstaging selected lines requires source `staged`.".to_string()); + } + _ => { + return Err("Invalid stage selection request. Expected op/source to be stage+unstaged or unstage+staged.".to_string()); + } + }; + + let mut args = diff_args.to_vec(); + args.push(action_path.as_str()); + let source_patch = String::from_utf8_lossy(&git_core::run_git_diff( + &repo_root.to_path_buf(), + &args, + ) + .await?) + .to_string(); + if source_patch.trim().is_empty() { + return Err("No changes available for the requested selection source.".to_string()); + } + + let mut selected_lines = HashSet::new(); + for line in &lines { + selected_lines.insert(SelectionLineKey::try_from(line)?); + } + + let (selected_patch, applied_line_count) = build_selected_patch(&source_patch, &selected_lines)?; + apply_cached_patch(&repo_root, &selected_patch, reverse_apply).await?; + + Ok(GitSelectionApplyResult { + applied: true, + applied_line_count, + warning: None, + }) +} + pub(super) async fn stage_git_file_inner( workspaces: &Mutex>, workspace_id: String, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..db4c774c3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -8,6 +8,27 @@ pub(crate) struct GitFileStatus { pub(crate) deletions: i64, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitSelectionLine { + #[serde(rename = "type")] + pub(crate) line_type: String, + #[serde(default, rename = "oldLine")] + pub(crate) old_line: Option, + #[serde(default, rename = "newLine")] + pub(crate) new_line: Option, + pub(crate) text: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitSelectionApplyResult { + pub(crate) applied: bool, + pub(crate) applied_line_count: usize, + #[serde(default)] + pub(crate) warning: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct GitFileDiff { pub(crate) path: String, From 7f3b3cdf58fadd5e4762e03c4b340201c4b1ae47 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 11:31:38 -0500 Subject: [PATCH 02/18] Add frontend git line-selection stage action plumbing --- src/features/git/hooks/useGitActions.ts | 39 ++++++++++++++++++++++++- src/services/tauri.ts | 12 ++++++++ src/types.ts | 13 +++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 63863dcd7..39494ba0a 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -8,9 +8,14 @@ import { revertGitFile as revertGitFileService, stageGitAll as stageGitAllService, stageGitFile as stageGitFileService, + stageGitSelection as stageGitSelectionService, unstageGitFile as unstageGitFileService, } from "../../../services/tauri"; -import type { WorkspaceInfo } from "../../../types"; +import type { + GitSelectionApplyResult, + GitSelectionLine, + WorkspaceInfo, +} from "../../../types"; type UseGitActionsOptions = { activeWorkspace: WorkspaceInfo | null; @@ -95,6 +100,37 @@ export function useGitActions({ } }, [onError, refreshGitData, workspaceId]); + const stageGitSelection = useCallback( + async (options: { + path: string; + op: "stage" | "unstage"; + source: "unstaged" | "staged"; + lines: GitSelectionLine[]; + }): Promise => { + if (!workspaceId) { + return null; + } + const actionWorkspaceId = workspaceId; + try { + return await stageGitSelectionService( + actionWorkspaceId, + options.path, + options.op, + options.source, + options.lines, + ); + } catch (error) { + onError?.(error); + return null; + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } + }, + [onError, refreshGitData, workspaceId], + ); + const unstageGitFile = useCallback( async (path: string) => { if (!workspaceId) { @@ -327,6 +363,7 @@ export function useGitActions({ revertGitFile, stageGitAll, stageGitFile, + stageGitSelection, unstageGitFile, worktreeApplyError, worktreeApplyLoading, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 029e0d31b..830dc6282 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -26,6 +26,8 @@ import type { GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, + GitSelectionApplyResult, + GitSelectionLine, ReviewTarget, } from "../types"; @@ -642,6 +644,16 @@ export async function stageGitAll(workspaceId: string): Promise { return invoke("stage_git_all", { workspaceId }); } +export async function stageGitSelection( + workspaceId: string, + path: string, + op: "stage" | "unstage", + source: "unstaged" | "staged", + lines: GitSelectionLine[], +): Promise { + return invoke("stage_git_selection", { workspaceId, path, op, source, lines }); +} + export async function unstageGitFile(workspaceId: string, path: string) { return invoke("unstage_git_file", { workspaceId, path }); } diff --git a/src/types.ts b/src/types.ts index 51b1515c9..dd2874516 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,6 +432,19 @@ export type GitFileStatus = { deletions: number; }; +export type GitSelectionLine = { + type: "add" | "del"; + oldLine: number | null; + newLine: number | null; + text: string; +}; + +export type GitSelectionApplyResult = { + applied: boolean; + appliedLineCount: number; + warning?: string | null; +}; + export type GitFileDiff = { path: string; diff: string; From 61289fc57fd5e181892e95ec5e189f1854ab8001 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 12:17:41 -0500 Subject: [PATCH 03/18] Add line-level stage/unstage actions to diff viewer --- src/features/app/hooks/useMainAppGitState.ts | 2 + .../app/hooks/useMainAppLayoutSurfaces.ts | 4 + src/features/git/components/GitDiffViewer.tsx | 108 +++++++++++++++- .../git/components/GitDiffViewer.types.ts | 12 ++ src/styles/diff-viewer.css | 120 ++++++++++++++++++ 5 files changed, 242 insertions(+), 4 deletions(-) diff --git a/src/features/app/hooks/useMainAppGitState.ts b/src/features/app/hooks/useMainAppGitState.ts index b0088dca9..c2b17ab78 100644 --- a/src/features/app/hooks/useMainAppGitState.ts +++ b/src/features/app/hooks/useMainAppGitState.ts @@ -338,6 +338,7 @@ export function useMainAppGitState({ revertGitFile: handleRevertGitFile, stageGitAll: handleStageGitAll, stageGitFile: handleStageGitFile, + stageGitSelection: handleStageGitSelection, unstageGitFile: handleUnstageGitFile, worktreeApplyError, worktreeApplyLoading, @@ -505,6 +506,7 @@ export function useMainAppGitState({ handleRevertGitFile, handleStageGitAll, handleStageGitFile, + handleStageGitSelection, handleUnstageGitFile, worktreeApplyError, worktreeApplyLoading, diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index b6ac05279..9bcc57c79 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -840,6 +840,7 @@ function buildGitSurface({ scrollRequestId: gitState.diffScrollRequestId, isLoading: gitState.activeDiffLoading, error: gitState.activeDiffError, + diffSource: gitState.diffSource, ignoreWhitespaceChanges: appSettings.gitDiffIgnoreWhitespaceChanges && gitState.diffSource !== "pr", pullRequest: gitState.diffSource === "pr" ? gitState.selectedPullRequest : null, @@ -855,6 +856,9 @@ function buildGitSurface({ gitState.handleCheckoutPullRequest(pullRequest.number), canRevert: gitState.diffSource === "local", onRevertFile: gitState.handleRevertGitFile, + stagedPaths: gitState.gitStatus.stagedFiles.map((file) => file.path), + unstagedPaths: gitState.gitStatus.unstagedFiles.map((file) => file.path), + onStageSelection: gitState.handleStageGitSelection, onActivePathChange: gitState.handleActiveDiffPath, onInsertComposerText: composerWorkspaceState.canInsertComposerText ? composerWorkspaceState.handleInsertComposerText diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index ff152e58c..3585750d0 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -8,6 +8,7 @@ import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import type { ParsedDiffLine } from "../../../utils/diff"; import { workerFactory } from "../../../utils/diffsWorker"; import type { + GitSelectionLine, PullRequestReviewIntent, PullRequestSelectionRange, } from "../../../types"; @@ -124,12 +125,19 @@ function buildSelectionRangeFromLineSelection({ }; } +type LocalLineAction = { + op: "stage" | "unstage"; + source: "unstaged" | "staged"; + disabledReason?: string; +}; + export function GitDiffViewer({ diffs, selectedPath, scrollRequestId, isLoading, error, + diffSource = "local", diffStyle = "split", ignoreWhitespaceChanges = false, pullRequest, @@ -143,6 +151,9 @@ export function GitDiffViewer({ onCheckoutPullRequest, canRevert = false, onRevertFile, + stagedPaths = [], + unstagedPaths = [], + onStageSelection, onActivePathChange, onInsertComposerText, }: GitDiffViewerProps) { @@ -166,6 +177,7 @@ export function GitDiffViewer({ path: string; range: SelectedLineRange; } | null>(null); + const [lineActionBusy, setLineActionBusy] = useState(false); const clearSelection = useCallback(() => { setLineSelection(null); @@ -201,6 +213,8 @@ export function GitDiffViewer({ () => DIFF_VIEWER_HIGHLIGHTER_OPTIONS, [], ); + const stagedPathSet = useMemo(() => new Set(stagedPaths), [stagedPaths]); + const unstagedPathSet = useMemo(() => new Set(unstagedPaths), [unstagedPaths]); const indexByPath = useMemo(() => { const map = new Map(); @@ -271,6 +285,82 @@ export function GitDiffViewer({ const showRevert = canRevert && Boolean(onRevertFile); + const resolveLocalLineAction = useCallback( + (entry: GitDiffViewerItem): LocalLineAction | null => { + if (diffSource !== "local" || !onStageSelection) { + return null; + } + if (entry.status === "R") { + return { + op: "stage", + source: "unstaged", + disabledReason: "Line-level stage/unstage is not supported for renamed files.", + }; + } + const path = entry.path; + const hasStaged = stagedPathSet.has(path); + const hasUnstaged = unstagedPathSet.has(path); + if (!hasStaged && !hasUnstaged) { + return null; + } + if (hasStaged && hasUnstaged) { + return { + op: "stage", + source: "unstaged", + disabledReason: + "Line-level stage/unstage is unavailable when a file has both staged and unstaged edits.", + }; + } + if (hasUnstaged) { + return { + op: "stage", + source: "unstaged", + }; + } + return { + op: "unstage", + source: "staged", + }; + }, + [diffSource, onStageSelection, stagedPathSet, unstagedPathSet], + ); + + const handleApplyLineAction = useCallback( + async (path: string, action: LocalLineAction, line: ParsedDiffLine) => { + if (!onStageSelection || action.disabledReason || lineActionBusy) { + return; + } + if (line.type !== "add" && line.type !== "del") { + return; + } + if ( + (line.type === "add" && line.newLine === null) || + (line.type === "del" && line.oldLine === null) + ) { + return; + } + setLineActionBusy(true); + try { + await onStageSelection({ + path, + op: action.op, + source: action.source, + lines: [ + { + type: line.type, + oldLine: line.oldLine, + newLine: line.newLine, + text: line.text, + } satisfies GitSelectionLine, + ], + }); + } finally { + setLineActionBusy(false); + } + }, + [lineActionBusy, onStageSelection], + ); + const handleInsertLineReference = useCallback( (entry: GitDiffViewerItem, line: ParsedDiffLine, index: number) => { if (!onInsertComposerText) { @@ -554,6 +644,9 @@ export function GitDiffViewer({ > {virtualItems.map((virtualRow) => { const entry = diffs[virtualRow.index]; + const localLineAction = resolveLocalLineAction(entry); + const shouldHandleStageSelection = + Boolean(localLineAction) && !localLineAction?.disabledReason; return (
{ - handleInsertLineReference(entry, line, index); + shouldHandleStageSelection + ? (line) => { + if (!localLineAction) { + return; + } + void handleApplyLineAction(entry.path, localLineAction, line); } - : undefined + : onInsertComposerText + ? (line, index) => { + handleInsertLineReference(entry, line, index); + } + : undefined } reviewActions={pullRequestReviewActions} onRunReviewAction={(intent, parsedLines, selectedLines) => { diff --git a/src/features/git/components/GitDiffViewer.types.ts b/src/features/git/components/GitDiffViewer.types.ts index 199eaa458..2dba044ac 100644 --- a/src/features/git/components/GitDiffViewer.types.ts +++ b/src/features/git/components/GitDiffViewer.types.ts @@ -1,10 +1,13 @@ import type { + GitSelectionApplyResult, + GitSelectionLine, GitHubPullRequest, GitHubPullRequestComment, PullRequestReviewAction, PullRequestReviewIntent, PullRequestSelectionRange, } from "../../../types"; +import type { GitDiffSource } from "../types"; export type GitDiffViewerItem = { path: string; @@ -31,6 +34,7 @@ export type GitDiffViewerProps = { scrollRequestId?: number; isLoading: boolean; error: string | null; + diffSource?: GitDiffSource; diffStyle?: "split" | "unified"; ignoreWhitespaceChanges?: boolean; pullRequest?: GitHubPullRequest | null; @@ -51,6 +55,14 @@ export type GitDiffViewerProps = { ) => Promise | void; canRevert?: boolean; onRevertFile?: (path: string) => Promise | void; + stagedPaths?: string[]; + unstagedPaths?: string[]; + onStageSelection?: (options: { + path: string; + op: "stage" | "unstage"; + source: "unstaged" | "staged"; + lines: GitSelectionLine[]; + }) => Promise; onActivePathChange?: (path: string) => void; onInsertComposerText?: (text: string) => void; }; diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 1be9e5bda..4299d537e 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -509,6 +509,38 @@ background: transparent; } +.diff-split-block { + display: flex; + flex-direction: column; +} + +.diff-split-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.diff-split-cell { + display: grid; + grid-template-columns: 38px 1fr; + align-items: start; + gap: 10px; + min-width: 0; + position: relative; +} + +.diff-split-cell-empty { + opacity: 0.45; +} + +.diff-split-meta { + padding: 3px 10px; + color: var(--text-subtle); + font-size: var(--code-font-size, 11px); + font-family: var(--code-font-family); + white-space: pre-wrap; +} + .diff-line { display: grid; grid-template-columns: 64px 1fr; @@ -517,6 +549,7 @@ padding: 2px 6px 2px 4px; border-radius: 0; white-space: pre-wrap; + position: relative; } .diff-line.has-line-action { @@ -584,6 +617,10 @@ font-variant-numeric: tabular-nums; } +.diff-gutter-single { + grid-template-columns: 1fr; +} + .diff-line-number { min-width: 0; } @@ -773,6 +810,89 @@ color: #7fd1ff; } +.diff-line-action { + position: absolute; + top: 2px; + right: 6px; + z-index: 2; + min-width: 46px; + height: 20px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-control) 92%, transparent); + color: var(--text-subtle); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + opacity: 0; + transform: translateY(-2px); + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.diff-line-action--add { + color: #47d488; + border-color: rgba(71, 212, 136, 0.38); +} + +.diff-line-action--del { + color: #ff8f8f; + border-color: rgba(255, 143, 143, 0.38); +} + +.diff-line-action:disabled { + opacity: 0.45; + pointer-events: none; +} + +.diff-line:hover .diff-line-action, +.diff-line:focus-within .diff-line-action { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.diff-line .diff-line-action:disabled { + opacity: 0.45; + pointer-events: none; +} + +.diff-viewer-line-action-hint { + margin-top: 6px; + padding: 0 8px; + font-size: 11px; + color: var(--text-faint); +} + +@media (hover: none), (pointer: coarse) { + .diff-line-action { + opacity: 1; + transform: none; + pointer-events: auto; + height: 24px; + min-width: 54px; + } + + .diff-line, + .diff-split-cell { + padding-right: 62px; + } +} + +.app.layout-phone .diff-line-action { + opacity: 1; + transform: none; + pointer-events: auto; + min-width: 60px; + height: 28px; + font-size: 11px; +} + +.app.layout-phone .diff-line, +.app.layout-phone .diff-split-cell { + padding-right: 68px; +} + .diff-viewer-placeholder, .diff-viewer-empty { color: var(--text-subtle); From 6f0e7ce40004c308aa7a06fa53bd56a2c7eb58e8 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 12:18:00 -0500 Subject: [PATCH 04/18] Add tests for git line-selection stage wiring --- .../git/components/GitDiffViewer.test.tsx | 67 +++++++++++++++++++ src/services/tauri.test.ts | 34 ++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/features/git/components/GitDiffViewer.test.tsx b/src/features/git/components/GitDiffViewer.test.tsx index 871548afc..f0b8b1a3d 100644 --- a/src/features/git/components/GitDiffViewer.test.tsx +++ b/src/features/git/components/GitDiffViewer.test.tsx @@ -129,4 +129,71 @@ describe("GitDiffViewer", () => { expect(rawLines[1]?.className).toContain("diff-viewer-raw-line-add"); expect(rawLines[2]?.className).toContain("diff-viewer-raw-line-del"); }); + + it("invokes line-level stage action for local unstaged diffs", () => { + const onStageSelection = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Ask for changes on hovered line" }), + ); + + expect(onStageSelection).toHaveBeenCalledTimes(1); + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "new line", + }, + ], + }); + }); + + it("disables line-level actions when file has both staged and unstaged changes", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: "Ask for changes on hovered line" }), + ).toBeNull(); + }); }); diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index eb411228a..4d0131594 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -25,6 +25,7 @@ import { openWorkspaceIn, readAgentMd, stageGitAll, + stageGitSelection, respondToServerRequest, respondToUserInputRequest, sendUserMessage, @@ -199,6 +200,39 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps args for stage_git_selection", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ + applied: true, + appliedLineCount: 1, + warning: null, + }); + + await stageGitSelection("ws-1", "src/main.ts", "stage", "unstaged", [ + { + type: "add", + oldLine: null, + newLine: 7, + text: "const x = 1;", + }, + ]); + + expect(invokeMock).toHaveBeenCalledWith("stage_git_selection", { + workspaceId: "ws-1", + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 7, + text: "const x = 1;", + }, + ], + }); + }); + it("maps args for createGitHubRepo", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ status: "ok", repo: "me/repo" }); From f1f2a32539fa4d0d5cbe620dd3c2b69377dab715 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 13:02:30 -0500 Subject: [PATCH 05/18] Stabilize disabled line-action viewer test --- src/features/git/components/GitDiffViewer.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/git/components/GitDiffViewer.test.tsx b/src/features/git/components/GitDiffViewer.test.tsx index f0b8b1a3d..5b2d70ad1 100644 --- a/src/features/git/components/GitDiffViewer.test.tsx +++ b/src/features/git/components/GitDiffViewer.test.tsx @@ -172,6 +172,7 @@ describe("GitDiffViewer", () => { }); it("disables line-level actions when file has both staged and unstaged changes", () => { + const onStageSelection = vi.fn(); render( { diffSource="local" stagedPaths={["src/main.ts"]} unstagedPaths={["src/main.ts"]} - onStageSelection={vi.fn()} + onStageSelection={onStageSelection} />, ); expect( screen.queryByRole("button", { name: "Ask for changes on hovered line" }), ).toBeNull(); + expect(onStageSelection).not.toHaveBeenCalled(); }); }); From d1b2d4f6770d27575e6438b0feb7adf9abb2a099 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 13:53:27 -0500 Subject: [PATCH 06/18] Avoid false unstaged status after line-level staging --- src-tauri/src/shared/git_ui_core/diff.rs | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 0c6af07e8..355d85291 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -123,6 +123,21 @@ fn status_for_delta(status: git2::Delta) -> &'static str { } } +fn has_unstaged_diff_with_git(repo_root: &Path, path: &str) -> Option { + let git_bin = resolve_git_binary().ok()?; + let output = std_command(git_bin) + .args(["diff", "--no-color", "-U0", "--", path]) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .ok()?; + if !(output.status.success() || output.status.code() == Some(1)) { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + Some(!stdout.trim().is_empty()) +} + fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { let mut current = path.parent(); while let Some(parent) = current { @@ -395,13 +410,25 @@ pub(super) async fn get_git_status_inner( | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE, ); - let include_workdir = status.intersects( + let mut include_workdir = status.intersects( Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE, ); + + // When the index is updated externally (for example via line-level staging), + // libgit2 can briefly report both staged and workdir status for a path. + // Verify actual unstaged diff content before keeping the workdir bucket. + if include_index && include_workdir { + if matches!( + has_unstaged_diff_with_git(&repo_root, normalized_path.as_str()), + Some(false) + ) { + include_workdir = false; + } + } let mut combined_additions = 0i64; let mut combined_deletions = 0i64; From 984cfb8a9e9b3d181e1d45f998533499223dfebb Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 14:20:12 -0500 Subject: [PATCH 07/18] Expose separate staged and unstaged diffs for mixed files --- src-tauri/src/shared/git_ui_core/diff.rs | 58 ++++++++++++++++++++++++ src-tauri/src/types.rs | 4 ++ src/features/git/hooks/useGitDiffs.ts | 2 + src/types.ts | 2 + 4 files changed, 66 insertions(+) diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 355d85291..7f7c76ff8 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -138,6 +138,37 @@ fn has_unstaged_diff_with_git(repo_root: &Path, path: &str) -> Option { Some(!stdout.trim().is_empty()) } +fn source_diff_for_path( + repo_root: &Path, + path: &str, + cached: bool, + ignore_whitespace_changes: bool, +) -> Option { + let git_bin = resolve_git_binary().ok()?; + let mut args = vec!["diff"]; + if cached { + args.push("--cached"); + } + args.push("--no-color"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(path); + + let output = std_command(git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .ok()?; + if !(output.status.success() || output.status.code() == Some(1)) { + return None; + } + + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { let mut current = path.parent(); while let Some(parent) = current { @@ -547,6 +578,29 @@ pub(super) async fn get_git_diffs_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; + let path_status = repo.status_file(display_path).unwrap_or(Status::empty()); + let has_staged = + path_status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE); + let has_unstaged = + path_status.intersects(Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE); + let (staged_diff, unstaged_diff) = if has_staged && has_unstaged { + ( + source_diff_for_path( + &repo_root, + normalized_path.as_str(), + true, + ignore_whitespace_changes, + ), + source_diff_for_path( + &repo_root, + normalized_path.as_str(), + false, + ignore_whitespace_changes, + ), + ) + } else { + (None, None) + }; let old_lines = if !is_added { head_tree @@ -596,6 +650,8 @@ pub(super) async fn get_git_diffs_inner( results.push(GitFileDiff { path: normalized_path, diff: String::new(), + staged_diff, + unstaged_diff, old_lines: None, new_lines: None, is_binary: true, @@ -625,6 +681,8 @@ pub(super) async fn get_git_diffs_inner( results.push(GitFileDiff { path: normalized_path, diff: content, + staged_diff, + unstaged_diff, old_lines, new_lines, is_binary: false, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index db4c774c3..dfcfa923b 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -33,6 +33,10 @@ pub(crate) struct GitSelectionApplyResult { pub(crate) struct GitFileDiff { pub(crate) path: String, pub(crate) diff: String, + #[serde(default, rename = "stagedDiff")] + pub(crate) staged_diff: Option, + #[serde(default, rename = "unstagedDiff")] + pub(crate) unstaged_diff: Option, #[serde(default, rename = "oldLines")] pub(crate) old_lines: Option>, #[serde(default, rename = "newLines")] diff --git a/src/features/git/hooks/useGitDiffs.ts b/src/features/git/hooks/useGitDiffs.ts index ff5b0a073..6cad7e87c 100644 --- a/src/features/git/hooks/useGitDiffs.ts +++ b/src/features/git/hooks/useGitDiffs.ts @@ -111,6 +111,8 @@ export function useGitDiffs( path: file.path, status: file.status, diff: entry?.diff ?? "", + stagedDiff: entry?.stagedDiff ?? null, + unstagedDiff: entry?.unstagedDiff ?? null, oldLines: entry?.oldLines, newLines: entry?.newLines, isImage: entry?.isImage, diff --git a/src/types.ts b/src/types.ts index dd2874516..48bca3537 100644 --- a/src/types.ts +++ b/src/types.ts @@ -448,6 +448,8 @@ export type GitSelectionApplyResult = { export type GitFileDiff = { path: string; diff: string; + stagedDiff?: string | null; + unstagedDiff?: string | null; oldLines?: string[]; newLines?: string[]; isBinary?: boolean; From f2d470fe4e9c66f1dd86021f805536b88b3376bc Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 14:20:34 -0500 Subject: [PATCH 08/18] Group line actions by hunk and support mixed stage/unstage UI --- src/styles/diff-viewer.css | 46 +++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 4299d537e..069588f24 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -755,6 +755,14 @@ background: rgba(248, 81, 73, 0.25); } +.diff-viewer-line-action-section--staged .diff-line-add { + background: rgba(46, 160, 67, 0.38); +} + +.diff-viewer-line-action-section--staged .diff-line-del { + background: rgba(248, 81, 73, 0.36); +} + .diff-line-meta { color: var(--text-faint); } @@ -827,7 +835,12 @@ opacity: 0; transform: translateY(-2px); pointer-events: none; - transition: opacity 120ms ease, transform 120ms ease, color 120ms ease, border-color 120ms ease; + transition: + opacity 120ms ease, + transform 120ms ease, + color 120ms ease, + border-color 120ms ease, + background 120ms ease; } .diff-line-action--add { @@ -840,6 +853,17 @@ border-color: rgba(255, 143, 143, 0.38); } +.diff-line-action--mixed { + color: var(--text-subtle); + border-color: var(--border-subtle); +} + +.diff-line-action--tone-unstage { + color: #ffd39f; + border-color: rgba(255, 211, 159, 0.5); + background: color-mix(in srgb, #6e4b22 24%, var(--surface-control)); +} + .diff-line-action:disabled { opacity: 0.45; pointer-events: none; @@ -857,6 +881,26 @@ pointer-events: none; } +.diff-viewer-line-action-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.diff-viewer-line-action-section + .diff-viewer-line-action-section { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); +} + +.diff-viewer-line-action-section-label { + padding: 0 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); +} + .diff-viewer-line-action-hint { margin-top: 6px; padding: 0 8px; From 309d804341979d55b3544fba9d828b40d53b7883 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Feb 2026 14:38:56 -0500 Subject: [PATCH 09/18] Polish diff action button alignment and hover reveal --- src/styles/diff-viewer.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 069588f24..21bb3e9ea 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -823,6 +823,9 @@ top: 2px; right: 6px; z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; min-width: 46px; height: 20px; border-radius: 999px; @@ -870,7 +873,9 @@ } .diff-line:hover .diff-line-action, -.diff-line:focus-within .diff-line-action { +.diff-line:focus-within .diff-line-action, +.diff-viewer-line-action-section:hover .diff-line-action, +.diff-viewer-line-action-section:focus-within .diff-line-action { opacity: 1; transform: translateY(0); pointer-events: auto; From dfd73045039cdb3f16e341ce6da48caeecede56c Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Mar 2026 10:37:07 -0400 Subject: [PATCH 10/18] Initial fixes after rebase --- package-lock.json | 282 +++++++++--------- src-tauri/src/git/mod.rs | 2 +- .../design-system/diff/diffViewerTheme.ts | 1 - src/styles/diff-viewer.css | 38 +-- 4 files changed, 148 insertions(+), 175 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ffa1ed73..68f57830c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1036,9 +1036,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1086,9 +1086,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1209,15 +1209,14 @@ } }, "node_modules/@pierre/diffs": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.6.tgz", - "integrity": "sha512-VoFvtGCSPi+wAj4Ap+0j80/KPnQbYZsvztjIP4nHsjCgcVUBF7+X1nHFqjl/xbTqSc0okZG1tHYb49CUON9ZXQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.1.tgz", + "integrity": "sha512-YJUUdYuRGN2r+rU/alMf9LEm5S6INDIOajxqJaiLpo1swDsebzsQ+UrNgfc1IspDPWkl0d62KWzt1IfUFqJBrg==", "license": "apache-2.0", "dependencies": { - "@shikijs/core": "^3.0.0", - "@shikijs/engine-javascript": "^3.0.0", + "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", - "diff": "8.0.2", + "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" @@ -1227,6 +1226,15 @@ "react-dom": "^18.3.1 || ^19.0.0" } }, + "node_modules/@pierre/theme": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-0.0.22.tgz", + "integrity": "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA==", + "license": "MIT", + "engines": { + "vscode": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1235,9 +1243,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1249,9 +1257,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1263,9 +1271,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1277,9 +1285,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1291,9 +1299,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1305,9 +1313,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1319,9 +1327,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1333,9 +1341,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1347,9 +1355,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1361,9 +1369,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1375,9 +1383,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1389,9 +1397,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1403,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1417,9 +1425,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1431,9 +1439,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1445,9 +1453,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1459,9 +1467,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1473,9 +1481,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1487,9 +1495,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1501,9 +1509,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1515,9 +1523,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1529,9 +1537,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1543,9 +1551,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1557,9 +1565,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1571,9 +1579,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2648,9 +2656,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3457,9 +3465,9 @@ } }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3910,9 +3918,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3977,9 +3985,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4227,9 +4235,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4436,9 +4444,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6471,13 +6479,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7278,9 +7286,9 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7294,31 +7302,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index c5bdba461..d07d29059 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,6 +1,6 @@ use serde::de::DeserializeOwned; use serde::Serialize; -use serde_json::Value; +use serde_json::{json, Value}; use tauri::{AppHandle, State}; use crate::remote_backend; diff --git a/src/features/design-system/diff/diffViewerTheme.ts b/src/features/design-system/diff/diffViewerTheme.ts index a3cff626b..0dbd83d41 100644 --- a/src/features/design-system/diff/diffViewerTheme.ts +++ b/src/features/design-system/diff/diffViewerTheme.ts @@ -1,7 +1,6 @@ export const DIFF_VIEWER_SCROLL_CSS = ` [data-column-number], [data-buffer], -[data-separator-wrapper], [data-annotation-content] { position: static !important; } diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 21bb3e9ea..5e0a475f6 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -509,38 +509,6 @@ background: transparent; } -.diff-split-block { - display: flex; - flex-direction: column; -} - -.diff-split-row { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; -} - -.diff-split-cell { - display: grid; - grid-template-columns: 38px 1fr; - align-items: start; - gap: 10px; - min-width: 0; - position: relative; -} - -.diff-split-cell-empty { - opacity: 0.45; -} - -.diff-split-meta { - padding: 3px 10px; - color: var(--text-subtle); - font-size: var(--code-font-size, 11px); - font-family: var(--code-font-family); - white-space: pre-wrap; -} - .diff-line { display: grid; grid-template-columns: 64px 1fr; @@ -922,8 +890,7 @@ min-width: 54px; } - .diff-line, - .diff-split-cell { + .diff-line { padding-right: 62px; } } @@ -937,8 +904,7 @@ font-size: 11px; } -.app.layout-phone .diff-line, -.app.layout-phone .diff-split-cell { +.app.layout-phone .diff-line { padding-right: 68px; } From 659890825be6acc7497678e4757e13e087cfb8c8 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Mar 2026 13:33:33 -0400 Subject: [PATCH 11/18] Revamps chunk based stage/unstage actions --- .../git/components/GitDiffViewer.test.tsx | 352 ++++++++- src/features/git/components/GitDiffViewer.tsx | 108 +-- .../git/components/GitDiffViewer.types.ts | 18 + .../git/components/GitDiffViewerDiffCard.tsx | 240 ++++--- .../git/components/LocalActionDiffBlock.tsx | 673 ++++++++++++++++++ .../git/components/PierreDiffBlock.tsx | 7 +- src/features/git/hooks/useGitActions.ts | 20 +- src/styles/diff-viewer.css | 183 ++--- 8 files changed, 1296 insertions(+), 305 deletions(-) create mode 100644 src/features/git/components/LocalActionDiffBlock.tsx diff --git a/src/features/git/components/GitDiffViewer.test.tsx b/src/features/git/components/GitDiffViewer.test.tsx index 5b2d70ad1..0ac534eb7 100644 --- a/src/features/git/components/GitDiffViewer.test.tsx +++ b/src/features/git/components/GitDiffViewer.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { GitDiffViewer } from "./GitDiffViewer"; @@ -130,7 +130,7 @@ describe("GitDiffViewer", () => { expect(rawLines[2]?.className).toContain("diff-viewer-raw-line-del"); }); - it("invokes line-level stage action for local unstaged diffs", () => { + it("invokes line-level stage action for local unstaged diffs", async () => { const onStageSelection = vi.fn(); render( { />, ); - fireEvent.click( - screen.getByRole("button", { name: "Ask for changes on hovered line" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Stage" })); - expect(onStageSelection).toHaveBeenCalledTimes(1); - expect(onStageSelection).toHaveBeenCalledWith({ - path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "new line", - }, - ], + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledTimes(1); + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "new line", + }, + ], + }); }); }); - it("disables line-level actions when file has both staged and unstaged changes", () => { + it("enables line-level stage actions in split view", async () => { const onStageSelection = vi.fn(); render( { selectedPath="src/main.ts" isLoading={false} error={null} - diffStyle="unified" + diffStyle="split" + diffSource="local" + unstagedPaths={["src/main.ts"]} + onStageSelection={onStageSelection} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledTimes(1); + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "new line", + }, + ], + }); + }); + }); + + it("keeps mixed files in-order and offers chunk-level stage/unstage actions", async () => { + const onStageSelection = vi.fn(); + render( + { />, ); - expect( - screen.queryByRole("button", { name: "Ask for changes on hovered line" }), - ).toBeNull(); - expect(onStageSelection).not.toHaveBeenCalled(); + expect(screen.queryByText("Staged changes")).toBeNull(); + expect(screen.queryByText("Unstaged changes")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "unstage", + source: "staged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "new staged line", + }, + ], + }); + }); + + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledTimes(1); + }); + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledTimes(2); + expect(onStageSelection).toHaveBeenLastCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 4, + text: "new unstaged line", + }, + ], + }); + }); + }); + + it("renders one hover target per changed chunk", async () => { + const onStageSelection = vi.fn(); + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(2); + + fireEvent.click(stageButtons[0]); + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "first addition", + }, + ], + }); + }); + + fireEvent.click(stageButtons[1]); + await waitFor(() => { + expect(onStageSelection).toHaveBeenLastCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 4, + text: "second addition", + }, + ], + }); + }); + }); + + it("stages a full contiguous changed chunk with one click", async () => { + const onStageSelection = vi.fn(); + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons.length).toBeGreaterThan(0); + + fireEvent.click(stageButtons[0]); + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "first addition", + }, + { + type: "add", + oldLine: null, + newLine: 3, + text: "second addition", + }, + { + type: "add", + oldLine: null, + newLine: 4, + text: "third addition", + }, + ], + }); + }); + }); + + it("maps mixed-file chunk line coordinates to source-specific diffs", async () => { + const onStageSelection = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + await waitFor(() => { + expect(onStageSelection).toHaveBeenLastCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "new unstaged line", + }, + ], + }); + }); + }); + + it("preserves empty added lines in mixed staged/unstaged chunk actions", async () => { + const onStageSelection = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); + await waitFor(() => { + expect(onStageSelection).toHaveBeenCalledWith({ + path: "src/main.ts", + op: "unstage", + source: "staged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 2, + text: "", + }, + ], + }); + }); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + await waitFor(() => { + expect(onStageSelection).toHaveBeenLastCalledWith({ + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 4, + text: "", + }, + { + type: "add", + oldLine: null, + newLine: 5, + text: "import c", + }, + ], + }); + }); }); }); diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 3585750d0..d7f71932d 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -22,6 +22,8 @@ import { PullRequestSummary } from "./GitDiffViewerPullRequestSummary"; import type { GitDiffViewerItem, GitDiffViewerProps, + LocalLineAction, + LocalLineActionContext, } from "./GitDiffViewer.types"; import { calculateDiffStats } from "./GitDiffViewer.utils"; @@ -125,12 +127,6 @@ function buildSelectionRangeFromLineSelection({ }; } -type LocalLineAction = { - op: "stage" | "unstage"; - source: "unstaged" | "staged"; - disabledReason?: string; -}; - export function GitDiffViewer({ diffs, selectedPath, @@ -285,58 +281,68 @@ export function GitDiffViewer({ const showRevert = canRevert && Boolean(onRevertFile); - const resolveLocalLineAction = useCallback( - (entry: GitDiffViewerItem): LocalLineAction | null => { + const resolveLocalLineActionContext = useCallback( + (entry: GitDiffViewerItem): LocalLineActionContext | null => { if (diffSource !== "local" || !onStageSelection) { return null; } - if (entry.status === "R") { - return { - op: "stage", - source: "unstaged", - disabledReason: "Line-level stage/unstage is not supported for renamed files.", - }; - } const path = entry.path; const hasStaged = stagedPathSet.has(path); const hasUnstaged = unstagedPathSet.has(path); if (!hasStaged && !hasUnstaged) { return null; } - if (hasStaged && hasUnstaged) { + if (entry.status === "R") { return { - op: "stage", - source: "unstaged", + hasStaged, + hasUnstaged, + stagedDiff: entry.stagedDiff, + unstagedDiff: entry.unstagedDiff, disabledReason: - "Line-level stage/unstage is unavailable when a file has both staged and unstaged edits.", + "Line-level stage/unstage is not supported for renamed files.", }; } - if (hasUnstaged) { + if ( + hasStaged && + hasUnstaged && + (!entry.stagedDiff?.trim() || !entry.unstagedDiff?.trim()) + ) { return { - op: "stage", - source: "unstaged", + hasStaged, + hasUnstaged, + stagedDiff: entry.stagedDiff, + unstagedDiff: entry.unstagedDiff, + disabledReason: + "Line-level stage/unstage is unavailable until staged and unstaged hunks finish loading.", }; } return { - op: "unstage", - source: "staged", + hasStaged, + hasUnstaged, + stagedDiff: entry.stagedDiff, + unstagedDiff: entry.unstagedDiff, }; }, [diffSource, onStageSelection, stagedPathSet, unstagedPathSet], ); const handleApplyLineAction = useCallback( - async (path: string, action: LocalLineAction, line: ParsedDiffLine) => { + async (path: string, action: LocalLineAction, lines: GitSelectionLine[]) => { if (!onStageSelection || action.disabledReason || lineActionBusy) { return; } - if (line.type !== "add" && line.type !== "del") { + if (!lines.length) { return; } - if ( - (line.type === "add" && line.newLine === null) || - (line.type === "del" && line.oldLine === null) - ) { + const normalizedLines = lines.filter( + (line) => + (line.type === "add" || line.type === "del") && + !( + (line.type === "add" && line.newLine === null) || + (line.type === "del" && line.oldLine === null) + ), + ); + if (!normalizedLines.length) { return; } setLineActionBusy(true); @@ -345,14 +351,7 @@ export function GitDiffViewer({ path, op: action.op, source: action.source, - lines: [ - { - type: line.type, - oldLine: line.oldLine, - newLine: line.newLine, - text: line.text, - } satisfies GitSelectionLine, - ], + lines: normalizedLines, }); } finally { setLineActionBusy(false); @@ -644,9 +643,10 @@ export function GitDiffViewer({ > {virtualItems.map((virtualRow) => { const entry = diffs[virtualRow.index]; - const localLineAction = resolveLocalLineAction(entry); - const shouldHandleStageSelection = - Boolean(localLineAction) && !localLineAction?.disabledReason; + const localLineActionContext = + resolveLocalLineActionContext(entry); + const hasLocalLineActions = Boolean(localLineActionContext); + const hasComposerLineActions = Boolean(onInsertComposerText); return (
{ setSelectedLinesForPath(entry.path, range); }} - onLineAction={ - shouldHandleStageSelection - ? (line) => { - if (!localLineAction) { - return; - } - void handleApplyLineAction(entry.path, localLineAction, line); + localLineActionContext={localLineActionContext} + lineActionBusy={lineActionBusy} + onLocalChunkAction={ + hasLocalLineActions + ? (lines, action) => { + void handleApplyLineAction(entry.path, action, lines); + } + : undefined + } + onComposerLineAction={ + !hasLocalLineActions && hasComposerLineActions + ? (line, index) => { + handleInsertLineReference(entry, line, index); } - : onInsertComposerText - ? (line, index) => { - handleInsertLineReference(entry, line, index); - } - : undefined + : undefined } reviewActions={pullRequestReviewActions} onRunReviewAction={(intent, parsedLines, selectedLines) => { diff --git a/src/features/git/components/GitDiffViewer.types.ts b/src/features/git/components/GitDiffViewer.types.ts index 2dba044ac..19236c860 100644 --- a/src/features/git/components/GitDiffViewer.types.ts +++ b/src/features/git/components/GitDiffViewer.types.ts @@ -14,6 +14,8 @@ export type GitDiffViewerItem = { displayPath?: string; status: string; diff: string; + stagedDiff?: string | null; + unstagedDiff?: string | null; oldLines?: string[]; newLines?: string[]; isImage?: boolean; @@ -23,6 +25,22 @@ export type GitDiffViewerItem = { newImageMime?: string | null; }; +export type LocalLineAction = { + op: "stage" | "unstage"; + source: "unstaged" | "staged"; + label: "Stage" | "Unstage"; + title: string; + disabledReason?: string; +}; + +export type LocalLineActionContext = { + hasStaged: boolean; + hasUnstaged: boolean; + stagedDiff?: string | null; + unstagedDiff?: string | null; + disabledReason?: string; +}; + export type DiffStats = { additions: number; deletions: number; diff --git a/src/features/git/components/GitDiffViewerDiffCard.tsx b/src/features/git/components/GitDiffViewerDiffCard.tsx index 0ccabf349..7b11fb5d9 100644 --- a/src/features/git/components/GitDiffViewerDiffCard.tsx +++ b/src/features/git/components/GitDiffViewerDiffCard.tsx @@ -8,21 +8,25 @@ import { import { FileDiff } from "@pierre/diffs/react"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import type { + GitSelectionLine, PullRequestReviewAction, PullRequestReviewIntent, } from "../../../types"; import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; import { highlightLine, languageFromPath } from "../../../utils/syntax"; -import { - DIFF_VIEWER_SCROLL_CSS, -} from "../../design-system/diff/diffViewerTheme"; +import { DIFF_VIEWER_SCROLL_CSS } from "../../design-system/diff/diffViewerTheme"; import { splitPath } from "./GitDiffPanel.utils"; -import type { GitDiffViewerItem } from "./GitDiffViewer.types"; +import type { + GitDiffViewerItem, + LocalLineAction, + LocalLineActionContext, +} from "./GitDiffViewer.types"; import { isFallbackRawDiffLineHighlightable, normalizePatchName, parseRawDiffLines, } from "./GitDiffViewer.utils"; +import { LocalActionDiffBlock } from "./LocalActionDiffBlock"; type HoveredDiffLine = | { @@ -32,12 +36,52 @@ type HoveredDiffLine = } | undefined; +type FileDiffWithSourceLines = FileDiffMetadata & { + oldLines?: string[]; + newLines?: string[]; +}; + function isSelectableLine( line: ParsedDiffLine, ): line is ParsedDiffLine & { type: "add" | "del" | "context" } { return line.type === "add" || line.type === "del" || line.type === "context"; } +function parseDiffForViewer(diff: string) { + const parsed = parseDiff(diff); + if (parsed.length > 0) { + return parsed; + } + return parseRawDiffLines(diff); +} + +function resolveFileDiff( + diff: string, + displayPath: string, + oldLines?: string[], + newLines?: string[], +): FileDiffWithSourceLines | null { + if (!diff.trim()) { + return null; + } + const patch = parsePatchFiles(diff); + const parsed = patch[0]?.files[0]; + if (!parsed) { + return null; + } + const normalizedName = normalizePatchName(parsed.name || displayPath); + const normalizedPrevName = parsed.prevName + ? normalizePatchName(parsed.prevName) + : undefined; + return { + ...parsed, + name: normalizedName, + prevName: normalizedPrevName, + oldLines, + newLines, + } as FileDiffWithSourceLines; +} + function resolveParsedLineForHover( parsedLines: ParsedDiffLine[], hovered: HoveredDiffLine, @@ -86,7 +130,13 @@ export type DiffCardProps = { interactiveSelectionEnabled: boolean; selectedLines?: SelectedLineRange | null; onSelectedLinesChange?: (range: SelectedLineRange | null) => void; - onLineAction?: (line: ParsedDiffLine, index: number) => void; + localLineActionContext?: LocalLineActionContext | null; + lineActionBusy?: boolean; + onLocalChunkAction?: ( + lines: GitSelectionLine[], + action: LocalLineAction, + ) => void; + onComposerLineAction?: (line: ParsedDiffLine, index: number) => void; reviewActions?: PullRequestReviewAction[]; onRunReviewAction?: ( intent: PullRequestReviewIntent, @@ -109,7 +159,10 @@ export const DiffCard = memo(function DiffCard({ interactiveSelectionEnabled, selectedLines = null, onSelectedLinesChange, - onLineAction, + localLineActionContext = null, + lineActionBusy = false, + onLocalChunkAction, + onComposerLineAction, reviewActions = [], onRunReviewAction, onClearSelection, @@ -127,27 +180,30 @@ export const DiffCard = memo(function DiffCard({ [displayPath], ); - const fileDiff = useMemo(() => { - if (!entry.diff.trim()) { - return null; - } - const patch = parsePatchFiles(entry.diff); - const parsed = patch[0]?.files[0]; - if (!parsed) { - return null; - } - const normalizedName = normalizePatchName(parsed.name || displayPath); - const normalizedPrevName = parsed.prevName - ? normalizePatchName(parsed.prevName) - : undefined; - return { - ...parsed, - name: normalizedName, - prevName: normalizedPrevName, - oldLines: entry.oldLines, - newLines: entry.newLines, - } satisfies FileDiffMetadata; - }, [displayPath, entry.diff, entry.newLines, entry.oldLines]); + const parsedLines = useMemo( + () => parseDiffForViewer(entry.diff), + [entry.diff], + ); + const hasSelectableLines = useMemo( + () => parsedLines.some(isSelectableLine), + [parsedLines], + ); + const useInteractiveDiff = interactiveSelectionEnabled && hasSelectableLines; + const showLocalLineActions = Boolean( + !useInteractiveDiff && localLineActionContext && onLocalChunkAction, + ); + const composerLineActionEnabled = Boolean( + !useInteractiveDiff && + !showLocalLineActions && + onComposerLineAction && + hasSelectableLines, + ); + + const fileDiff = useMemo( + () => + resolveFileDiff(entry.diff, displayPath, entry.oldLines, entry.newLines), + [displayPath, entry.diff, entry.newLines, entry.oldLines], + ); const placeholder = useMemo(() => { if (isLoading) { @@ -159,41 +215,6 @@ export const DiffCard = memo(function DiffCard({ return "Diff unavailable."; }, [entry.diff, ignoreWhitespaceChanges, isLoading]); - const parsedLines = useMemo(() => { - const parsed = parseDiff(entry.diff); - if (parsed.length > 0) { - return parsed; - } - return parseRawDiffLines(entry.diff); - }, [entry.diff]); - - const hasSelectableLines = useMemo( - () => parsedLines.some(isSelectableLine), - [parsedLines], - ); - const useInteractiveDiff = interactiveSelectionEnabled && hasSelectableLines; - const lineActionEnabled = - diffStyle === "unified" && Boolean(onLineAction) && hasSelectableLines; - - const diffOptions = useMemo( - () => ({ - diffStyle, - hunkSeparators: "line-info" as const, - overflow: "scroll" as const, - unsafeCSS: DIFF_VIEWER_SCROLL_CSS, - disableFileHeader: true, - enableLineSelection: useInteractiveDiff, - onLineSelected: useInteractiveDiff ? onSelectedLinesChange : undefined, - enableHoverUtility: lineActionEnabled, - }), - [ - diffStyle, - lineActionEnabled, - onSelectedLinesChange, - useInteractiveDiff, - ], - ); - return (
{useInteractiveDiff && selectedLines && reviewActions.length > 0 ? ( -
+
{reviewActions.map((action) => (
- ) : entry.diff.trim().length > 0 && parsedLines.length > 0 ? ( -
- {parsedLines.map((line, index) => { - const highlighted = highlightLine( - line.text, - isFallbackRawDiffLineHighlightable(line.type) - ? fallbackLanguage - : null, - ); + ) : entry.diff.trim().length > 0 && parsedLines.length > 0 ? ( +
+ {parsedLines.map((line, index) => { + const highlighted = highlightLine( + line.text, + isFallbackRawDiffLineHighlightable(line.type) + ? fallbackLanguage + : null, + ); - return ( -
- -
- ); - })} -
- ) : ( -
{placeholder}
- )} + return ( +
+ +
+ ); + })} +
+ ) : ( +
{placeholder}
+ )} + {showLocalLineActions && localLineActionContext?.disabledReason ? ( +
+ {localLineActionContext.disabledReason} +
+ ) : null} +
); }); diff --git a/src/features/git/components/LocalActionDiffBlock.tsx b/src/features/git/components/LocalActionDiffBlock.tsx new file mode 100644 index 000000000..fce69d80f --- /dev/null +++ b/src/features/git/components/LocalActionDiffBlock.tsx @@ -0,0 +1,673 @@ +import { useMemo, useState, type MouseEvent } from "react"; +import type { GitSelectionLine } from "../../../types"; +import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; +import { highlightLine } from "../../../utils/syntax"; +import type { + LocalLineAction, + LocalLineActionContext, +} from "./GitDiffViewer.types"; +import { parseRawDiffLines } from "./GitDiffViewer.utils"; + +type SplitLineEntry = { + line: ParsedDiffLine; + index: number; +}; + +type SplitRow = + | { type: "meta"; line: ParsedDiffLine } + | { type: "content"; left: SplitLineEntry | null; right: SplitLineEntry | null }; + +type ChangeChunk = { + id: string; + startIndex: number; + lineIndices: number[]; + lines: GitSelectionLine[]; + isStaged: boolean; + action: LocalLineAction; +}; + +type ChunkMeta = { + isChunkStart: boolean; + chunk?: ChangeChunk; + isStaged: boolean; +}; + +type SelectionSource = "staged" | "unstaged"; + +type SourceMappedLine = { + source: SelectionSource; + line: GitSelectionLine; +}; + +type LocalActionDiffBlockProps = { + parsedLines: ParsedDiffLine[]; + diffStyle: "split" | "unified"; + language?: string | null; + context: LocalLineActionContext; + lineActionBusy?: boolean; + onChunkAction?: (lines: GitSelectionLine[], action: LocalLineAction) => void; +}; + +function isHighlightableLine(line: ParsedDiffLine) { + return line.type === "add" || line.type === "del" || line.type === "context"; +} + +function isChangeLine( + line: ParsedDiffLine, +): line is ParsedDiffLine & { type: "add" | "del" } { + return line.type === "add" || line.type === "del"; +} + +function parseDiffForViewer(diff: string) { + const parsed = parseDiff(diff); + if (parsed.length > 0) { + return parsed; + } + return parseRawDiffLines(diff); +} + +function buildSplitRows(parsed: ParsedDiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let pendingDel: SplitLineEntry[] = []; + let pendingAdd: SplitLineEntry[] = []; + + const flushPending = () => { + if (pendingDel.length === 0 && pendingAdd.length === 0) { + return; + } + const maxLen = Math.max(pendingDel.length, pendingAdd.length); + for (let index = 0; index < maxLen; index += 1) { + rows.push({ + type: "content", + left: pendingDel[index] ?? null, + right: pendingAdd[index] ?? null, + }); + } + pendingDel = []; + pendingAdd = []; + }; + + parsed.forEach((line, index) => { + if (line.type === "del") { + pendingDel.push({ line, index }); + return; + } + if (line.type === "add") { + pendingAdd.push({ line, index }); + return; + } + flushPending(); + if (line.type === "context") { + rows.push({ + type: "content", + left: { line, index }, + right: { line, index }, + }); + return; + } + rows.push({ type: "meta", line }); + }); + flushPending(); + + return rows; +} + +function buildGitLineSignature(line: GitSelectionLine) { + return `${line.type}:${line.oldLine ?? "null"}:${line.newLine ?? "null"}:${line.text}`; +} + +function buildGitLinePrimaryKey(line: GitSelectionLine) { + const primary = line.type === "add" ? line.newLine : line.oldLine; + return `${line.type}:${primary ?? "null"}:${line.text}`; +} + +function buildGitLineFuzzyKey(line: GitSelectionLine) { + return `${line.type}:${line.text}`; +} + +function primaryLineNumber(line: GitSelectionLine) { + return line.type === "add" ? line.newLine : line.oldLine; +} + +function toGitSelectionLine( + line: ParsedDiffLine & { type: "add" | "del" }, +): GitSelectionLine { + return { + type: line.type, + oldLine: line.oldLine, + newLine: line.newLine, + text: line.text, + }; +} + +type IndexedSourceLines = { + lines: GitSelectionLine[]; + cursor: number; + exactBuckets: Map; + primaryBuckets: Map; + fuzzyBuckets: Map; + exactPointers: Map; + primaryPointers: Map; + fuzzyPointers: Map; +}; + +type SourceMatchCandidate = { + source: SelectionSource; + lineIndex: number; + line: GitSelectionLine; + score: number; + lineDistance: number; + cursorDistance: number; +}; + +function pushBucketIndex( + buckets: Map, + key: string, + lineIndex: number, +) { + const existing = buckets.get(key); + if (existing) { + existing.push(lineIndex); + } else { + buckets.set(key, [lineIndex]); + } +} + +function buildIndexedSourceLines(lines: GitSelectionLine[]): IndexedSourceLines { + const exactBuckets = new Map(); + const primaryBuckets = new Map(); + const fuzzyBuckets = new Map(); + + lines.forEach((line, lineIndex) => { + pushBucketIndex(exactBuckets, buildGitLineSignature(line), lineIndex); + pushBucketIndex(primaryBuckets, buildGitLinePrimaryKey(line), lineIndex); + pushBucketIndex(fuzzyBuckets, buildGitLineFuzzyKey(line), lineIndex); + }); + + return { + lines, + cursor: 0, + exactBuckets, + primaryBuckets, + fuzzyBuckets, + exactPointers: new Map(), + primaryPointers: new Map(), + fuzzyPointers: new Map(), + }; +} + +function nextBucketIndex( + buckets: Map, + pointers: Map, + key: string, + cursor: number, +) { + const indices = buckets.get(key); + if (!indices || !indices.length) { + return null; + } + let pointer = pointers.get(key) ?? 0; + while (pointer < indices.length && indices[pointer] < cursor) { + pointer += 1; + } + pointers.set(key, pointer); + if (pointer >= indices.length) { + return null; + } + return indices[pointer]; +} + +function buildSourceMatchCandidate( + source: SelectionSource, + selectedLine: GitSelectionLine, + sourceLines: IndexedSourceLines, +) { + const exactIndex = nextBucketIndex( + sourceLines.exactBuckets, + sourceLines.exactPointers, + buildGitLineSignature(selectedLine), + sourceLines.cursor, + ); + const primaryIndex = nextBucketIndex( + sourceLines.primaryBuckets, + sourceLines.primaryPointers, + buildGitLinePrimaryKey(selectedLine), + sourceLines.cursor, + ); + const fuzzyIndex = nextBucketIndex( + sourceLines.fuzzyBuckets, + sourceLines.fuzzyPointers, + buildGitLineFuzzyKey(selectedLine), + sourceLines.cursor, + ); + + const index = + exactIndex ?? primaryIndex ?? fuzzyIndex; + if (index === null) { + return null; + } + const line = sourceLines.lines[index]; + const score = index === exactIndex ? 0 : index === primaryIndex ? 1 : 2; + const selectedPrimary = primaryLineNumber(selectedLine); + const candidatePrimary = primaryLineNumber(line); + const lineDistance = + typeof selectedPrimary === "number" && typeof candidatePrimary === "number" + ? Math.abs(selectedPrimary - candidatePrimary) + : Number.MAX_SAFE_INTEGER; + return { + source, + lineIndex: index, + line, + score, + lineDistance, + cursorDistance: index - sourceLines.cursor, + } satisfies SourceMatchCandidate; +} + +function choosePreferredSourceCandidate( + stagedCandidate: SourceMatchCandidate | null, + unstagedCandidate: SourceMatchCandidate | null, + previousSource: SelectionSource | null, +) { + if (!stagedCandidate) { + return unstagedCandidate; + } + if (!unstagedCandidate) { + return stagedCandidate; + } + if (stagedCandidate.score !== unstagedCandidate.score) { + return stagedCandidate.score < unstagedCandidate.score + ? stagedCandidate + : unstagedCandidate; + } + if (stagedCandidate.lineDistance !== unstagedCandidate.lineDistance) { + return stagedCandidate.lineDistance < unstagedCandidate.lineDistance + ? stagedCandidate + : unstagedCandidate; + } + if (stagedCandidate.cursorDistance !== unstagedCandidate.cursorDistance) { + return stagedCandidate.cursorDistance < unstagedCandidate.cursorDistance + ? stagedCandidate + : unstagedCandidate; + } + if (previousSource) { + if (stagedCandidate.source === previousSource) { + return stagedCandidate; + } + if (unstagedCandidate.source === previousSource) { + return unstagedCandidate; + } + } + return stagedCandidate.lineIndex <= unstagedCandidate.lineIndex + ? stagedCandidate + : unstagedCandidate; +} + +function buildSourceMappedLines( + parsedLines: ParsedDiffLine[], + context: LocalLineActionContext, + stagedSourceLines: GitSelectionLine[], + unstagedSourceLines: GitSelectionLine[], +) { + const mappedByIndex = new Map(); + const stagedLookup = buildIndexedSourceLines(stagedSourceLines); + const unstagedLookup = buildIndexedSourceLines(unstagedSourceLines); + let previousSource: SelectionSource | null = null; + + parsedLines.forEach((line, index) => { + if (!isChangeLine(line)) { + return; + } + const selectedLine = toGitSelectionLine(line); + const stagedCandidate = context.hasStaged + ? buildSourceMatchCandidate("staged", selectedLine, stagedLookup) + : null; + const unstagedCandidate = context.hasUnstaged + ? buildSourceMatchCandidate("unstaged", selectedLine, unstagedLookup) + : null; + const chosen = + choosePreferredSourceCandidate( + stagedCandidate, + unstagedCandidate, + previousSource, + ) ?? + (context.hasStaged && !context.hasUnstaged + ? ({ + source: "staged" as const, + lineIndex: -1, + line: selectedLine, + } satisfies Pick) + : context.hasUnstaged && !context.hasStaged + ? ({ + source: "unstaged" as const, + lineIndex: -1, + line: selectedLine, + } satisfies Pick) + : null); + + if (!chosen) { + return; + } + + if (chosen.lineIndex >= 0) { + if (chosen.source === "staged") { + stagedLookup.cursor = chosen.lineIndex + 1; + } else { + unstagedLookup.cursor = chosen.lineIndex + 1; + } + } + + mappedByIndex.set(index, { + source: chosen.source, + line: chosen.line, + }); + previousSource = chosen.source; + }); + + return mappedByIndex; +} + +function buildChunks( + parsedLines: ParsedDiffLine[], + sourceLineByIndex: Map, + disabledReason?: string, +) { + const stageActionBase: LocalLineAction = { + op: "stage", + source: "unstaged", + label: "Stage", + title: "Stage this chunk", + disabledReason, + }; + const unstageActionBase: LocalLineAction = { + op: "unstage", + source: "staged", + label: "Unstage", + title: "Unstage this chunk", + disabledReason, + }; + const chunkMetaByIndex = new Map(); + const chunks: ChangeChunk[] = []; + let current: ChangeChunk | null = null; + let currentStaged = false; + + const flush = () => { + if (!current) { + return; + } + chunks.push(current); + current = null; + }; + + parsedLines.forEach((line, index) => { + if (!isChangeLine(line)) { + flush(); + return; + } + + const sourceMapped = sourceLineByIndex.get(index); + if (!sourceMapped) { + flush(); + return; + } + const isStaged = sourceMapped.source === "staged"; + const gitLine = sourceMapped.line; + + if (!current || currentStaged !== isStaged) { + flush(); + currentStaged = isStaged; + current = { + id: `chunk-${index}`, + startIndex: index, + lineIndices: [index], + lines: [gitLine], + isStaged, + action: isStaged ? unstageActionBase : stageActionBase, + }; + chunkMetaByIndex.set(index, { + isChunkStart: true, + chunk: current, + isStaged, + }); + return; + } + + current.lineIndices.push(index); + current.lines.push(gitLine); + chunkMetaByIndex.set(index, { + isChunkStart: false, + chunk: current, + isStaged, + }); + }); + + flush(); + + return { chunks, chunkMetaByIndex }; +} + +export function LocalActionDiffBlock({ + parsedLines, + diffStyle, + language, + context, + lineActionBusy = false, + onChunkAction, +}: LocalActionDiffBlockProps) { + const [hoveredChunkId, setHoveredChunkId] = useState(null); + const splitRows = useMemo( + () => (diffStyle === "split" ? buildSplitRows(parsedLines) : []), + [diffStyle, parsedLines], + ); + + const stagedSourceLines = useMemo( + () => + context.stagedDiff?.trim() + ? parseDiffForViewer(context.stagedDiff) + .filter(isChangeLine) + .map(toGitSelectionLine) + : context.hasStaged && !context.hasUnstaged + ? parsedLines.filter(isChangeLine).map(toGitSelectionLine) + : [], + [context.hasStaged, context.hasUnstaged, context.stagedDiff, parsedLines], + ); + const unstagedSourceLines = useMemo( + () => + context.unstagedDiff?.trim() + ? parseDiffForViewer(context.unstagedDiff) + .filter(isChangeLine) + .map(toGitSelectionLine) + : context.hasUnstaged && !context.hasStaged + ? parsedLines.filter(isChangeLine).map(toGitSelectionLine) + : [], + [context.hasStaged, context.hasUnstaged, context.unstagedDiff, parsedLines], + ); + + const sourceLineByIndex = useMemo( + () => + buildSourceMappedLines( + parsedLines, + context, + stagedSourceLines, + unstagedSourceLines, + ), + [context, parsedLines, stagedSourceLines, unstagedSourceLines], + ); + + const { chunkMetaByIndex } = useMemo( + () => buildChunks(parsedLines, sourceLineByIndex, context.disabledReason), + [context.disabledReason, parsedLines, sourceLineByIndex], + ); + + const renderLine = ( + line: ParsedDiffLine, + index: number, + side?: "left" | "right", + mirroredChunk?: ChangeChunk, + ) => { + const shouldHighlight = isHighlightableLine(line); + const html = highlightLine(line.text, shouldHighlight ? language : null); + const chunkMeta = chunkMetaByIndex.get(index); + const chunk = mirroredChunk ?? chunkMeta?.chunk; + const shouldRenderAction = Boolean( + mirroredChunk || (chunkMeta?.isChunkStart && chunkMeta?.chunk), + ); + const isChunkActive = Boolean(chunk && hoveredChunkId === chunk.id); + const isStaged = Boolean(chunkMeta?.isStaged); + const actionHardDisabled = Boolean(chunk?.action.disabledReason) || !chunk; + const actionBlocked = lineActionBusy || actionHardDisabled; + const lineClassName = `diff-line diff-line-${line.type}${ + shouldRenderAction ? " has-line-action" : "" + }${shouldRenderAction && isChunkActive ? " chunk-action-visible" : ""}${ + isStaged ? " diff-line-staged" : "" + }`; + + return ( +
+
+ + {side === "right" ? "" : (line.oldLine ?? "")} + + + {side === "left" ? "" : (line.newLine ?? "")} + +
+ + {shouldRenderAction && chunk ? ( + + ) : null} +
+ ); + }; + + if (diffStyle === "split") { + const handleChunkPointerMove = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const chunkNode = target.closest("[data-chunk-id]"); + const nextChunkId = chunkNode?.dataset.chunkId ?? null; + setHoveredChunkId((current) => (current === nextChunkId ? current : nextChunkId)); + }; + + return ( +
{ + setHoveredChunkId(null); + }} + > + {splitRows.map((row, rowIndex) => { + if (row.type === "meta") { + const metaClass = + row.line.type === "hunk" ? "diff-line-hunk" : "diff-line-meta"; + return ( +
+ {row.line.text} +
+ ); + } + const leftMeta = row.left + ? chunkMetaByIndex.get(row.left.index) + : undefined; + const rightMeta = row.right + ? chunkMetaByIndex.get(row.right.index) + : undefined; + const mirroredChunk = + leftMeta?.chunk && + rightMeta?.chunk && + leftMeta.chunk.id === rightMeta.chunk.id && + (leftMeta.isChunkStart || rightMeta.isChunkStart) + ? leftMeta.chunk + : undefined; + return ( +
+ {row.left ? ( + renderLine( + row.left.line, + row.left.index, + "left", + mirroredChunk, + ) + ) : ( +
+
+ + +
+ +
+ )} + {row.right ? ( + renderLine( + row.right.line, + row.right.index, + "right", + mirroredChunk, + ) + ) : ( +
+
+ + +
+ +
+ )} +
+ ); + })} +
+ ); + } + + return ( +
{ + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const chunkNode = target.closest("[data-chunk-id]"); + const nextChunkId = chunkNode?.dataset.chunkId ?? null; + setHoveredChunkId((current) => (current === nextChunkId ? current : nextChunkId)); + }} + onMouseLeave={() => { + setHoveredChunkId(null); + }} + > + {parsedLines.map((line, index) => ( +
{renderLine(line, index)}
+ ))} +
+ ); +} diff --git a/src/features/git/components/PierreDiffBlock.tsx b/src/features/git/components/PierreDiffBlock.tsx index 3a2bb5345..ad349d295 100644 --- a/src/features/git/components/PierreDiffBlock.tsx +++ b/src/features/git/components/PierreDiffBlock.tsx @@ -14,6 +14,11 @@ import { parseRawDiffLines, } from "./GitDiffViewer.utils"; +type FileDiffWithSourceLines = FileDiffMetadata & { + oldLines?: string[]; + newLines?: string[]; +}; + type PierreDiffBlockProps = { diff: string; displayPath: string; @@ -55,7 +60,7 @@ export function PierreDiffBlock({ prevName: normalizedPrevName, oldLines, newLines, - } satisfies FileDiffMetadata; + } as FileDiffWithSourceLines; }, [diff, displayPath, oldLines, newLines]); const parsedLines = useMemo(() => { diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 39494ba0a..71796ea34 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -60,9 +60,11 @@ export function useGitActions({ } }, [workspaceId]); - const refreshGitData = useCallback(() => { - onRefreshGitStatus(); - onRefreshGitDiffs(); + const refreshGitData = useCallback(async () => { + await Promise.allSettled([ + Promise.resolve(onRefreshGitStatus()), + Promise.resolve(onRefreshGitDiffs()), + ]); }, [onRefreshGitDiffs, onRefreshGitStatus]); const stageGitFile = useCallback( @@ -77,7 +79,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -95,7 +97,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, [onError, refreshGitData, workspaceId]); @@ -124,7 +126,7 @@ export function useGitActions({ return null; } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -143,7 +145,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -162,7 +164,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -182,7 +184,7 @@ export function useGitActions({ } try { await revertGitAll(workspaceId); - refreshGitData(); + await refreshGitData(); } catch (error) { onError?.(error); } diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 5e0a475f6..d3d6e2309 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -509,6 +509,25 @@ background: transparent; } +.diff-split-block { + display: flex; + flex-direction: column; +} + +.diff-split-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.diff-split-meta { + padding: 3px 10px; + color: var(--text-subtle); + font-size: var(--code-font-size, 11px); + font-family: var(--code-font-family); + white-space: pre-wrap; +} + .diff-line { display: grid; grid-template-columns: 64px 1fr; @@ -520,9 +539,12 @@ position: relative; } +.diff-line-empty { + opacity: 0.45; +} + .diff-line.has-line-action { position: relative; - padding-left: 30px; } .diff-line[data-has-gutter="false"] { @@ -602,7 +624,7 @@ .diff-line-action { position: absolute; top: 50%; - left: 6px; + left: 7px; transform: translateY(-50%); width: 20px; height: 20px; @@ -627,9 +649,25 @@ color 140ms ease; } +.diff-line-action--before-gutter { + left: 7px; +} + +.diff-line-action--after-gutter { + left: auto; + right: 7px; +} + +.diff-line-action--unstage { + color: #f5c363; + border-color: rgba(245, 195, 99, 0.48); + background: color-mix(in srgb, #f5c363 10%, var(--surface-control)); +} + .diff-line.has-line-action:hover .diff-line-action, .diff-line.has-line-action:focus-visible .diff-line-action, -.diff-line.has-line-action:focus-within .diff-line-action { +.diff-line.has-line-action:focus-within .diff-line-action, +.diff-line.has-line-action.chunk-action-visible .diff-line-action { opacity: 1; pointer-events: auto; } @@ -677,7 +715,7 @@ } .diff-line.has-line-action[data-has-gutter="false"] { - padding-left: 30px; + padding-left: 10px; } .diff-line[data-has-gutter="false"] .diff-line-content { @@ -723,12 +761,26 @@ background: rgba(248, 81, 73, 0.25); } -.diff-viewer-line-action-section--staged .diff-line-add { - background: rgba(46, 160, 67, 0.38); +.diff-line.diff-line-staged { + position: relative; } -.diff-viewer-line-action-section--staged .diff-line-del { - background: rgba(248, 81, 73, 0.36); +.diff-line.diff-line-staged::after { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + pointer-events: none; +} + +.diff-line.diff-line-staged.diff-line-add::after { + background: #3be98d; +} + +.diff-line.diff-line-staged.diff-line-del::after { + background: #ff6565; } .diff-line-meta { @@ -786,94 +838,6 @@ color: #7fd1ff; } -.diff-line-action { - position: absolute; - top: 2px; - right: 6px; - z-index: 2; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 46px; - height: 20px; - border-radius: 999px; - border: 1px solid var(--border-subtle); - background: color-mix(in srgb, var(--surface-control) 92%, transparent); - color: var(--text-subtle); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.02em; - opacity: 0; - transform: translateY(-2px); - pointer-events: none; - transition: - opacity 120ms ease, - transform 120ms ease, - color 120ms ease, - border-color 120ms ease, - background 120ms ease; -} - -.diff-line-action--add { - color: #47d488; - border-color: rgba(71, 212, 136, 0.38); -} - -.diff-line-action--del { - color: #ff8f8f; - border-color: rgba(255, 143, 143, 0.38); -} - -.diff-line-action--mixed { - color: var(--text-subtle); - border-color: var(--border-subtle); -} - -.diff-line-action--tone-unstage { - color: #ffd39f; - border-color: rgba(255, 211, 159, 0.5); - background: color-mix(in srgb, #6e4b22 24%, var(--surface-control)); -} - -.diff-line-action:disabled { - opacity: 0.45; - pointer-events: none; -} - -.diff-line:hover .diff-line-action, -.diff-line:focus-within .diff-line-action, -.diff-viewer-line-action-section:hover .diff-line-action, -.diff-viewer-line-action-section:focus-within .diff-line-action { - opacity: 1; - transform: translateY(0); - pointer-events: auto; -} - -.diff-line .diff-line-action:disabled { - opacity: 0.45; - pointer-events: none; -} - -.diff-viewer-line-action-section { - display: flex; - flex-direction: column; - gap: 4px; -} - -.diff-viewer-line-action-section + .diff-viewer-line-action-section { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-subtle); -} - -.diff-viewer-line-action-section-label { - padding: 0 8px; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-faint); -} - .diff-viewer-line-action-hint { margin-top: 6px; padding: 0 8px; @@ -881,33 +845,6 @@ color: var(--text-faint); } -@media (hover: none), (pointer: coarse) { - .diff-line-action { - opacity: 1; - transform: none; - pointer-events: auto; - height: 24px; - min-width: 54px; - } - - .diff-line { - padding-right: 62px; - } -} - -.app.layout-phone .diff-line-action { - opacity: 1; - transform: none; - pointer-events: auto; - min-width: 60px; - height: 28px; - font-size: 11px; -} - -.app.layout-phone .diff-line { - padding-right: 68px; -} - .diff-viewer-placeholder, .diff-viewer-empty { color: var(--text-subtle); From 36062df399fa73e4b49dd0f161c17040f815c298 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Mar 2026 14:44:37 -0400 Subject: [PATCH 12/18] Fixes hover performance issue --- .../git/components/LocalActionDiffBlock.tsx | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/src/features/git/components/LocalActionDiffBlock.tsx b/src/features/git/components/LocalActionDiffBlock.tsx index fce69d80f..36652aefa 100644 --- a/src/features/git/components/LocalActionDiffBlock.tsx +++ b/src/features/git/components/LocalActionDiffBlock.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type MouseEvent } from "react"; +import { useMemo, useRef, useState } from "react"; import type { GitSelectionLine } from "../../../types"; import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; import { highlightLine } from "../../../utils/syntax"; @@ -129,6 +129,14 @@ function primaryLineNumber(line: GitSelectionLine) { return line.type === "add" ? line.newLine : line.oldLine; } +function chunkIdFromEventTarget(target: EventTarget | null) { + if (!(target instanceof Element)) { + return null; + } + const chunkNode = target.closest("[data-chunk-id]"); + return chunkNode?.dataset.chunkId ?? null; +} + function toGitSelectionLine( line: ParsedDiffLine & { type: "add" | "del" }, ): GitSelectionLine { @@ -455,10 +463,19 @@ export function LocalActionDiffBlock({ onChunkAction, }: LocalActionDiffBlockProps) { const [hoveredChunkId, setHoveredChunkId] = useState(null); + const hoveredChunkIdRef = useRef(null); const splitRows = useMemo( () => (diffStyle === "split" ? buildSplitRows(parsedLines) : []), [diffStyle, parsedLines], ); + const highlightedHtmlByIndex = useMemo( + () => + parsedLines.map((line) => { + const shouldHighlight = isHighlightableLine(line); + return highlightLine(line.text, shouldHighlight ? language : null); + }), + [language, parsedLines], + ); const stagedSourceLines = useMemo( () => @@ -498,6 +515,13 @@ export function LocalActionDiffBlock({ () => buildChunks(parsedLines, sourceLineByIndex, context.disabledReason), [context.disabledReason, parsedLines, sourceLineByIndex], ); + const updateHoveredChunkId = (nextChunkId: string | null) => { + if (hoveredChunkIdRef.current === nextChunkId) { + return; + } + hoveredChunkIdRef.current = nextChunkId; + setHoveredChunkId(nextChunkId); + }; const renderLine = ( line: ParsedDiffLine, @@ -505,8 +529,7 @@ export function LocalActionDiffBlock({ side?: "left" | "right", mirroredChunk?: ChangeChunk, ) => { - const shouldHighlight = isHighlightableLine(line); - const html = highlightLine(line.text, shouldHighlight ? language : null); + const html = highlightedHtmlByIndex[index] ?? ""; const chunkMeta = chunkMetaByIndex.get(index); const chunk = mirroredChunk ?? chunkMeta?.chunk; const shouldRenderAction = Boolean( @@ -568,22 +591,14 @@ export function LocalActionDiffBlock({ }; if (diffStyle === "split") { - const handleChunkPointerMove = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Element)) { - return; - } - const chunkNode = target.closest("[data-chunk-id]"); - const nextChunkId = chunkNode?.dataset.chunkId ?? null; - setHoveredChunkId((current) => (current === nextChunkId ? current : nextChunkId)); - }; - return (
{ + updateHoveredChunkId(chunkIdFromEventTarget(event.target)); + }} onMouseLeave={() => { - setHoveredChunkId(null); + updateHoveredChunkId(null); }} > {splitRows.map((row, rowIndex) => { @@ -652,17 +667,11 @@ export function LocalActionDiffBlock({ return (
{ - const target = event.target; - if (!(target instanceof Element)) { - return; - } - const chunkNode = target.closest("[data-chunk-id]"); - const nextChunkId = chunkNode?.dataset.chunkId ?? null; - setHoveredChunkId((current) => (current === nextChunkId ? current : nextChunkId)); + onMouseOver={(event) => { + updateHoveredChunkId(chunkIdFromEventTarget(event.target)); }} onMouseLeave={() => { - setHoveredChunkId(null); + updateHoveredChunkId(null); }} > {parsedLines.map((line, index) => ( From 104fccf8b70c9afb9998cbd9c197ef020fea5024 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Mar 2026 15:05:57 -0400 Subject: [PATCH 13/18] Optimization to git status refesh --- src-tauri/src/shared/git_ui_core/diff.rs | 54 +++++++++++++++++------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 7f7c76ff8..8f713dfa8 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -123,19 +123,39 @@ fn status_for_delta(status: git2::Delta) -> &'static str { } } -fn has_unstaged_diff_with_git(repo_root: &Path, path: &str) -> Option { +fn unstaged_diff_paths_with_git(repo_root: &Path, paths: &[String]) -> Option> { + if paths.is_empty() { + return Some(HashSet::new()); + } + + const MAX_PATHS_PER_BATCH: usize = 200; let git_bin = resolve_git_binary().ok()?; - let output = std_command(git_bin) - .args(["diff", "--no-color", "-U0", "--", path]) - .current_dir(repo_root) - .env("PATH", git_env_path()) - .output() - .ok()?; - if !(output.status.success() || output.status.code() == Some(1)) { - return None; + let mut changed_paths = HashSet::new(); + + for batch in paths.chunks(MAX_PATHS_PER_BATCH) { + let mut args = vec!["diff", "--no-color", "--name-only", "-z", "--"]; + args.extend(batch.iter().map(String::as_str)); + + let output = std_command(&git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .ok()?; + if !(output.status.success() || output.status.code() == Some(1)) { + return None; + } + + for raw_path in output.stdout.split(|byte| *byte == 0) { + if raw_path.is_empty() { + continue; + } + let path = String::from_utf8_lossy(raw_path); + changed_paths.insert(normalize_git_path(path.as_ref())); + } } - let stdout = String::from_utf8_lossy(&output.stdout); - Some(!stdout.trim().is_empty()) + + Some(changed_paths) } fn source_diff_for_path( @@ -407,7 +427,12 @@ pub(super) async fn get_git_status_inner( .filter_map(|entry| entry.path().map(PathBuf::from)) .filter(|path| !path.as_os_str().is_empty()) .collect(); + let normalized_status_paths: Vec = status_paths + .iter() + .map(|path| normalize_git_path(path.to_string_lossy().as_ref())) + .collect(); let ignored_paths = collect_ignored_paths_with_git(&repo, &status_paths); + let unstaged_diff_paths = unstaged_diff_paths_with_git(&repo_root, &normalized_status_paths); let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let index = repo.index().ok(); @@ -453,11 +478,8 @@ pub(super) async fn get_git_status_inner( // libgit2 can briefly report both staged and workdir status for a path. // Verify actual unstaged diff content before keeping the workdir bucket. if include_index && include_workdir { - if matches!( - has_unstaged_diff_with_git(&repo_root, normalized_path.as_str()), - Some(false) - ) { - include_workdir = false; + if let Some(unstaged_diff_paths) = unstaged_diff_paths.as_ref() { + include_workdir = unstaged_diff_paths.contains(&normalized_path); } } let mut combined_additions = 0i64; From f4c763fd32d4f0f89a37e6c0b35c073a485e7a33 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Wed, 18 Mar 2026 15:40:34 -0400 Subject: [PATCH 14/18] Fix flicker that occurs on hover of diff chunks --- .../git/components/LocalActionDiffBlock.tsx | 47 +++++++++++++++---- src/styles/diff-viewer.css | 10 ++-- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/features/git/components/LocalActionDiffBlock.tsx b/src/features/git/components/LocalActionDiffBlock.tsx index 36652aefa..129fe4085 100644 --- a/src/features/git/components/LocalActionDiffBlock.tsx +++ b/src/features/git/components/LocalActionDiffBlock.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, type MouseEvent } from "react"; import type { GitSelectionLine } from "../../../types"; import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; import { highlightLine } from "../../../utils/syntax"; @@ -130,10 +130,16 @@ function primaryLineNumber(line: GitSelectionLine) { } function chunkIdFromEventTarget(target: EventTarget | null) { - if (!(target instanceof Element)) { + const element = + target instanceof Element + ? target + : target instanceof Node + ? target.parentElement + : null; + if (!element) { return null; } - const chunkNode = target.closest("[data-chunk-id]"); + const chunkNode = element.closest("[data-chunk-id]"); return chunkNode?.dataset.chunkId ?? null; } @@ -522,6 +528,21 @@ export function LocalActionDiffBlock({ hoveredChunkIdRef.current = nextChunkId; setHoveredChunkId(nextChunkId); }; + const handleChunkMouseEnter = (chunkId: string) => { + updateHoveredChunkId(chunkId); + }; + const handleChunkMouseLeave = ( + event: MouseEvent, + chunkId: string, + ) => { + const toChunkId = chunkIdFromEventTarget(event.relatedTarget); + if (toChunkId === chunkId) { + return; + } + if (hoveredChunkIdRef.current === chunkId) { + updateHoveredChunkId(null); + } + }; const renderLine = ( line: ParsedDiffLine, @@ -550,6 +571,20 @@ export function LocalActionDiffBlock({ className={lineClassName} data-has-gutter="true" data-chunk-id={chunk?.id} + onMouseEnter={ + chunk + ? () => { + handleChunkMouseEnter(chunk.id); + } + : undefined + } + onMouseLeave={ + chunk + ? (event) => { + handleChunkMouseLeave(event, chunk.id); + } + : undefined + } >
@@ -594,9 +629,6 @@ export function LocalActionDiffBlock({ return (
{ - updateHoveredChunkId(chunkIdFromEventTarget(event.target)); - }} onMouseLeave={() => { updateHoveredChunkId(null); }} @@ -667,9 +699,6 @@ export function LocalActionDiffBlock({ return (
{ - updateHoveredChunkId(chunkIdFromEventTarget(event.target)); - }} onMouseLeave={() => { updateHoveredChunkId(null); }} diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index d3d6e2309..ba3c41828 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -616,6 +616,8 @@ } .diff-line-content { + display: block; + white-space: pre-wrap; min-width: 0; overflow-wrap: anywhere; word-break: break-word; @@ -625,7 +627,7 @@ position: absolute; top: 50%; left: 7px; - transform: translateY(-50%); + transform: translate3d(0, -50%, 0); width: 20px; height: 20px; border-radius: 999px; @@ -642,8 +644,10 @@ line-height: 1; opacity: 0; pointer-events: none; + will-change: opacity; + backface-visibility: hidden; + contain: paint; transition: - opacity 140ms ease, background 140ms ease, border-color 140ms ease, color 140ms ease; @@ -674,7 +678,7 @@ .diff-line-action:hover:not(:disabled), .diff-line-action:active:not(:disabled) { - transform: translateY(-50%); + transform: translate3d(0, -50%, 0); box-shadow: none; } From 6298f0c0fc2f84cbcb1d9e1cc99143e6d19142d0 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Thu, 19 Mar 2026 14:19:43 -0400 Subject: [PATCH 15/18] Re-implementes stage patch feature to better support front end determining actionable hunks --- src-tauri/src/bin/codex_monitor_daemon.rs | 15 + .../src/bin/codex_monitor_daemon/rpc/git.rs | 13 + src-tauri/src/git/mod.rs | 29 + src-tauri/src/lib.rs | 1 + src-tauri/src/shared/git_rpc.rs | 9 + src-tauri/src/shared/git_ui_core.rs | 9 + src-tauri/src/shared/git_ui_core/commands.rs | 913 +++++++++++++++++- src-tauri/src/shared/git_ui_core/diff.rs | 570 ++++++++++- src-tauri/src/types.rs | 13 + src/features/app/hooks/useMainAppGitState.ts | 2 + .../app/hooks/useMainAppLayoutSurfaces.ts | 2 +- .../git/components/GitDiffViewer.test.tsx | 502 ++++++---- src/features/git/components/GitDiffViewer.tsx | 109 ++- .../git/components/GitDiffViewer.types.ts | 18 +- .../git/components/GitDiffViewerDiffCard.tsx | 16 +- .../git/components/LocalActionDiffBlock.tsx | 752 +++++---------- src/features/git/hooks/useGitActions.ts | 29 + src/features/git/hooks/useGitDiffs.ts | 1 + src/services/tauri.test.ts | 18 + src/services/tauri.ts | 8 + src/styles/diff-viewer.css | 25 +- src/types.ts | 10 + 22 files changed, 2251 insertions(+), 813 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 7863e2f36..5ccfb5215 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1107,6 +1107,21 @@ impl DaemonState { .await } + async fn apply_git_display_hunk( + &self, + workspace_id: String, + path: String, + display_hunk_id: String, + ) -> Result { + git_ui_core::apply_git_display_hunk_core( + &self.workspaces, + workspace_id, + path, + display_hunk_id, + ) + .await + } + async fn unstage_git_file(&self, workspace_id: String, path: String) -> Result<(), String> { git_ui_core::unstage_git_file_core(&self.workspaces, workspace_id, path).await } diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs index fda783eaf..d45bf5f44 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -103,6 +103,19 @@ pub(super) async fn try_handle( let request = parse_request_or_err!(params, git_rpc::WorkspaceIdRequest); Some(serialize_ok(state.stage_git_all(request.workspace_id)).await) } + git_rpc::METHOD_APPLY_GIT_DISPLAY_HUNK => { + let request = parse_request_or_err!(params, git_rpc::GitDisplayHunkActionRequest); + Some( + state + .apply_git_display_hunk( + request.workspace_id, + request.path, + request.display_hunk_id, + ) + .await + .and_then(|result| serde_json::to_value(result).map_err(|err| err.to_string())), + ) + } "stage_git_selection" => { let workspace_id = match parse_string(params, "workspaceId") { Ok(value) => value, diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index d07d29059..d74cd79db 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -215,6 +215,35 @@ pub(crate) async fn stage_git_selection( .await } +#[tauri::command] +pub(crate) async fn apply_git_display_hunk( + workspace_id: String, + path: String, + display_hunk_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let request = git_rpc::GitDisplayHunkActionRequest { + workspace_id: workspace_id.clone(), + path: path.clone(), + display_hunk_id: display_hunk_id.clone(), + }; + try_remote_typed!( + state, + app, + git_rpc::METHOD_APPLY_GIT_DISPLAY_HUNK, + git_remote_params(&request)?, + GitSelectionApplyResult + ); + git_ui_core::apply_git_display_hunk_core( + &state.workspaces, + workspace_id, + path, + display_hunk_id, + ) + .await +} + #[tauri::command] pub(crate) async fn unstage_git_file( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 021ed8c2d..7329766c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -241,6 +241,7 @@ pub fn run() { git::stage_git_file, git::stage_git_all, git::stage_git_selection, + git::apply_git_display_hunk, git::unstage_git_file, git::revert_git_file, git::revert_git_all, diff --git a/src-tauri/src/shared/git_rpc.rs b/src-tauri/src/shared/git_rpc.rs index 652a973fb..8e2f565c1 100644 --- a/src-tauri/src/shared/git_rpc.rs +++ b/src-tauri/src/shared/git_rpc.rs @@ -7,6 +7,7 @@ pub(crate) const METHOD_INIT_GIT_REPO: &str = "init_git_repo"; pub(crate) const METHOD_CREATE_GITHUB_REPO: &str = "create_github_repo"; pub(crate) const METHOD_STAGE_GIT_FILE: &str = "stage_git_file"; pub(crate) const METHOD_STAGE_GIT_ALL: &str = "stage_git_all"; +pub(crate) const METHOD_APPLY_GIT_DISPLAY_HUNK: &str = "apply_git_display_hunk"; pub(crate) const METHOD_UNSTAGE_GIT_FILE: &str = "unstage_git_file"; pub(crate) const METHOD_REVERT_GIT_FILE: &str = "revert_git_file"; pub(crate) const METHOD_REVERT_GIT_ALL: &str = "revert_git_all"; @@ -86,6 +87,14 @@ pub(crate) struct WorkspacePathRequest { pub(crate) path: String, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitDisplayHunkActionRequest { + pub(crate) workspace_id: String, + pub(crate) path: String, + pub(crate) display_hunk_id: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ListGitRootsRequest { diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index 8911afef1..a635d3a25 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -128,6 +128,15 @@ pub(crate) async fn stage_git_selection_core( commands::stage_git_selection_inner(workspaces, workspace_id, path, op, source, lines).await } +pub(crate) async fn apply_git_display_hunk_core( + workspaces: &Mutex>, + workspace_id: String, + path: String, + display_hunk_id: String, +) -> Result { + commands::apply_git_display_hunk_inner(workspaces, workspace_id, path, display_hunk_id).await +} + pub(crate) async fn unstage_git_file_core( workspaces: &Mutex>, workspace_id: String, diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs index 9c6bc23dc..afa4099e1 100644 --- a/src-tauri/src/shared/git_ui_core/commands.rs +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -18,6 +18,17 @@ use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; use super::context::workspace_entry_for_id; +fn git_selection_debug_enabled() -> bool { + std::env::var_os("CODEX_MONITOR_GIT_SELECTION_DEBUG").is_some() +} + +fn git_selection_debug_log(event: &str, payload: Value) { + if !git_selection_debug_enabled() { + return; + } + eprintln!("[git-selection] {event} {}", payload); +} + async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; let output = tokio_command(git_bin) @@ -401,7 +412,7 @@ async fn pull_with_default_strategy(repo_root: &Path) -> Result<(), String> { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum SelectionLineType { +pub(super) enum SelectionLineType { Add, Del, } @@ -444,24 +455,34 @@ impl TryFrom<&GitSelectionLine> for SelectionLineKey { } #[derive(Debug, Clone)] -struct ParsedPatchLine { - line_type: SelectionLineType, - old_line: Option, - new_line: Option, - old_anchor: usize, - new_anchor: usize, - text: String, +pub(super) struct ParsedPatchLine { + pub(super) line_type: SelectionLineType, + pub(super) old_line: Option, + pub(super) new_line: Option, + pub(super) old_anchor: usize, + pub(super) new_anchor: usize, + pub(super) text: String, +} + +#[derive(Debug, Clone)] +pub(super) struct ParsedPatchHunk { + pub(super) old_start: usize, + pub(super) old_count: usize, + pub(super) new_start: usize, + pub(super) new_count: usize, + pub(super) lines: Vec, } #[derive(Debug, Clone)] -struct ParsedPatchHunk { - lines: Vec, +pub(super) struct ParsedPatch { + pub(super) headers: Vec, + pub(super) hunks: Vec, } #[derive(Debug, Clone)] -struct ParsedPatch { - headers: Vec, - hunks: Vec, +struct SelectionSourceFileContext { + old_lines: Vec, + new_lines: Vec, } fn parse_hunk_range(raw: &str) -> Option<(usize, usize)> { @@ -472,7 +493,7 @@ fn parse_hunk_range(raw: &str) -> Option<(usize, usize)> { } } -fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { +pub(super) fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { let suffix = line.strip_prefix("@@ -")?; let (old_range_raw, rest) = suffix.split_once(" +")?; let marker_index = rest.find(" @@")?; @@ -482,7 +503,7 @@ fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { Some((old_start, old_count, new_start, new_count)) } -fn parse_zero_context_patch(diff_patch: &str) -> Result { +pub(super) fn parse_zero_context_patch(diff_patch: &str) -> Result { let lines: Vec<&str> = diff_patch.lines().collect(); if lines.is_empty() { return Err("No patch content to apply.".to_string()); @@ -536,7 +557,13 @@ fn parse_zero_context_patch(diff_patch: &str) -> Result { inner_index += 1; } if !parsed_lines.is_empty() { - hunks.push(ParsedPatchHunk { lines: parsed_lines }); + hunks.push(ParsedPatchHunk { + old_start, + old_count: _old_count, + new_start, + new_count: _new_count, + lines: parsed_lines, + }); } index = inner_index; continue; @@ -555,16 +582,328 @@ fn parse_zero_context_patch(diff_patch: &str) -> Result { Ok(ParsedPatch { headers, hunks }) } +pub(super) fn parsed_patch_hunk_id(source: &str, hunk: &ParsedPatchHunk) -> String { + format!( + "{source}:{}:{}:{}:{}", + hunk.old_start, + hunk.old_count, + hunk.new_start, + hunk.new_count + ) +} + +fn split_text_lines(content: &str) -> Vec { + content.lines().map(ToString::to_string).collect() +} + +fn blob_to_lines(blob: git2::Blob<'_>) -> Result, String> { + let content = String::from_utf8(blob.content().to_vec()) + .map_err(|_| "Selected file contents are not valid UTF-8.".to_string())?; + Ok(split_text_lines(&content)) +} + +fn read_head_lines(repo: &Repository, path: &str) -> Result, String> { + let head = match repo.head() { + Ok(head) => head, + Err(_) => return Ok(Vec::new()), + }; + let tree = head.peel_to_tree().map_err(|e| e.to_string())?; + let entry = match tree.get_path(Path::new(path)) { + Ok(entry) => entry, + Err(_) => return Ok(Vec::new()), + }; + let blob = repo.find_blob(entry.id()).map_err(|e| e.to_string())?; + blob_to_lines(blob) +} + +fn read_index_lines(repo: &Repository, path: &str) -> Result, String> { + let index = repo.index().map_err(|e| e.to_string())?; + let entry = match index.get_path(Path::new(path), 0) { + Some(entry) => entry, + None => return Ok(Vec::new()), + }; + let blob = repo.find_blob(entry.id).map_err(|e| e.to_string())?; + blob_to_lines(blob) +} + +fn read_worktree_lines(repo_root: &Path, path: &str) -> Result, String> { + let full_path = repo_root.join(path); + let data = match fs::read(&full_path) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Failed to read selected worktree file {}: {error}", + full_path.display() + )); + } + }; + let content = String::from_utf8(data) + .map_err(|_| "Selected file contents are not valid UTF-8.".to_string())?; + Ok(split_text_lines(&content)) +} + +fn load_selection_source_file_context( + repo_root: &Path, + path: &str, + source: &str, +) -> Result { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + match source { + "unstaged" => Ok(SelectionSourceFileContext { + old_lines: read_index_lines(&repo, path)?, + new_lines: read_worktree_lines(repo_root, path)?, + }), + "staged" => Ok(SelectionSourceFileContext { + old_lines: read_head_lines(&repo, path)?, + new_lines: read_index_lines(&repo, path)?, + }), + _ => Err("Invalid selection source.".to_string()), + } +} + +fn context_before_old_end(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.old_anchor, + SelectionLineType::Del => line.old_anchor.saturating_sub(1), + } +} + +fn context_before_new_end(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.new_anchor.saturating_sub(1), + SelectionLineType::Del => line.new_anchor, + } +} + +fn context_after_old_start(line: &ParsedPatchLine) -> usize { + line.old_anchor + 1 +} + +fn context_after_new_start(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.new_anchor + 1, + SelectionLineType::Del => line.new_anchor, + } +} + +fn selected_old_start(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.old_anchor + 1, + SelectionLineType::Del => line.old_anchor, + } +} + +fn selected_new_start(line: &ParsedPatchLine) -> usize { + line.new_anchor +} + +fn shared_suffix_context_len( + old_lines: &[String], + new_lines: &[String], + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) -> usize { + if old_start == 0 || new_start == 0 || old_end < old_start || new_end < new_start { + return 0; + } + let old_count = old_end - old_start + 1; + let new_count = new_end - new_start + 1; + let max_count = old_count.min(new_count); + let mut count = 0usize; + while count < max_count { + let old_index = old_end.saturating_sub(count); + let new_index = new_end.saturating_sub(count); + if old_index == 0 || new_index == 0 { + break; + } + let Some(old_line) = old_lines.get(old_index - 1) else { + break; + }; + let Some(new_line) = new_lines.get(new_index - 1) else { + break; + }; + if old_line != new_line { + break; + } + count += 1; + } + count +} + +fn shared_prefix_context_len( + old_lines: &[String], + new_lines: &[String], + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) -> usize { + if old_start == 0 || new_start == 0 || old_end < old_start || new_end < new_start { + return 0; + } + let old_count = old_end - old_start + 1; + let new_count = new_end - new_start + 1; + let max_count = old_count.min(new_count); + let mut count = 0usize; + while count < max_count { + let old_index = old_start + count; + let new_index = new_start + count; + let Some(old_line) = old_lines.get(old_index - 1) else { + break; + }; + let Some(new_line) = new_lines.get(new_index - 1) else { + break; + }; + if old_line != new_line { + break; + } + count += 1; + } + count +} + +fn append_full_hunk_with_context( + output: &mut Vec, + parsed: &ParsedPatch, + hunk_index: usize, + old_lines: &[String], + new_lines: &[String], +) { + let hunk = &parsed.hunks[hunk_index]; + let Some(first) = hunk.lines.first() else { + return; + }; + let Some(last) = hunk.lines.last() else { + return; + }; + + let previous_last = hunk_index + .checked_sub(1) + .and_then(|index| parsed.hunks.get(index)) + .and_then(|previous| previous.lines.last()); + let next_first = parsed + .hunks + .get(hunk_index + 1) + .and_then(|next| next.lines.first()); + + let available_before_old_start = previous_last + .map(context_after_old_start) + .unwrap_or(1); + let available_before_new_start = previous_last + .map(context_after_new_start) + .unwrap_or(1); + let available_before_old_end = context_before_old_end(first); + let available_before_new_end = context_before_new_end(first); + let before_count = shared_suffix_context_len( + old_lines, + new_lines, + available_before_old_start, + available_before_old_end, + available_before_new_start, + available_before_new_end, + ); + let before_old_start = if before_count > 0 { + available_before_old_end - before_count + 1 + } else { + 0 + }; + let before_new_start = if before_count > 0 { + available_before_new_end - before_count + 1 + } else { + 0 + }; + + let available_after_old_start = context_after_old_start(last); + let available_after_new_start = context_after_new_start(last); + let available_after_old_end = next_first + .map(context_before_old_end) + .unwrap_or(old_lines.len()); + let available_after_new_end = next_first + .map(context_before_new_end) + .unwrap_or(new_lines.len()); + let after_count = shared_prefix_context_len( + old_lines, + new_lines, + available_after_old_start, + available_after_old_end, + available_after_new_start, + available_after_new_end, + ); + + let old_count = before_count + + hunk + .lines + .iter() + .filter(|line| line.line_type == SelectionLineType::Del) + .count() + + after_count; + let new_count = before_count + + hunk + .lines + .iter() + .filter(|line| line.line_type == SelectionLineType::Add) + .count() + + after_count; + + let old_start = if before_count > 0 { + before_old_start + } else { + selected_old_start(first) + }; + let new_start = if before_count > 0 { + before_new_start + } else { + selected_new_start(first) + }; + + output.push(format!( + "@@ -{},{} +{},{} @@", + old_start, old_count, new_start, new_count + )); + + if before_count > 0 { + for offset in 0..before_count { + if let Some(line) = old_lines.get(before_old_start + offset - 1) { + output.push(format!(" {}", line)); + } + } + } + + for line in &hunk.lines { + let prefix = if line.line_type == SelectionLineType::Add { + '+' + } else { + '-' + }; + output.push(format!("{prefix}{}", line.text)); + } + + if after_count > 0 { + for offset in 0..after_count { + if let Some(line) = old_lines.get(available_after_old_start + offset - 1) { + output.push(format!(" {}", line)); + } + } + } +} + fn build_selected_patch( diff_patch: &str, selected_lines: &HashSet, + file_context: &SelectionSourceFileContext, ) -> Result<(String, usize), String> { let parsed = parse_zero_context_patch(diff_patch)?; let mut output = parsed.headers.clone(); let mut applied_line_count = 0usize; + let debug_enabled = git_selection_debug_enabled(); + let mut debug_hunks: Vec = Vec::new(); - for hunk in &parsed.hunks { + for (hunk_index, hunk) in parsed.hunks.iter().enumerate() { let mut group: Vec<&ParsedPatchLine> = Vec::new(); + let mut matched_lines: Vec = Vec::new(); let flush_group = |group: &mut Vec<&ParsedPatchLine>, output: &mut Vec| { if group.is_empty() { return; @@ -593,6 +932,19 @@ fn build_selected_patch( group.clear(); }; + let selected_count = hunk + .lines + .iter() + .filter(|line| { + selected_lines.contains(&SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + }) + .count(); + for line in &hunk.lines { let key = SelectionLineKey { line_type: line.line_type, @@ -603,11 +955,40 @@ fn build_selected_patch( if selected_lines.contains(&key) { group.push(line); applied_line_count += 1; + if debug_enabled { + matched_lines.push(json!({ + "type": if line.line_type == SelectionLineType::Add { "add" } else { "del" }, + "oldLine": line.old_line, + "newLine": line.new_line, + "oldAnchor": line.old_anchor, + "newAnchor": line.new_anchor, + "text": line.text, + })); + } } else { flush_group(&mut group, &mut output); } } - flush_group(&mut group, &mut output); + if selected_count == hunk.lines.len() && selected_count > 0 { + group.clear(); + append_full_hunk_with_context( + &mut output, + &parsed, + hunk_index, + &file_context.old_lines, + &file_context.new_lines, + ); + } else { + flush_group(&mut group, &mut output); + } + if debug_enabled { + debug_hunks.push(json!({ + "hunkIndex": hunk_index, + "hunkLineCount": hunk.lines.len(), + "matchedLineCount": matched_lines.len(), + "matchedLines": matched_lines, + })); + } } if applied_line_count == 0 { @@ -618,6 +999,18 @@ fn build_selected_patch( if !patch.ends_with('\n') { patch.push('\n'); } + if debug_enabled { + git_selection_debug_log( + "build-selected-patch", + json!({ + "selectedLineKeyCount": selected_lines.len(), + "appliedLineCount": applied_line_count, + "outputLineCount": patch.lines().count(), + "hunks": debug_hunks, + "patch": patch, + }), + ); + } Ok((patch, applied_line_count)) } @@ -672,6 +1065,52 @@ async fn apply_cached_patch(repo_root: &Path, patch: &str, reverse: bool) -> Res Err(detail.to_string()) } +fn selection_source_from_display_hunk_id(display_hunk_id: &str) -> Result<&str, String> { + let source = display_hunk_id + .split(':') + .next() + .ok_or_else(|| "Invalid display hunk ID.".to_string())?; + match source { + "staged" | "unstaged" => Ok(source), + _ => Err("Invalid display hunk ID source.".to_string()), + } +} + +fn build_display_hunk_patch( + diff_patch: &str, + source: &str, + display_hunk_id: &str, + file_context: &SelectionSourceFileContext, +) -> Result<(String, usize), String> { + let parsed = parse_zero_context_patch(diff_patch)?; + let Some((hunk_index, hunk)) = parsed + .hunks + .iter() + .enumerate() + .find(|(_, hunk)| parsed_patch_hunk_id(source, hunk) == display_hunk_id) + else { + return Err( + "Display hunk no longer matches the current diff. Refresh and try again.".to_string(), + ); + }; + + let mut output = parsed.headers.clone(); + append_full_hunk_with_context( + &mut output, + &parsed, + hunk_index, + &file_context.old_lines, + &file_context.new_lines, + ); + + let mut patch = output.join("\n"); + if !patch.ends_with('\n') { + patch.push('\n'); + } + + Ok((patch, hunk.lines.len())) +} + pub(super) async fn stage_git_selection_inner( workspaces: &Mutex>, workspace_id: String, @@ -717,13 +1156,158 @@ pub(super) async fn stage_git_selection_inner( if source_patch.trim().is_empty() { return Err("No changes available for the requested selection source.".to_string()); } + let debug_source_hunks = if git_selection_debug_enabled() { + parse_zero_context_patch(&source_patch).ok().map(|parsed| { + parsed + .hunks + .iter() + .enumerate() + .map(|(index, hunk)| { + let first = hunk.lines.first(); + let last = hunk.lines.last(); + json!({ + "hunkIndex": index, + "lineCount": hunk.lines.len(), + "firstOldLine": first.and_then(|line| line.old_line), + "firstNewLine": first.and_then(|line| line.new_line), + "lastOldLine": last.and_then(|line| line.old_line), + "lastNewLine": last.and_then(|line| line.new_line), + }) + }) + .collect::>() + }) + } else { + None + }; let mut selected_lines = HashSet::new(); for line in &lines { selected_lines.insert(SelectionLineKey::try_from(line)?); } + if git_selection_debug_enabled() { + git_selection_debug_log( + "stage-selection-request", + json!({ + "workspaceId": workspace_id, + "path": path, + "op": op, + "source": source, + "rawLineCount": lines.len(), + "dedupedLineCount": selected_lines.len(), + "selectedLines": lines, + "sourceHunks": debug_source_hunks.unwrap_or_default(), + }), + ); + } + + let file_context = load_selection_source_file_context(&repo_root, action_path.as_str(), &source)?; + let (selected_patch, applied_line_count) = + build_selected_patch(&source_patch, &selected_lines, &file_context)?; + if git_selection_debug_enabled() { + git_selection_debug_log( + "stage-selection-apply", + json!({ + "path": path, + "reverseApply": reverse_apply, + "appliedLineCount": applied_line_count, + "selectedPatchLineCount": selected_patch.lines().count(), + }), + ); + } + apply_cached_patch(&repo_root, &selected_patch, reverse_apply).await?; + if git_selection_debug_enabled() { + let cached_after_apply = String::from_utf8_lossy( + &git_core::run_git_diff( + &repo_root.to_path_buf(), + &["diff", "--cached", "--no-color", "-U0", "--", action_path.as_str()], + ) + .await?, + ) + .to_string(); + let unstaged_after_apply = String::from_utf8_lossy( + &git_core::run_git_diff( + &repo_root.to_path_buf(), + &["diff", "--no-color", "-U0", "--", action_path.as_str()], + ) + .await?, + ) + .to_string(); + git_selection_debug_log( + "stage-selection-post-apply", + json!({ + "path": path, + "op": op, + "source": source, + "cachedDiff": cached_after_apply, + "unstagedDiff": unstaged_after_apply, + }), + ); + } + + Ok(GitSelectionApplyResult { + applied: true, + applied_line_count, + warning: None, + }) +} + +pub(super) async fn apply_git_display_hunk_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, + display_hunk_id: String, +) -> Result { + let source = selection_source_from_display_hunk_id(&display_hunk_id)?; + let op = match source { + "unstaged" => "stage", + "staged" => "unstage", + _ => unreachable!(), + }; + + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let action_paths = action_paths_for_file(&repo_root, &path); + if action_paths.len() != 1 { + return Err("Line-level stage/unstage for renamed paths is not supported yet.".to_string()); + } + let action_path = action_paths[0].clone(); + + let (diff_args, reverse_apply): (&[&str], bool) = match source { + "unstaged" => (&["diff", "--no-color", "-U0", "--"], false), + "staged" => (&["diff", "--cached", "--no-color", "-U0", "--"], true), + _ => unreachable!(), + }; + + let mut args = diff_args.to_vec(); + args.push(action_path.as_str()); + let source_patch = String::from_utf8_lossy( + &git_core::run_git_diff(&repo_root.to_path_buf(), &args).await?, + ) + .to_string(); + if source_patch.trim().is_empty() { + return Err("No changes available for the requested display hunk.".to_string()); + } + + let file_context = + load_selection_source_file_context(&repo_root, action_path.as_str(), source)?; + let (selected_patch, applied_line_count) = + build_display_hunk_patch(&source_patch, source, &display_hunk_id, &file_context)?; + + if git_selection_debug_enabled() { + git_selection_debug_log( + "display-hunk-apply", + json!({ + "workspaceId": workspace_id, + "path": path, + "displayHunkId": display_hunk_id, + "op": op, + "source": source, + "reverseApply": reverse_apply, + "appliedLineCount": applied_line_count, + }), + ); + } - let (selected_patch, applied_line_count) = build_selected_patch(&source_patch, &selected_lines)?; apply_cached_patch(&repo_root, &selected_patch, reverse_apply).await?; Ok(GitSelectionApplyResult { @@ -1115,7 +1699,78 @@ pub(super) async fn create_git_branch_inner( #[cfg(test)] mod tests { - use super::{gh_repo_create_args, validate_branch_name}; + use super::{ + build_selected_patch, gh_repo_create_args, parse_zero_context_patch, + SelectionLineKey, SelectionSourceFileContext, validate_branch_name, + }; + use std::{ + collections::HashSet, + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn run_git(repo_root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("failed to run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).to_string() + } + + fn run_git_with_stdin(repo_root: &Path, args: &[&str], stdin_text: &str) { + let mut child = Command::new("git") + .args(args) + .current_dir(repo_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn git"); + child + .stdin + .as_mut() + .expect("missing git stdin") + .write_all(stdin_text.as_bytes()) + .expect("failed to write git stdin"); + let output = child.wait_with_output().expect("failed to wait for git"); + assert!( + output.status.success(), + "git {:?} failed: {}\n{}", + args, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + } + + fn create_temp_repo() -> PathBuf { + let unique = format!( + "codex_monitor_git_select_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock drift") + .as_nanos() + ); + let repo_root = std::env::temp_dir().join(unique); + fs::create_dir_all(&repo_root).expect("failed to create temp repo"); + run_git(&repo_root, &["init"]); + run_git(&repo_root, &["config", "user.name", "Codex Monitor Tests"]); + run_git( + &repo_root, + &["config", "user.email", "codex-monitor-tests@example.com"], + ); + repo_root + } #[test] fn validate_branch_name_rejects_repeated_slashes() { @@ -1147,4 +1802,222 @@ mod tests { vec!["repo", "create", "owner/repo", "--public"] ); } + + #[test] + fn build_selected_patch_targets_first_identical_addition_hunk() { + let repo_root = create_temp_repo(); + let file_path = repo_root.join("CardView.swift"); + + let baseline = "pre\nanchor-one\nmid\nanchor-two\npost\n"; + fs::write(&file_path, baseline).expect("failed to write baseline"); + run_git(&repo_root, &["add", "--", "CardView.swift"]); + run_git( + &repo_root, + &["commit", "-m", "Initial baseline", "--quiet"], + ); + + let changed = "pre\nanchor-one\n.padding(6)\n.background(Color.black.opacity(0.35), in:\nCircle())\n.shadow(color: .black.opacity(0.35), radius:\n4, x: 0, y: 2)\nmid\nanchor-two\n.padding(6)\n.background(Color.black.opacity(0.35), in:\nCircle())\n.shadow(color: .black.opacity(0.35), radius:\n4, x: 0, y: 2)\npost\n"; + fs::write(&file_path, changed).expect("failed to write changed file"); + + let source_patch = run_git(&repo_root, &["diff", "--no-color", "-U0", "--", "CardView.swift"]); + let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch"); + assert!( + parsed.hunks.len() >= 2, + "expected at least two hunks in source patch" + ); + + let first_hunk = &parsed.hunks[0]; + let second_hunk = &parsed.hunks[1]; + let selected_lines: HashSet = first_hunk + .lines + .iter() + .map(|line| SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + .collect(); + + let file_context = SelectionSourceFileContext { + old_lines: baseline.lines().map(ToString::to_string).collect(), + new_lines: changed.lines().map(ToString::to_string).collect(), + }; + let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context) + .expect("selection patch failed"); + + let second_header = format!( + "@@ -{},0 +{},{} @@", + second_hunk.lines[0].old_anchor, + second_hunk.lines[0].new_anchor, + second_hunk.lines.len() + ); + let first_header = format!( + "@@ -{},0 +{},{} @@", + first_hunk.lines[0].old_anchor, + first_hunk.lines[0].new_anchor, + first_hunk.lines.len() + ); + assert!( + selected_patch.contains(" anchor-one"), + "selection patch did not include first-hunk context: {selected_patch}" + ); + assert!( + selected_patch.contains(" mid"), + "selection patch did not include trailing context for first hunk: {selected_patch}" + ); + assert!( + selected_patch.matches("+.padding(6)").count() == 1, + "selection patch included duplicate selected additions: {selected_patch}" + ); + + run_git_with_stdin( + &repo_root, + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"], + &selected_patch, + ); + + let cached_patch = run_git( + &repo_root, + &["diff", "--cached", "--no-color", "-U0", "--", "CardView.swift"], + ); + assert!( + cached_patch.contains(&first_header), + "cached patch did not stage first hunk: {cached_patch}" + ); + assert!( + !cached_patch.contains(&second_header), + "cached patch staged second hunk unexpectedly: {cached_patch}" + ); + + fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo"); + } + + #[test] + fn build_selected_patch_targets_first_identical_swiftui_overlay_hunk() { + let repo_root = create_temp_repo(); + let file_path = repo_root.join("CardsMediaB25ContentView.swift"); + + let baseline = r#"struct CardsMediaB25View: CardsSwiftUIContentViewInitializable { + func mediaOverlay(for type: OverlayType) { + if type.contains(.video) { + Image("video_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + } else if type.contains(.audio) { + Image("audio_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + } + } +} +"#; + fs::write(&file_path, baseline).expect("failed to write baseline"); + run_git(&repo_root, &["add", "--", "CardsMediaB25ContentView.swift"]); + run_git( + &repo_root, + &["commit", "-m", "Initial baseline", "--quiet"], + ); + + let changed = r#"struct CardsMediaB25View: CardsSwiftUIContentViewInitializable { + func mediaOverlay(for type: OverlayType) { + if type.contains(.video) { + Image("video_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + .padding(6) + .background(Color.black.opacity(0.35), in: Circle()) + .shadow(color: .black.opacity(0.35), radius: 4, x: 0, y: 2) + } else if type.contains(.audio) { + Image("audio_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + .padding(6) + .background(Color.black.opacity(0.35), in: Circle()) + .shadow(color: .black.opacity(0.35), radius: 4, x: 0, y: 2) + } + } +} +"#; + fs::write(&file_path, changed).expect("failed to write changed file"); + + let source_patch = run_git( + &repo_root, + &["diff", "--no-color", "-U0", "--", "CardsMediaB25ContentView.swift"], + ); + let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch"); + assert_eq!(parsed.hunks.len(), 2, "expected two identical hunks"); + + let first_hunk = &parsed.hunks[0]; + let second_hunk = &parsed.hunks[1]; + let selected_lines: HashSet = first_hunk + .lines + .iter() + .map(|line| SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + .collect(); + + let file_context = SelectionSourceFileContext { + old_lines: baseline.lines().map(ToString::to_string).collect(), + new_lines: changed.lines().map(ToString::to_string).collect(), + }; + let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context) + .expect("selection patch failed"); + assert!( + selected_patch.contains(r#" Image("video_overlay")"#), + "selection patch did not anchor to the video block: {selected_patch}" + ); + assert!( + selected_patch.matches("+ .padding(6)").count() == 1, + "selection patch included duplicate selected additions: {selected_patch}" + ); + + run_git_with_stdin( + &repo_root, + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"], + &selected_patch, + ); + + let first_header = format!( + "@@ -{},0 +{},{} @@", + first_hunk.lines[0].old_anchor, + first_hunk.lines[0].new_anchor, + first_hunk.lines.len() + ); + let second_header = format!( + "@@ -{},0 +{},{} @@", + second_hunk.lines[0].old_anchor, + second_hunk.lines[0].new_anchor, + second_hunk.lines.len() + ); + let cached_patch = run_git( + &repo_root, + &[ + "diff", + "--cached", + "--no-color", + "-U0", + "--", + "CardsMediaB25ContentView.swift", + ], + ); + assert!( + cached_patch.contains(&first_header), + "cached patch did not stage first SwiftUI hunk: {cached_patch}" + ); + assert!( + !cached_patch.contains(&second_header), + "cached patch staged second SwiftUI hunk unexpectedly: {cached_patch}" + ); + + fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo"); + } } diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 8f713dfa8..8f6139a5a 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -13,15 +13,51 @@ use crate::git_utils::{ diff_patch_to_string, diff_stats_for_path, image_mime_type, resolve_git_root, }; use crate::shared::process_core::std_command; -use crate::types::{AppSettings, GitCommitDiff, GitFileDiff, GitFileStatus, WorkspaceEntry}; +use crate::types::{ + AppSettings, GitCommitDiff, GitFileDiff, GitFileDisplayHunk, GitFileStatus, WorkspaceEntry, +}; use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; +use super::commands::{parse_zero_context_patch, parsed_patch_hunk_id, ParsedPatchHunk}; use super::context::workspace_entry_for_id; const INDEX_SKIP_WORKTREE_FLAG: u16 = 0x4000; const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; const MAX_TEXT_DIFF_BYTES: usize = 2 * 1024 * 1024; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParsedDisplayLineType { + Add, + Del, + Context, + Hunk, + Meta, +} + +#[derive(Debug, Clone)] +struct ParsedDisplayLine { + line_type: ParsedDisplayLineType, + old_line: Option, + new_line: Option, +} + +#[derive(Debug, Clone)] +struct ParsedDisplaySegment { + lines: Vec<(usize, ParsedDisplayLine)>, +} + +#[derive(Debug, Clone, Copy)] +struct DisplayLineRange { + start: usize, + end: usize, +} + +#[derive(Debug, Clone, Copy)] +struct DisplayMatchRange { + old_range: Option, + new_range: Option, +} + fn encode_image_base64(data: &[u8]) -> Option { if data.len() > MAX_IMAGE_BYTES { return None; @@ -170,6 +206,7 @@ fn source_diff_for_path( args.push("--cached"); } args.push("--no-color"); + args.push("-U0"); if ignore_whitespace_changes { args.push("-w"); } @@ -189,6 +226,501 @@ fn source_diff_for_path( Some(String::from_utf8_lossy(&output.stdout).to_string()) } +fn parse_display_diff(diff: &str) -> Vec { + let mut parsed = Vec::new(); + let mut old_line = 0usize; + let mut new_line = 0usize; + let mut in_hunk = false; + + for raw_line in diff.split('\n') { + if let Some((old_start, _, new_start, _)) = super::commands::parse_hunk_header(raw_line) { + old_line = old_start; + new_line = new_start; + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Hunk, + old_line: None, + new_line: None, + }); + in_hunk = true; + continue; + } + + if !in_hunk { + continue; + } + + if raw_line.starts_with('+') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Add, + old_line: None, + new_line: Some(new_line), + }); + new_line += 1; + continue; + } + + if raw_line.starts_with('-') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Del, + old_line: Some(old_line), + new_line: None, + }); + old_line += 1; + continue; + } + + if raw_line.starts_with(' ') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Context, + old_line: Some(old_line), + new_line: Some(new_line), + }); + old_line += 1; + new_line += 1; + continue; + } + + if raw_line.starts_with('\\') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Meta, + old_line: None, + new_line: None, + }); + } + } + + parsed +} + +fn build_display_segments(parsed_lines: &[ParsedDisplayLine]) -> Vec { + let mut segments = Vec::new(); + let mut current: Vec<(usize, ParsedDisplayLine)> = Vec::new(); + + for (index, line) in parsed_lines.iter().cloned().enumerate() { + match line.line_type { + ParsedDisplayLineType::Add | ParsedDisplayLineType::Del => current.push((index, line)), + _ => { + if !current.is_empty() { + segments.push(ParsedDisplaySegment { lines: current }); + current = Vec::new(); + } + } + } + } + + if !current.is_empty() { + segments.push(ParsedDisplaySegment { lines: current }); + } + + segments +} + +fn hunk_old_end(hunk: &ParsedPatchHunk) -> Option { + if hunk.old_count == 0 { + None + } else { + Some(hunk.old_start + hunk.old_count - 1) + } +} + +fn hunk_new_end(hunk: &ParsedPatchHunk) -> Option { + if hunk.new_count == 0 { + None + } else { + Some(hunk.new_start + hunk.new_count - 1) + } +} + +fn map_old_to_new_line_clamped(hunks: &[ParsedPatchHunk], old_line: usize) -> usize { + let mut delta = 0isize; + + for hunk in hunks { + if hunk.old_count == 0 { + if old_line < hunk.old_start { + break; + } + delta += hunk.new_count as isize; + continue; + } + + let old_end = hunk.old_start + hunk.old_count - 1; + if old_line < hunk.old_start { + break; + } + if old_line <= old_end { + if hunk.new_count == 0 { + return hunk.new_start; + } + let relative = old_line - hunk.old_start; + return hunk.new_start + relative.min(hunk.new_count - 1); + } + + delta += hunk.new_count as isize - hunk.old_count as isize; + } + + ((old_line as isize) + delta).max(1) as usize +} + +fn map_new_to_old_line_clamped(hunks: &[ParsedPatchHunk], new_line: usize) -> usize { + let mut delta = 0isize; + + for hunk in hunks { + if hunk.new_count == 0 { + let insertion_point = hunk.new_start; + if new_line < insertion_point { + break; + } + delta -= hunk.old_count as isize; + continue; + } + + let new_end = hunk.new_start + hunk.new_count - 1; + if new_line < hunk.new_start { + break; + } + if new_line <= new_end { + if hunk.old_count == 0 { + return hunk.old_start; + } + let relative = new_line - hunk.new_start; + return hunk.old_start + relative.min(hunk.old_count - 1); + } + + delta += hunk.old_count as isize - hunk.new_count as isize; + } + + ((new_line as isize) + delta).max(1) as usize +} + +fn display_match_range_for_staged_hunk( + hunk: &ParsedPatchHunk, + unstaged_hunks: &[ParsedPatchHunk], +) -> DisplayMatchRange { + DisplayMatchRange { + old_range: hunk_old_end(hunk).map(|end| DisplayLineRange { + start: hunk.old_start, + end, + }), + new_range: hunk_new_end(hunk).map(|end| DisplayLineRange { + start: map_old_to_new_line_clamped(unstaged_hunks, hunk.new_start), + end: map_old_to_new_line_clamped(unstaged_hunks, end), + }), + } +} + +fn display_match_range_for_unstaged_hunk( + hunk: &ParsedPatchHunk, + staged_hunks: &[ParsedPatchHunk], +) -> DisplayMatchRange { + DisplayMatchRange { + old_range: hunk_old_end(hunk).map(|end| DisplayLineRange { + start: map_new_to_old_line_clamped(staged_hunks, hunk.old_start), + end: map_new_to_old_line_clamped(staged_hunks, end), + }), + new_range: hunk_new_end(hunk).map(|end| DisplayLineRange { + start: hunk.new_start, + end, + }), + } +} + +fn range_contains(range: DisplayLineRange, line_number: Option) -> bool { + matches!(line_number, Some(line_number) if line_number >= range.start && line_number <= range.end) +} + +fn source_hunk_line_counts(hunk: &ParsedPatchHunk) -> (usize, usize) { + hunk.lines.iter().fold((0usize, 0usize), |(adds, dels), line| { + if line.line_type == super::commands::SelectionLineType::Add { + (adds + 1, dels) + } else { + (adds, dels + 1) + } + }) +} + +fn find_display_hunk_span( + segments: &[ParsedDisplaySegment], + min_start_index: usize, + display_range: DisplayMatchRange, + expected_add_count: usize, + expected_del_count: usize, +) -> Option<(usize, usize, usize)> { + for segment in segments { + let mut matched_indices = Vec::new(); + let mut add_count = 0usize; + let mut del_count = 0usize; + + for (display_index, line) in &segment.lines { + if *display_index < min_start_index { + continue; + } + match line.line_type { + ParsedDisplayLineType::Add => { + if display_range + .new_range + .is_some_and(|range| range_contains(range, line.new_line)) + { + matched_indices.push(*display_index); + add_count += 1; + } + } + ParsedDisplayLineType::Del => { + if display_range + .old_range + .is_some_and(|range| range_contains(range, line.old_line)) + { + matched_indices.push(*display_index); + del_count += 1; + } + } + _ => {} + } + } + + if add_count == expected_add_count && del_count == expected_del_count { + let start = matched_indices.first().copied()?; + let end = matched_indices.last().copied()?; + return Some((start, end, matched_indices.len())); + } + } + + None +} + +fn parse_source_hunks(diff: Option<&str>) -> Vec { + diff.and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + parse_zero_context_patch(diff).ok() + } + }) + .map(|parsed| parsed.hunks) + .unwrap_or_default() +} + +fn build_display_hunks( + diff: &str, + staged_diff: Option<&str>, + unstaged_diff: Option<&str>, +) -> Vec { + let parsed_display_lines = parse_display_diff(diff); + if parsed_display_lines.is_empty() { + return Vec::new(); + } + let display_segments = build_display_segments(&parsed_display_lines); + if display_segments.is_empty() { + return Vec::new(); + } + + let staged_hunks = parse_source_hunks(staged_diff); + let unstaged_hunks = parse_source_hunks(unstaged_diff); + let mut display_hunks = Vec::new(); + + let mut staged_min_start_index = 0usize; + for hunk in &staged_hunks { + let display_range = display_match_range_for_staged_hunk(hunk, &unstaged_hunks); + let (expected_add_count, expected_del_count) = source_hunk_line_counts(hunk); + let Some((start, end, line_count)) = find_display_hunk_span( + &display_segments, + staged_min_start_index, + display_range, + expected_add_count, + expected_del_count, + ) else { + continue; + }; + staged_min_start_index = end.saturating_add(1); + display_hunks.push(GitFileDisplayHunk { + id: parsed_patch_hunk_id("staged", hunk), + source: "staged".to_string(), + action: "unstage".to_string(), + start_display_line_index: start, + end_display_line_index: end, + line_count, + }); + } + + let mut unstaged_min_start_index = 0usize; + for hunk in &unstaged_hunks { + let display_range = display_match_range_for_unstaged_hunk(hunk, &staged_hunks); + let (expected_add_count, expected_del_count) = source_hunk_line_counts(hunk); + let Some((start, end, line_count)) = find_display_hunk_span( + &display_segments, + unstaged_min_start_index, + display_range, + expected_add_count, + expected_del_count, + ) else { + continue; + }; + unstaged_min_start_index = end.saturating_add(1); + display_hunks.push(GitFileDisplayHunk { + id: parsed_patch_hunk_id("unstaged", hunk), + source: "unstaged".to_string(), + action: "stage".to_string(), + start_display_line_index: start, + end_display_line_index: end, + line_count, + }); + } + + display_hunks.sort_by(|left, right| { + left.start_display_line_index + .cmp(&right.start_display_line_index) + .then(left.end_display_line_index.cmp(&right.end_display_line_index)) + .then(left.action.cmp(&right.action)) + .then(left.id.cmp(&right.id)) + }); + + display_hunks +} + +#[cfg(test)] +mod display_hunk_tests { + use super::build_display_hunks; + + #[test] + fn build_display_hunks_preserves_file_order_for_mixed_disjoint_hunks() { + let diff = + "@@ -1,2 +1,4 @@\n line one\n+new staged line\n line two\n+new unstaged line"; + let staged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 1111111..2222222 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,0 +2,1 @@\n", + "+new staged line\n" + ); + let unstaged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 2222222..3333333 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -3,0 +4,1 @@\n", + "+new unstaged line\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:1:0:2:1"); + assert_eq!(display_hunks[0].start_display_line_index, 2); + assert_eq!(display_hunks[0].end_display_line_index, 2); + assert_eq!(display_hunks[1].id, "unstaged:3:0:4:1"); + assert_eq!(display_hunks[1].start_display_line_index, 4); + assert_eq!(display_hunks[1].end_display_line_index, 4); + } + + #[test] + fn build_display_hunks_supports_overlapping_staged_and_unstaged_spans() { + let diff = "@@ -1,1 +1,1 @@\n-old value\n+newer value"; + let staged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 1111111..2222222 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,1 +1,1 @@\n", + "-old value\n", + "+new value\n" + ); + let unstaged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 2222222..3333333 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,1 +1,1 @@\n", + "-new value\n", + "+newer value\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].start_display_line_index, 1); + assert_eq!(display_hunks[0].end_display_line_index, 2); + assert_eq!(display_hunks[1].start_display_line_index, 1); + assert_eq!(display_hunks[1].end_display_line_index, 2); + } + + #[test] + fn build_display_hunks_maps_staged_and_unstaged_insertions_in_file_order() { + let diff = concat!( + "@@ -29,6 +29,17 @@ pub(crate) struct GitSelectionApplyResult {\n", + " pub(crate) warning: Option,\n", + " }\n", + " \n", + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n", + "+#[serde(rename_all = \"camelCase\")]\n", + "+pub(crate) struct GitFileDisplayHunk {\n", + "+ pub(crate) id: String,\n", + "+ pub(crate) source: String,\n", + "+ pub(crate) action: String,\n", + "+ pub(crate) start_display_line_index: usize,\n", + "+ pub(crate) end_display_line_index: usize,\n", + "+ pub(crate) line_count: usize,\n", + "+}\n", + "+\n", + " #[derive(Debug, Serialize, Deserialize, Clone)]\n", + " pub(crate) struct GitFileDiff {\n", + " pub(crate) path: String,\n", + "@@ -37,6 +48,8 @@ pub(crate) struct GitFileDiff {\n", + " pub(crate) staged_diff: Option,\n", + " #[serde(default, rename = \"unstagedDiff\")]\n", + " pub(crate) unstaged_diff: Option,\n", + "+ #[serde(default, rename = \"displayHunks\")]\n", + "+ pub(crate) display_hunks: Vec,\n", + " #[serde(default, rename = \"oldLines\")]\n", + " pub(crate) old_lines: Option>,\n", + " #[serde(default, rename = \"newLines\")]\n" + ); + let staged_diff = concat!( + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n", + "index dfcfa92..1277207 100644\n", + "--- a/src-tauri/src/types.rs\n", + "+++ b/src-tauri/src/types.rs\n", + "@@ -31,0 +32,11 @@ pub(crate) struct GitSelectionApplyResult {\n", + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n", + "+#[serde(rename_all = \"camelCase\")]\n", + "+pub(crate) struct GitFileDisplayHunk {\n", + "+ pub(crate) id: String,\n", + "+ pub(crate) source: String,\n", + "+ pub(crate) action: String,\n", + "+ pub(crate) start_display_line_index: usize,\n", + "+ pub(crate) end_display_line_index: usize,\n", + "+ pub(crate) line_count: usize,\n", + "+}\n", + "+\n" + ); + let unstaged_diff = concat!( + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n", + "index 1277207..4d7914e 100644\n", + "--- a/src-tauri/src/types.rs\n", + "+++ b/src-tauri/src/types.rs\n", + "@@ -50,0 +51,2 @@ pub(crate) struct GitFileDiff {\n", + "+ #[serde(default, rename = \"displayHunks\")]\n", + "+ pub(crate) display_hunks: Vec,\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:31:0:32:11"); + assert_eq!(display_hunks[0].source, "staged"); + assert_eq!(display_hunks[0].action, "unstage"); + assert_eq!(display_hunks[0].line_count, 11); + assert!(display_hunks[0].start_display_line_index <= display_hunks[0].end_display_line_index); + + assert_eq!(display_hunks[1].id, "unstaged:50:0:51:2"); + assert_eq!(display_hunks[1].source, "unstaged"); + assert_eq!(display_hunks[1].action, "stage"); + assert_eq!(display_hunks[1].line_count, 2); + assert!(display_hunks[1].start_display_line_index <= display_hunks[1].end_display_line_index); + + assert!(display_hunks[0].start_display_line_index < display_hunks[1].start_display_line_index); + } +} + fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { let mut current = path.parent(); while let Some(parent) = current { @@ -605,23 +1137,25 @@ pub(super) async fn get_git_diffs_inner( path_status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE); let has_unstaged = path_status.intersects(Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE); - let (staged_diff, unstaged_diff) = if has_staged && has_unstaged { - ( - source_diff_for_path( - &repo_root, - normalized_path.as_str(), - true, - ignore_whitespace_changes, - ), - source_diff_for_path( - &repo_root, - normalized_path.as_str(), - false, - ignore_whitespace_changes, - ), + let staged_diff = if has_staged { + source_diff_for_path( + &repo_root, + normalized_path.as_str(), + true, + ignore_whitespace_changes, ) } else { - (None, None) + None + }; + let unstaged_diff = if has_unstaged { + source_diff_for_path( + &repo_root, + normalized_path.as_str(), + false, + ignore_whitespace_changes, + ) + } else { + None }; let old_lines = if !is_added { @@ -674,6 +1208,7 @@ pub(super) async fn get_git_diffs_inner( diff: String::new(), staged_diff, unstaged_diff, + display_hunks: Vec::new(), old_lines: None, new_lines: None, is_binary: true, @@ -700,11 +1235,14 @@ pub(super) async fn get_git_diffs_inner( if content.trim().is_empty() { continue; } + let display_hunks = + build_display_hunks(&content, staged_diff.as_deref(), unstaged_diff.as_deref()); results.push(GitFileDiff { path: normalized_path, diff: content, staged_diff, unstaged_diff, + display_hunks, old_lines, new_lines, is_binary: false, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dfcfa923b..4d7914ee2 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -29,6 +29,17 @@ pub(crate) struct GitSelectionApplyResult { pub(crate) warning: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitFileDisplayHunk { + pub(crate) id: String, + pub(crate) source: String, + pub(crate) action: String, + pub(crate) start_display_line_index: usize, + pub(crate) end_display_line_index: usize, + pub(crate) line_count: usize, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct GitFileDiff { pub(crate) path: String, @@ -37,6 +48,8 @@ pub(crate) struct GitFileDiff { pub(crate) staged_diff: Option, #[serde(default, rename = "unstagedDiff")] pub(crate) unstaged_diff: Option, + #[serde(default, rename = "displayHunks")] + pub(crate) display_hunks: Vec, #[serde(default, rename = "oldLines")] pub(crate) old_lines: Option>, #[serde(default, rename = "newLines")] diff --git a/src/features/app/hooks/useMainAppGitState.ts b/src/features/app/hooks/useMainAppGitState.ts index c2b17ab78..4c48aa21a 100644 --- a/src/features/app/hooks/useMainAppGitState.ts +++ b/src/features/app/hooks/useMainAppGitState.ts @@ -330,6 +330,7 @@ export function useMainAppGitState({ const { applyWorktreeChanges: handleApplyWorktreeChanges, + applyGitDisplayHunk: handleApplyGitDisplayHunk, createGitHubRepo: handleCreateGitHubRepo, createGitHubRepoLoading, initGitRepo: handleInitGitRepo, @@ -504,6 +505,7 @@ export function useMainAppGitState({ initGitRepoLoading, handleRevertAllGitChanges, handleRevertGitFile, + handleApplyGitDisplayHunk, handleStageGitAll, handleStageGitFile, handleStageGitSelection, diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index 9bcc57c79..bf7994009 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -858,7 +858,7 @@ function buildGitSurface({ onRevertFile: gitState.handleRevertGitFile, stagedPaths: gitState.gitStatus.stagedFiles.map((file) => file.path), unstagedPaths: gitState.gitStatus.unstagedFiles.map((file) => file.path), - onStageSelection: gitState.handleStageGitSelection, + onApplyDisplayHunk: gitState.handleApplyGitDisplayHunk, onActivePathChange: gitState.handleActiveDiffPath, onInsertComposerText: composerWorkspaceState.canInsertComposerText ? composerWorkspaceState.handleInsertComposerText diff --git a/src/features/git/components/GitDiffViewer.test.tsx b/src/features/git/components/GitDiffViewer.test.tsx index 0ac534eb7..d5c739dd1 100644 --- a/src/features/git/components/GitDiffViewer.test.tsx +++ b/src/features/git/components/GitDiffViewer.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { GitFileDisplayHunk } from "../../../types"; import { GitDiffViewer } from "./GitDiffViewer"; vi.mock("@tanstack/react-virtual", () => ({ @@ -72,6 +73,24 @@ afterEach(() => { cleanup(); }); +function displayHunk( + id: string, + source: "staged" | "unstaged", + action: "stage" | "unstage", + startDisplayLineIndex: number, + endDisplayLineIndex: number, + lineCount: number, +): GitFileDisplayHunk { + return { + id, + source, + action, + startDisplayLineIndex, + endDisplayLineIndex, + lineCount, + }; +} + describe("GitDiffViewer", () => { it("inserts a diff line reference into composer when the line '+' action is clicked", () => { const onInsertComposerText = vi.fn(); @@ -130,8 +149,8 @@ describe("GitDiffViewer", () => { expect(rawLines[2]?.className).toContain("diff-viewer-raw-line-del"); }); - it("invokes line-level stage action for local unstaged diffs", async () => { - const onStageSelection = vi.fn(); + it("applies a backend-authored unstaged display hunk in unified view", async () => { + const onApplyDisplayHunk = vi.fn(); render( { path: "src/main.ts", status: "M", diff: "@@ -1,1 +1,2 @@\n line one\n+new line", + unstagedDiff: "@@ -1,0 +2,1 @@\n+new line", + displayHunks: [ + displayHunk("unstaged:1:0:2:1", "unstaged", "stage", 2, 2, 1), + ], }, ]} selectedPath="src/main.ts" @@ -147,32 +170,22 @@ describe("GitDiffViewer", () => { diffStyle="unified" diffSource="local" unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={onApplyDisplayHunk} />, ); fireEvent.click(screen.getByRole("button", { name: "Stage" })); await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledTimes(1); - expect(onStageSelection).toHaveBeenCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "new line", - }, - ], + displayHunkId: "unstaged:1:0:2:1", }); }); }); - it("enables line-level stage actions in split view", async () => { - const onStageSelection = vi.fn(); + it("applies backend-authored display hunks in split view", async () => { + const onApplyDisplayHunk = vi.fn(); render( { path: "src/main.ts", status: "M", diff: "@@ -1,1 +1,2 @@\n line one\n+new line", + unstagedDiff: "@@ -1,0 +2,1 @@\n+new line", + displayHunks: [ + displayHunk("unstaged:1:0:2:1", "unstaged", "stage", 2, 2, 1), + ], }, ]} selectedPath="src/main.ts" @@ -188,42 +205,32 @@ describe("GitDiffViewer", () => { diffStyle="split" diffSource="local" unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={onApplyDisplayHunk} />, ); fireEvent.click(screen.getByRole("button", { name: "Stage" })); await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledTimes(1); - expect(onStageSelection).toHaveBeenCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "new line", - }, - ], + displayHunkId: "unstaged:1:0:2:1", }); }); }); - it("keeps mixed files in-order and offers chunk-level stage/unstage actions", async () => { - const onStageSelection = vi.fn(); + it("renders split-view actions on the additions side for modified hunks", () => { render( { error={null} diffStyle="split" diffSource="local" - stagedPaths={["src/main.ts"]} unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={vi.fn()} />, ); - expect(screen.queryByText("Staged changes")).toBeNull(); - expect(screen.queryByText("Unstaged changes")).toBeNull(); + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(1); + expect( + stageButtons[0]?.closest("[data-display-line-index]")?.getAttribute( + "data-display-line-index", + ), + ).toBe("2"); + expect(stageButtons[0]?.closest(".diff-line-action-group")?.className).toContain( + "diff-line-action-group--before-gutter", + ); + }); - fireEvent.click(screen.getByRole("button", { name: "Unstage" })); - await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledWith({ - path: "src/main.ts", - op: "unstage", - source: "staged", - lines: [ + it("renders split-view actions on the deletions side for deletion-only hunks", () => { + render( + , + ); - await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledTimes(1); - }); - fireEvent.click(screen.getByRole("button", { name: "Stage" })); - await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledTimes(2); - expect(onStageSelection).toHaveBeenLastCalledWith({ - path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(1); + expect( + stageButtons[0]?.closest("[data-display-line-index]")?.getAttribute( + "data-display-line-index", + ), + ).toBe("1"); + expect(stageButtons[0]?.closest(".diff-line-action-group")?.className).toContain( + "diff-line-action-group--after-gutter", + ); + }); + + it("activates addition-only split hunks when hovering the empty side", () => { + render( + , + ); + + const emptySplitLine = document.querySelector( + '.diff-line-empty[data-display-line-index="2"]', + ); + expect(emptySplitLine).toBeTruthy(); + + fireEvent.mouseEnter(emptySplitLine as Element); + + const actionLine = document.querySelector( + '.diff-line.has-line-action[data-display-line-index="2"]', + ); + expect(actionLine?.className).toContain("chunk-action-visible"); + }); + + it("renders both staged and unstaged actions for the mixed types.rs insertion scenario", () => { + render( + ,\n" + + " }\n" + + " \n" + + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n" + + "+#[serde(rename_all = \"camelCase\")]\n" + + "+pub(crate) struct GitFileDisplayHunk {\n" + + "+ pub(crate) id: String,\n" + + "+ pub(crate) source: String,\n" + + "+ pub(crate) action: String,\n" + + "+ pub(crate) start_display_line_index: usize,\n" + + "+ pub(crate) end_display_line_index: usize,\n" + + "+ pub(crate) line_count: usize,\n" + + "+}\n" + + "+\n" + + " #[derive(Debug, Serialize, Deserialize, Clone)]\n" + + " pub(crate) struct GitFileDiff {\n" + + " pub(crate) path: String,\n" + + "@@ -37,6 +48,8 @@ pub(crate) struct GitFileDiff {\n" + + " pub(crate) staged_diff: Option,\n" + + " #[serde(default, rename = \"unstagedDiff\")]\n" + + " pub(crate) unstaged_diff: Option,\n" + + "+ #[serde(default, rename = \"displayHunks\")]\n" + + "+ pub(crate) display_hunks: Vec,\n" + + " #[serde(default, rename = \"oldLines\")]\n" + + " pub(crate) old_lines: Option>,\n" + + " #[serde(default, rename = \"newLines\")]\n", + stagedDiff: + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n" + + "index dfcfa92..1277207 100644\n" + + "--- a/src-tauri/src/types.rs\n" + + "+++ b/src-tauri/src/types.rs\n" + + "@@ -31,0 +32,11 @@ pub(crate) struct GitSelectionApplyResult {\n" + + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n" + + "+#[serde(rename_all = \"camelCase\")]\n" + + "+pub(crate) struct GitFileDisplayHunk {\n" + + "+ pub(crate) id: String,\n" + + "+ pub(crate) source: String,\n" + + "+ pub(crate) action: String,\n" + + "+ pub(crate) start_display_line_index: usize,\n" + + "+ pub(crate) end_display_line_index: usize,\n" + + "+ pub(crate) line_count: usize,\n" + + "+}\n" + + "+\n", + unstagedDiff: + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n" + + "index 1277207..4d7914e 100644\n" + + "--- a/src-tauri/src/types.rs\n" + + "+++ b/src-tauri/src/types.rs\n" + + "@@ -50,0 +51,2 @@ pub(crate) struct GitFileDiff {\n" + + "+ #[serde(default, rename = \"displayHunks\")]\n" + + "+ pub(crate) display_hunks: Vec,\n", + displayHunks: [ + displayHunk("staged:31:0:32:11", "staged", "unstage", 4, 14, 11), + displayHunk("unstaged:50:0:51:2", "unstaged", "stage", 22, 23, 2), + ], + }, + ]} + selectedPath="src-tauri/src/types.rs" + isLoading={false} + error={null} + diffStyle="split" + diffSource="local" + stagedPaths={["src-tauri/src/types.rs"]} + unstagedPaths={["src-tauri/src/types.rs"]} + onApplyDisplayHunk={vi.fn()} + />, + ); + + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + + const nonStartLine = document.querySelector('[data-display-line-index="10"]'); + const startLine = document.querySelector( + '.diff-line.has-line-action[data-display-line-index="4"]', + ); + expect(nonStartLine).toBeTruthy(); + expect(startLine).toBeTruthy(); + + fireEvent.mouseEnter(nonStartLine as Element); + expect(startLine?.className).toContain("chunk-action-visible"); }); - it("renders one hover target per changed chunk", async () => { - const onStageSelection = vi.fn(); + it("keeps mixed staged and unstaged hunks in one file-ordered view", () => { render( { path: "src/main.ts", status: "M", diff: - "@@ -1,3 +1,5 @@\n line one\n+first addition\n line two\n+second addition", + "@@ -1,2 +1,4 @@\n line one\n+new staged line\n line two\n+new unstaged line", + stagedDiff: "@@ -1,1 +1,2 @@\n line one\n+new staged line", + unstagedDiff: "@@ -2,1 +3,2 @@\n line two\n+new unstaged line", + displayHunks: [ + displayHunk("staged:1:1:1:2", "staged", "unstage", 2, 2, 1), + displayHunk("unstaged:2:1:3:2", "unstaged", "stage", 4, 4, 1), + ], }, ]} selectedPath="src/main.ts" @@ -296,51 +441,68 @@ describe("GitDiffViewer", () => { error={null} diffStyle="split" diffSource="local" + stagedPaths={["src/main.ts"]} unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={vi.fn()} />, ); - const stageButtons = screen.getAllByRole("button", { name: "Stage" }); - expect(stageButtons).toHaveLength(2); + expect(screen.queryByText("Staged changes")).toBeNull(); + expect(screen.queryByText("Unstaged changes")).toBeNull(); + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + expect(screen.getByText((_, node) => node?.textContent === "new staged line")).toBeTruthy(); + expect(screen.getByText((_, node) => node?.textContent === "new unstaged line")).toBeTruthy(); + }); - fireEvent.click(stageButtons[0]); - await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledWith({ - path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ + it("targets the correct display hunk id for mixed staged and unstaged spans", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "staged:1:1:1:2", }); }); - fireEvent.click(stageButtons[1]); + fireEvent.click(screen.getByRole("button", { name: "Stage" })); await waitFor(() => { - expect(onStageSelection).toHaveBeenLastCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 4, - text: "second addition", - }, - ], + displayHunkId: "unstaged:2:1:3:2", }); }); }); - it("stages a full contiguous changed chunk with one click", async () => { - const onStageSelection = vi.fn(); + it("splits pure unstaged actions by backend display hunk boundaries", async () => { + const onApplyDisplayHunk = vi.fn(); render( { path: "src/main.ts", status: "M", diff: - "@@ -1,2 +1,5 @@\n line one\n+first addition\n+second addition\n+third addition\n line two", + "@@ -1,1 +1,6 @@\n line one\n+first addition\n+second addition\n+third addition\n+fourth addition\n+fifth addition", + unstagedDiff: + "@@ -1,0 +2,2 @@\n+first addition\n+second addition\n@@ -4,0 +4,3 @@\n+third addition\n+fourth addition\n+fifth addition", + displayHunks: [ + displayHunk("unstaged:1:0:2:2", "unstaged", "stage", 2, 3, 2), + displayHunk("unstaged:4:0:4:3", "unstaged", "stage", 4, 6, 3), + ], }, ]} selectedPath="src/main.ts" @@ -357,45 +525,24 @@ describe("GitDiffViewer", () => { diffStyle="split" diffSource="local" unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={onApplyDisplayHunk} />, ); const stageButtons = screen.getAllByRole("button", { name: "Stage" }); - expect(stageButtons.length).toBeGreaterThan(0); + expect(stageButtons).toHaveLength(2); fireEvent.click(stageButtons[0]); await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "first addition", - }, - { - type: "add", - oldLine: null, - newLine: 3, - text: "second addition", - }, - { - type: "add", - oldLine: null, - newLine: 4, - text: "third addition", - }, - ], + displayHunkId: "unstaged:1:0:2:2", }); }); }); - it("maps mixed-file chunk line coordinates to source-specific diffs", async () => { - const onStageSelection = vi.fn(); + it("keeps repeated identical additions distinct via display hunk ids", async () => { + const onApplyDisplayHunk = vi.fn(); render( { path: "src/main.ts", status: "M", diff: - "@@ -1,2 +1,4 @@\n line one\n+new staged line\n line two\n+new unstaged line", - stagedDiff: "@@ -1,1 +1,2 @@\n line one\n+new staged line", - unstagedDiff: "@@ -1,1 +1,2 @@\n line two\n+new unstaged line", + "@@ -1,6 +1,12 @@\n line one\n+repeat one\n+repeat two\n+repeat three\n line two\n line three\n+repeat one\n+repeat two\n+repeat three\n line four", + unstagedDiff: + "@@ -1,0 +2,3 @@\n+repeat one\n+repeat two\n+repeat three\n@@ -6,0 +7,3 @@\n+repeat one\n+repeat two\n+repeat three", + displayHunks: [ + displayHunk("unstaged:1:0:2:3", "unstaged", "stage", 2, 4, 3), + displayHunk("unstaged:6:0:7:3", "unstaged", "stage", 7, 9, 3), + ], }, ]} selectedPath="src/main.ts" @@ -413,42 +564,46 @@ describe("GitDiffViewer", () => { error={null} diffStyle="split" diffSource="local" - stagedPaths={["src/main.ts"]} unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={onApplyDisplayHunk} />, ); - fireEvent.click(screen.getByRole("button", { name: "Stage" })); + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(2); + + fireEvent.click(stageButtons[0]); await waitFor(() => { - expect(onStageSelection).toHaveBeenLastCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "new unstaged line", - }, - ], + displayHunkId: "unstaged:1:0:2:3", + }); + }); + + fireEvent.click(stageButtons[1]); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:6:0:7:3", }); }); }); - it("preserves empty added lines in mixed staged/unstaged chunk actions", async () => { - const onStageSelection = vi.fn(); + it("renders overlapping staged and unstaged actions at the same visible span", async () => { + const onApplyDisplayHunk = vi.fn(); render( { diffSource="local" stagedPaths={["src/main.ts"]} unstagedPaths={["src/main.ts"]} - onStageSelection={onStageSelection} + onApplyDisplayHunk={onApplyDisplayHunk} />, ); + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); await waitFor(() => { - expect(onStageSelection).toHaveBeenCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ path: "src/main.ts", - op: "unstage", - source: "staged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 2, - text: "", - }, - ], + displayHunkId: "staged:1:1:1:1", }); }); fireEvent.click(screen.getByRole("button", { name: "Stage" })); await waitFor(() => { - expect(onStageSelection).toHaveBeenLastCalledWith({ + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ path: "src/main.ts", - op: "stage", - source: "unstaged", - lines: [ - { - type: "add", - oldLine: null, - newLine: 4, - text: "", - }, - { - type: "add", - oldLine: null, - newLine: 5, - text: "import c", - }, - ], + displayHunkId: "unstaged:1:1:1:1", }); }); }); diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index d7f71932d..56f055073 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -7,11 +7,7 @@ import GitCommitHorizontal from "lucide-react/dist/esm/icons/git-commit-horizont import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import type { ParsedDiffLine } from "../../../utils/diff"; import { workerFactory } from "../../../utils/diffsWorker"; -import type { - GitSelectionLine, - PullRequestReviewIntent, - PullRequestSelectionRange, -} from "../../../types"; +import type { PullRequestReviewIntent, PullRequestSelectionRange } from "../../../types"; import { DIFF_VIEWER_HIGHLIGHTER_OPTIONS, } from "../../design-system/diff/diffViewerTheme"; @@ -33,6 +29,24 @@ function isSelectableLine( return line.type === "add" || line.type === "del" || line.type === "context"; } +function gitSelectionDebugEnabled() { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem("codexMonitor.gitSelectionDebug") === "1"; + } catch { + return false; + } +} + +function gitSelectionDebugLog(event: string, payload: unknown) { + if (!gitSelectionDebugEnabled()) { + return; + } + console.debug("[git-selection]", event, payload); +} + function findSelectionLineIndex( parsedLines: ParsedDiffLine[], lineNumber: number, @@ -149,7 +163,7 @@ export function GitDiffViewer({ onRevertFile, stagedPaths = [], unstagedPaths = [], - onStageSelection, + onApplyDisplayHunk, onActivePathChange, onInsertComposerText, }: GitDiffViewerProps) { @@ -283,7 +297,7 @@ export function GitDiffViewer({ const resolveLocalLineActionContext = useCallback( (entry: GitDiffViewerItem): LocalLineActionContext | null => { - if (diffSource !== "local" || !onStageSelection) { + if (diffSource !== "local" || !onApplyDisplayHunk) { return null; } const path = entry.path; @@ -292,72 +306,71 @@ export function GitDiffViewer({ if (!hasStaged && !hasUnstaged) { return null; } - if (entry.status === "R") { - return { + const missingStagedDiff = hasStaged && !entry.stagedDiff?.trim(); + const missingUnstagedDiff = hasUnstaged && !entry.unstagedDiff?.trim(); + const displayHunks = entry.displayHunks ?? []; + if (gitSelectionDebugEnabled()) { + gitSelectionDebugLog("display-hunks-context", { + path, + status: entry.status, hasStaged, hasUnstaged, - stagedDiff: entry.stagedDiff, - unstagedDiff: entry.unstagedDiff, + missingStagedDiff, + missingUnstagedDiff, + displayHunkCount: displayHunks.length, + displayHunks: displayHunks.map((hunk) => ({ + id: hunk.id, + source: hunk.source, + action: hunk.action, + startDisplayLineIndex: hunk.startDisplayLineIndex, + endDisplayLineIndex: hunk.endDisplayLineIndex, + lineCount: hunk.lineCount, + })), + }); + } + if (entry.status === "R") { + return { + displayHunks, disabledReason: "Line-level stage/unstage is not supported for renamed files.", }; } - if ( - hasStaged && - hasUnstaged && - (!entry.stagedDiff?.trim() || !entry.unstagedDiff?.trim()) - ) { + if (missingStagedDiff || missingUnstagedDiff || displayHunks.length === 0) { return { - hasStaged, - hasUnstaged, - stagedDiff: entry.stagedDiff, - unstagedDiff: entry.unstagedDiff, + displayHunks, disabledReason: - "Line-level stage/unstage is unavailable until staged and unstaged hunks finish loading.", + "Line-level stage/unstage is unavailable until display hunks finish loading.", }; } return { - hasStaged, - hasUnstaged, - stagedDiff: entry.stagedDiff, - unstagedDiff: entry.unstagedDiff, + displayHunks, }; }, - [diffSource, onStageSelection, stagedPathSet, unstagedPathSet], + [diffSource, onApplyDisplayHunk, stagedPathSet, unstagedPathSet], ); const handleApplyLineAction = useCallback( - async (path: string, action: LocalLineAction, lines: GitSelectionLine[]) => { - if (!onStageSelection || action.disabledReason || lineActionBusy) { - return; - } - if (!lines.length) { - return; - } - const normalizedLines = lines.filter( - (line) => - (line.type === "add" || line.type === "del") && - !( - (line.type === "add" && line.newLine === null) || - (line.type === "del" && line.oldLine === null) - ), - ); - if (!normalizedLines.length) { + async (path: string, action: LocalLineAction) => { + if (!onApplyDisplayHunk || action.disabledReason || lineActionBusy) { return; } setLineActionBusy(true); try { - await onStageSelection({ + gitSelectionDebugLog("handle-apply-line-action", { path, - op: action.op, + displayHunkId: action.id, + op: action.action, source: action.source, - lines: normalizedLines, + }); + await onApplyDisplayHunk({ + path, + displayHunkId: action.id, }); } finally { setLineActionBusy(false); } }, - [lineActionBusy, onStageSelection], + [lineActionBusy, onApplyDisplayHunk], ); const handleInsertLineReference = useCallback( @@ -687,8 +700,8 @@ export function GitDiffViewer({ lineActionBusy={lineActionBusy} onLocalChunkAction={ hasLocalLineActions - ? (lines, action) => { - void handleApplyLineAction(entry.path, action, lines); + ? (action) => { + void handleApplyLineAction(entry.path, action); } : undefined } diff --git a/src/features/git/components/GitDiffViewer.types.ts b/src/features/git/components/GitDiffViewer.types.ts index 19236c860..fe920f281 100644 --- a/src/features/git/components/GitDiffViewer.types.ts +++ b/src/features/git/components/GitDiffViewer.types.ts @@ -1,6 +1,6 @@ import type { + GitFileDisplayHunk, GitSelectionApplyResult, - GitSelectionLine, GitHubPullRequest, GitHubPullRequestComment, PullRequestReviewAction, @@ -16,6 +16,7 @@ export type GitDiffViewerItem = { diff: string; stagedDiff?: string | null; unstagedDiff?: string | null; + displayHunks?: GitFileDisplayHunk[]; oldLines?: string[]; newLines?: string[]; isImage?: boolean; @@ -25,19 +26,14 @@ export type GitDiffViewerItem = { newImageMime?: string | null; }; -export type LocalLineAction = { - op: "stage" | "unstage"; - source: "unstaged" | "staged"; +export type LocalLineAction = Pick & { label: "Stage" | "Unstage"; title: string; disabledReason?: string; }; export type LocalLineActionContext = { - hasStaged: boolean; - hasUnstaged: boolean; - stagedDiff?: string | null; - unstagedDiff?: string | null; + displayHunks: GitFileDisplayHunk[]; disabledReason?: string; }; @@ -75,11 +71,9 @@ export type GitDiffViewerProps = { onRevertFile?: (path: string) => Promise | void; stagedPaths?: string[]; unstagedPaths?: string[]; - onStageSelection?: (options: { + onApplyDisplayHunk?: (options: { path: string; - op: "stage" | "unstage"; - source: "unstaged" | "staged"; - lines: GitSelectionLine[]; + displayHunkId: string; }) => Promise; onActivePathChange?: (path: string) => void; onInsertComposerText?: (text: string) => void; diff --git a/src/features/git/components/GitDiffViewerDiffCard.tsx b/src/features/git/components/GitDiffViewerDiffCard.tsx index 7b11fb5d9..eebf3b26b 100644 --- a/src/features/git/components/GitDiffViewerDiffCard.tsx +++ b/src/features/git/components/GitDiffViewerDiffCard.tsx @@ -8,7 +8,6 @@ import { import { FileDiff } from "@pierre/diffs/react"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import type { - GitSelectionLine, PullRequestReviewAction, PullRequestReviewIntent, } from "../../../types"; @@ -132,10 +131,7 @@ export type DiffCardProps = { onSelectedLinesChange?: (range: SelectedLineRange | null) => void; localLineActionContext?: LocalLineActionContext | null; lineActionBusy?: boolean; - onLocalChunkAction?: ( - lines: GitSelectionLine[], - action: LocalLineAction, - ) => void; + onLocalChunkAction?: (action: LocalLineAction) => void; onComposerLineAction?: (line: ParsedDiffLine, index: number) => void; reviewActions?: PullRequestReviewAction[]; onRunReviewAction?: ( @@ -281,15 +277,17 @@ export const DiffCard = memo(function DiffCard({
) : null}
- {entry.diff.trim().length > 0 && showLocalLineActions ? ( + {showLocalLineActions && localLineActionContext ? ( { - onLocalChunkAction?.(lines, action); + onChunkAction={(action) => { + onLocalChunkAction?.(action); }} /> ) : entry.diff.trim().length > 0 && fileDiff ? ( diff --git a/src/features/git/components/LocalActionDiffBlock.tsx b/src/features/git/components/LocalActionDiffBlock.tsx index 129fe4085..8921db78b 100644 --- a/src/features/git/components/LocalActionDiffBlock.tsx +++ b/src/features/git/components/LocalActionDiffBlock.tsx @@ -1,12 +1,8 @@ import { useMemo, useRef, useState, type MouseEvent } from "react"; -import type { GitSelectionLine } from "../../../types"; -import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; +import type { GitFileDisplayHunk } from "../../../types"; +import type { ParsedDiffLine } from "../../../utils/diff"; import { highlightLine } from "../../../utils/syntax"; -import type { - LocalLineAction, - LocalLineActionContext, -} from "./GitDiffViewer.types"; -import { parseRawDiffLines } from "./GitDiffViewer.utils"; +import type { LocalLineAction } from "./GitDiffViewer.types"; type SplitLineEntry = { line: ParsedDiffLine; @@ -17,53 +13,45 @@ type SplitRow = | { type: "meta"; line: ParsedDiffLine } | { type: "content"; left: SplitLineEntry | null; right: SplitLineEntry | null }; -type ChangeChunk = { - id: string; - startIndex: number; - lineIndices: number[]; - lines: GitSelectionLine[]; - isStaged: boolean; - action: LocalLineAction; +type LineDisplayHunkMeta = { + activeHunkIds: string[]; + startHunkIds: string[]; + hasStaged: boolean; }; -type ChunkMeta = { - isChunkStart: boolean; - chunk?: ChangeChunk; - isStaged: boolean; -}; - -type SelectionSource = "staged" | "unstaged"; - -type SourceMappedLine = { - source: SelectionSource; - line: GitSelectionLine; -}; +type ResolvedDisplayHunkAction = GitFileDisplayHunk & LocalLineAction; type LocalActionDiffBlockProps = { + filePath: string; parsedLines: ParsedDiffLine[]; diffStyle: "split" | "unified"; language?: string | null; - context: LocalLineActionContext; + displayHunks: GitFileDisplayHunk[]; + disabledReason?: string; lineActionBusy?: boolean; - onChunkAction?: (lines: GitSelectionLine[], action: LocalLineAction) => void; + onChunkAction?: (action: LocalLineAction) => void; }; -function isHighlightableLine(line: ParsedDiffLine) { - return line.type === "add" || line.type === "del" || line.type === "context"; +function gitSelectionDebugEnabled() { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem("codexMonitor.gitSelectionDebug") === "1"; + } catch { + return false; + } } -function isChangeLine( - line: ParsedDiffLine, -): line is ParsedDiffLine & { type: "add" | "del" } { - return line.type === "add" || line.type === "del"; +function gitSelectionDebugLog(event: string, payload: unknown) { + if (!gitSelectionDebugEnabled()) { + return; + } + console.debug("[git-selection]", event, payload); } -function parseDiffForViewer(diff: string) { - const parsed = parseDiff(diff); - if (parsed.length > 0) { - return parsed; - } - return parseRawDiffLines(diff); +function isHighlightableLine(line: ParsedDiffLine) { + return line.type === "add" || line.type === "del" || line.type === "context"; } function buildSplitRows(parsed: ParsedDiffLine[]): SplitRow[] { @@ -112,364 +100,74 @@ function buildSplitRows(parsed: ParsedDiffLine[]): SplitRow[] { return rows; } -function buildGitLineSignature(line: GitSelectionLine) { - return `${line.type}:${line.oldLine ?? "null"}:${line.newLine ?? "null"}:${line.text}`; -} - -function buildGitLinePrimaryKey(line: GitSelectionLine) { - const primary = line.type === "add" ? line.newLine : line.oldLine; - return `${line.type}:${primary ?? "null"}:${line.text}`; -} - -function buildGitLineFuzzyKey(line: GitSelectionLine) { - return `${line.type}:${line.text}`; -} - -function primaryLineNumber(line: GitSelectionLine) { - return line.type === "add" ? line.newLine : line.oldLine; -} - -function chunkIdFromEventTarget(target: EventTarget | null) { - const element = - target instanceof Element - ? target - : target instanceof Node - ? target.parentElement - : null; - if (!element) { - return null; - } - const chunkNode = element.closest("[data-chunk-id]"); - return chunkNode?.dataset.chunkId ?? null; -} - -function toGitSelectionLine( - line: ParsedDiffLine & { type: "add" | "del" }, -): GitSelectionLine { - return { - type: line.type, - oldLine: line.oldLine, - newLine: line.newLine, - text: line.text, - }; -} - -type IndexedSourceLines = { - lines: GitSelectionLine[]; - cursor: number; - exactBuckets: Map; - primaryBuckets: Map; - fuzzyBuckets: Map; - exactPointers: Map; - primaryPointers: Map; - fuzzyPointers: Map; -}; - -type SourceMatchCandidate = { - source: SelectionSource; - lineIndex: number; - line: GitSelectionLine; - score: number; - lineDistance: number; - cursorDistance: number; -}; - -function pushBucketIndex( - buckets: Map, - key: string, - lineIndex: number, -) { - const existing = buckets.get(key); - if (existing) { - existing.push(lineIndex); - } else { - buckets.set(key, [lineIndex]); - } -} - -function buildIndexedSourceLines(lines: GitSelectionLine[]): IndexedSourceLines { - const exactBuckets = new Map(); - const primaryBuckets = new Map(); - const fuzzyBuckets = new Map(); - - lines.forEach((line, lineIndex) => { - pushBucketIndex(exactBuckets, buildGitLineSignature(line), lineIndex); - pushBucketIndex(primaryBuckets, buildGitLinePrimaryKey(line), lineIndex); - pushBucketIndex(fuzzyBuckets, buildGitLineFuzzyKey(line), lineIndex); - }); - +function toLocalLineAction( + displayHunk: GitFileDisplayHunk, + disabledReason?: string, +): ResolvedDisplayHunkAction { + const action = displayHunk.action; return { - lines, - cursor: 0, - exactBuckets, - primaryBuckets, - fuzzyBuckets, - exactPointers: new Map(), - primaryPointers: new Map(), - fuzzyPointers: new Map(), + ...displayHunk, + id: displayHunk.id, + source: displayHunk.source, + action, + label: action === "unstage" ? "Unstage" : "Stage", + title: action === "unstage" ? "Unstage this hunk" : "Stage this hunk", + disabledReason, }; } -function nextBucketIndex( - buckets: Map, - pointers: Map, - key: string, - cursor: number, -) { - const indices = buckets.get(key); - if (!indices || !indices.length) { - return null; - } - let pointer = pointers.get(key) ?? 0; - while (pointer < indices.length && indices[pointer] < cursor) { - pointer += 1; - } - pointers.set(key, pointer); - if (pointer >= indices.length) { - return null; - } - return indices[pointer]; -} - -function buildSourceMatchCandidate( - source: SelectionSource, - selectedLine: GitSelectionLine, - sourceLines: IndexedSourceLines, -) { - const exactIndex = nextBucketIndex( - sourceLines.exactBuckets, - sourceLines.exactPointers, - buildGitLineSignature(selectedLine), - sourceLines.cursor, - ); - const primaryIndex = nextBucketIndex( - sourceLines.primaryBuckets, - sourceLines.primaryPointers, - buildGitLinePrimaryKey(selectedLine), - sourceLines.cursor, - ); - const fuzzyIndex = nextBucketIndex( - sourceLines.fuzzyBuckets, - sourceLines.fuzzyPointers, - buildGitLineFuzzyKey(selectedLine), - sourceLines.cursor, - ); - - const index = - exactIndex ?? primaryIndex ?? fuzzyIndex; - if (index === null) { - return null; - } - const line = sourceLines.lines[index]; - const score = index === exactIndex ? 0 : index === primaryIndex ? 1 : 2; - const selectedPrimary = primaryLineNumber(selectedLine); - const candidatePrimary = primaryLineNumber(line); - const lineDistance = - typeof selectedPrimary === "number" && typeof candidatePrimary === "number" - ? Math.abs(selectedPrimary - candidatePrimary) - : Number.MAX_SAFE_INTEGER; - return { - source, - lineIndex: index, - line, - score, - lineDistance, - cursorDistance: index - sourceLines.cursor, - } satisfies SourceMatchCandidate; -} - -function choosePreferredSourceCandidate( - stagedCandidate: SourceMatchCandidate | null, - unstagedCandidate: SourceMatchCandidate | null, - previousSource: SelectionSource | null, -) { - if (!stagedCandidate) { - return unstagedCandidate; - } - if (!unstagedCandidate) { - return stagedCandidate; - } - if (stagedCandidate.score !== unstagedCandidate.score) { - return stagedCandidate.score < unstagedCandidate.score - ? stagedCandidate - : unstagedCandidate; - } - if (stagedCandidate.lineDistance !== unstagedCandidate.lineDistance) { - return stagedCandidate.lineDistance < unstagedCandidate.lineDistance - ? stagedCandidate - : unstagedCandidate; - } - if (stagedCandidate.cursorDistance !== unstagedCandidate.cursorDistance) { - return stagedCandidate.cursorDistance < unstagedCandidate.cursorDistance - ? stagedCandidate - : unstagedCandidate; - } - if (previousSource) { - if (stagedCandidate.source === previousSource) { - return stagedCandidate; - } - if (unstagedCandidate.source === previousSource) { - return unstagedCandidate; - } - } - return stagedCandidate.lineIndex <= unstagedCandidate.lineIndex - ? stagedCandidate - : unstagedCandidate; -} - -function buildSourceMappedLines( - parsedLines: ParsedDiffLine[], - context: LocalLineActionContext, - stagedSourceLines: GitSelectionLine[], - unstagedSourceLines: GitSelectionLine[], -) { - const mappedByIndex = new Map(); - const stagedLookup = buildIndexedSourceLines(stagedSourceLines); - const unstagedLookup = buildIndexedSourceLines(unstagedSourceLines); - let previousSource: SelectionSource | null = null; - - parsedLines.forEach((line, index) => { - if (!isChangeLine(line)) { - return; - } - const selectedLine = toGitSelectionLine(line); - const stagedCandidate = context.hasStaged - ? buildSourceMatchCandidate("staged", selectedLine, stagedLookup) - : null; - const unstagedCandidate = context.hasUnstaged - ? buildSourceMatchCandidate("unstaged", selectedLine, unstagedLookup) - : null; - const chosen = - choosePreferredSourceCandidate( - stagedCandidate, - unstagedCandidate, - previousSource, - ) ?? - (context.hasStaged && !context.hasUnstaged - ? ({ - source: "staged" as const, - lineIndex: -1, - line: selectedLine, - } satisfies Pick) - : context.hasUnstaged && !context.hasStaged - ? ({ - source: "unstaged" as const, - lineIndex: -1, - line: selectedLine, - } satisfies Pick) - : null); - - if (!chosen) { - return; - } +function buildDisplayHunkMeta(displayHunks: ResolvedDisplayHunkAction[]) { + const actionsById = new Map(); + const metaByIndex = new Map(); - if (chosen.lineIndex >= 0) { - if (chosen.source === "staged") { - stagedLookup.cursor = chosen.lineIndex + 1; - } else { - unstagedLookup.cursor = chosen.lineIndex + 1; - } + const ensureMeta = (index: number) => { + const existing = metaByIndex.get(index); + if (existing) { + return existing; } - - mappedByIndex.set(index, { - source: chosen.source, - line: chosen.line, - }); - previousSource = chosen.source; - }); - - return mappedByIndex; -} - -function buildChunks( - parsedLines: ParsedDiffLine[], - sourceLineByIndex: Map, - disabledReason?: string, -) { - const stageActionBase: LocalLineAction = { - op: "stage", - source: "unstaged", - label: "Stage", - title: "Stage this chunk", - disabledReason, - }; - const unstageActionBase: LocalLineAction = { - op: "unstage", - source: "staged", - label: "Unstage", - title: "Unstage this chunk", - disabledReason, + const next: LineDisplayHunkMeta = { + activeHunkIds: [], + startHunkIds: [], + hasStaged: false, + }; + metaByIndex.set(index, next); + return next; }; - const chunkMetaByIndex = new Map(); - const chunks: ChangeChunk[] = []; - let current: ChangeChunk | null = null; - let currentStaged = false; - const flush = () => { - if (!current) { - return; - } - chunks.push(current); - current = null; - }; - - parsedLines.forEach((line, index) => { - if (!isChangeLine(line)) { - flush(); - return; - } - - const sourceMapped = sourceLineByIndex.get(index); - if (!sourceMapped) { - flush(); - return; - } - const isStaged = sourceMapped.source === "staged"; - const gitLine = sourceMapped.line; - - if (!current || currentStaged !== isStaged) { - flush(); - currentStaged = isStaged; - current = { - id: `chunk-${index}`, - startIndex: index, - lineIndices: [index], - lines: [gitLine], - isStaged, - action: isStaged ? unstageActionBase : stageActionBase, - }; - chunkMetaByIndex.set(index, { - isChunkStart: true, - chunk: current, - isStaged, - }); - return; + displayHunks.forEach((displayHunk) => { + actionsById.set(displayHunk.id, displayHunk); + + const startMeta = ensureMeta(displayHunk.startDisplayLineIndex); + startMeta.startHunkIds.push(displayHunk.id); + + for ( + let index = displayHunk.startDisplayLineIndex; + index <= displayHunk.endDisplayLineIndex; + index += 1 + ) { + const meta = ensureMeta(index); + meta.activeHunkIds.push(displayHunk.id); + if (displayHunk.source === "staged") { + meta.hasStaged = true; + } } - - current.lineIndices.push(index); - current.lines.push(gitLine); - chunkMetaByIndex.set(index, { - isChunkStart: false, - chunk: current, - isStaged, - }); }); - flush(); - - return { chunks, chunkMetaByIndex }; + return { actionsById, metaByIndex }; } export function LocalActionDiffBlock({ + filePath, parsedLines, diffStyle, language, - context, + displayHunks, + disabledReason, lineActionBusy = false, onChunkAction, }: LocalActionDiffBlockProps) { - const [hoveredChunkId, setHoveredChunkId] = useState(null); - const hoveredChunkIdRef = useRef(null); + const [hoveredHunkIds, setHoveredHunkIds] = useState([]); + const hoveredHunkIdsRef = useRef([]); const splitRows = useMemo( () => (diffStyle === "split" ? buildSplitRows(parsedLines) : []), [diffStyle, parsedLines], @@ -483,108 +181,137 @@ export function LocalActionDiffBlock({ [language, parsedLines], ); - const stagedSourceLines = useMemo( - () => - context.stagedDiff?.trim() - ? parseDiffForViewer(context.stagedDiff) - .filter(isChangeLine) - .map(toGitSelectionLine) - : context.hasStaged && !context.hasUnstaged - ? parsedLines.filter(isChangeLine).map(toGitSelectionLine) - : [], - [context.hasStaged, context.hasUnstaged, context.stagedDiff, parsedLines], - ); - const unstagedSourceLines = useMemo( + const displayHunkActions = useMemo( () => - context.unstagedDiff?.trim() - ? parseDiffForViewer(context.unstagedDiff) - .filter(isChangeLine) - .map(toGitSelectionLine) - : context.hasUnstaged && !context.hasStaged - ? parsedLines.filter(isChangeLine).map(toGitSelectionLine) - : [], - [context.hasStaged, context.hasUnstaged, context.unstagedDiff, parsedLines], + displayHunks.map((displayHunk) => toLocalLineAction(displayHunk, disabledReason)), + [disabledReason, displayHunks], ); - const sourceLineByIndex = useMemo( - () => - buildSourceMappedLines( - parsedLines, - context, - stagedSourceLines, - unstagedSourceLines, - ), - [context, parsedLines, stagedSourceLines, unstagedSourceLines], + const { actionsById, metaByIndex } = useMemo( + () => buildDisplayHunkMeta(displayHunkActions), + [displayHunkActions], ); - const { chunkMetaByIndex } = useMemo( - () => buildChunks(parsedLines, sourceLineByIndex, context.disabledReason), - [context.disabledReason, parsedLines, sourceLineByIndex], - ); - const updateHoveredChunkId = (nextChunkId: string | null) => { - if (hoveredChunkIdRef.current === nextChunkId) { + const updateHoveredHunkIds = (nextHunkIds: string[]) => { + if ( + hoveredHunkIdsRef.current.length === nextHunkIds.length && + hoveredHunkIdsRef.current.every((value, index) => value === nextHunkIds[index]) + ) { return; } - hoveredChunkIdRef.current = nextChunkId; - setHoveredChunkId(nextChunkId); + hoveredHunkIdsRef.current = nextHunkIds; + setHoveredHunkIds(nextHunkIds); }; - const handleChunkMouseEnter = (chunkId: string) => { - updateHoveredChunkId(chunkId); + + const handleLineMouseEnter = (index: number) => { + updateHoveredHunkIds(metaByIndex.get(index)?.activeHunkIds ?? []); }; - const handleChunkMouseLeave = ( - event: MouseEvent, - chunkId: string, - ) => { - const toChunkId = chunkIdFromEventTarget(event.relatedTarget); - if (toChunkId === chunkId) { - return; + + const handleLineMouseLeave = (event: MouseEvent) => { + const nextTarget = + event.relatedTarget instanceof Element ? event.relatedTarget : null; + const nextLineIndex = nextTarget?.closest("[data-display-line-index]") + ?.dataset.displayLineIndex; + if (typeof nextLineIndex === "string") { + const parsedIndex = Number(nextLineIndex); + if (!Number.isNaN(parsedIndex)) { + updateHoveredHunkIds(metaByIndex.get(parsedIndex)?.activeHunkIds ?? []); + return; + } } - if (hoveredChunkIdRef.current === chunkId) { - updateHoveredChunkId(null); + updateHoveredHunkIds([]); + }; + + const renderActionButtons = ( + actions: LocalLineAction[], + side?: "left" | "right", + ) => { + if (!actions.length) { + return null; } + + return ( +
+ {actions.map((action) => { + const actionHardDisabled = Boolean(action.disabledReason); + const actionBlocked = lineActionBusy || actionHardDisabled; + + return ( + + ); + })} +
+ ); }; const renderLine = ( line: ParsedDiffLine, index: number, side?: "left" | "right", - mirroredChunk?: ChangeChunk, + actionOverride?: LocalLineAction[], ) => { const html = highlightedHtmlByIndex[index] ?? ""; - const chunkMeta = chunkMetaByIndex.get(index); - const chunk = mirroredChunk ?? chunkMeta?.chunk; - const shouldRenderAction = Boolean( - mirroredChunk || (chunkMeta?.isChunkStart && chunkMeta?.chunk), + const meta = metaByIndex.get(index); + const startActions = actionOverride ?? + (meta?.startHunkIds + .map((id) => actionsById.get(id)) + .filter((value): value is LocalLineAction => Boolean(value)) ?? []); + const isLineActive = Boolean( + meta?.activeHunkIds.some((id) => hoveredHunkIds.includes(id)), ); - const isChunkActive = Boolean(chunk && hoveredChunkId === chunk.id); - const isStaged = Boolean(chunkMeta?.isStaged); - const actionHardDisabled = Boolean(chunk?.action.disabledReason) || !chunk; - const actionBlocked = lineActionBusy || actionHardDisabled; const lineClassName = `diff-line diff-line-${line.type}${ - shouldRenderAction ? " has-line-action" : "" - }${shouldRenderAction && isChunkActive ? " chunk-action-visible" : ""}${ - isStaged ? " diff-line-staged" : "" + startActions.length > 0 ? " has-line-action" : "" + }${isLineActive ? " chunk-action-visible" : ""}${ + meta?.hasStaged ? " diff-line-staged" : "" }`; return (
{ - handleChunkMouseEnter(chunk.id); - } - : undefined - } - onMouseLeave={ - chunk - ? (event) => { - handleChunkMouseLeave(event, chunk.id); - } - : undefined - } + onMouseEnter={() => { + handleLineMouseEnter(index); + }} + onMouseLeave={handleLineMouseLeave} >
@@ -595,42 +322,39 @@ export function LocalActionDiffBlock({
- {shouldRenderAction && chunk ? ( - - ) : null} + {renderActionButtons(startActions, side)}
); }; + const renderEmptySplitLine = (hoverIndex?: number) => ( +
{ + handleLineMouseEnter(hoverIndex); + } + : undefined + } + onMouseLeave={typeof hoverIndex === "number" ? handleLineMouseLeave : undefined} + > +
+ + +
+ +
+ ); + if (diffStyle === "split") { return (
{ - updateHoveredChunkId(null); + updateHoveredHunkIds([]); }} > {splitRows.map((row, rowIndex) => { @@ -643,52 +367,42 @@ export function LocalActionDiffBlock({
); } - const leftMeta = row.left - ? chunkMetaByIndex.get(row.left.index) - : undefined; - const rightMeta = row.right - ? chunkMetaByIndex.get(row.right.index) - : undefined; - const mirroredChunk = - leftMeta?.chunk && - rightMeta?.chunk && - leftMeta.chunk.id === rightMeta.chunk.id && - (leftMeta.isChunkStart || rightMeta.isChunkStart) - ? leftMeta.chunk + const leftMeta = row.left ? metaByIndex.get(row.left.index) : undefined; + const rightMeta = row.right ? metaByIndex.get(row.right.index) : undefined; + const rowStartActions = Array.from( + new Set([ + ...(leftMeta?.startHunkIds ?? []), + ...(rightMeta?.startHunkIds ?? []), + ]), + ) + .map((id) => actionsById.get(id)) + .filter((value): value is LocalLineAction => Boolean(value)); + const preferRightActions = Boolean(row.right && row.right.line.type === "add"); + const leftActions = + rowStartActions.length > 0 + ? preferRightActions + ? [] + : row.left + ? rowStartActions + : [] + : undefined; + const rightActions = + rowStartActions.length > 0 + ? preferRightActions || !row.left + ? rowStartActions + : [] : undefined; return (
{row.left ? ( - renderLine( - row.left.line, - row.left.index, - "left", - mirroredChunk, - ) + renderLine(row.left.line, row.left.index, "left", leftActions) ) : ( -
-
- - -
- -
+ renderEmptySplitLine(row.right?.index) )} {row.right ? ( - renderLine( - row.right.line, - row.right.index, - "right", - mirroredChunk, - ) + renderLine(row.right.line, row.right.index, "right", rightActions) ) : ( -
-
- - -
- -
+ renderEmptySplitLine(row.left?.index) )}
); @@ -700,7 +414,7 @@ export function LocalActionDiffBlock({ return (
{ - updateHoveredChunkId(null); + updateHoveredHunkIds([]); }} > {parsedLines.map((line, index) => ( diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 71796ea34..a3db3da89 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ask } from "@tauri-apps/plugin-dialog"; import { + applyGitDisplayHunk as applyGitDisplayHunkService, applyWorktreeChanges as applyWorktreeChangesService, createGitHubRepo as createGitHubRepoService, initGitRepo as initGitRepoService, @@ -133,6 +134,33 @@ export function useGitActions({ [onError, refreshGitData, workspaceId], ); + const applyGitDisplayHunk = useCallback( + async (options: { + path: string; + displayHunkId: string; + }): Promise => { + if (!workspaceId) { + return null; + } + const actionWorkspaceId = workspaceId; + try { + return await applyGitDisplayHunkService( + actionWorkspaceId, + options.path, + options.displayHunkId, + ); + } catch (error) { + onError?.(error); + return null; + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + await refreshGitData(); + } + } + }, + [onError, refreshGitData, workspaceId], + ); + const unstageGitFile = useCallback( async (path: string) => { if (!workspaceId) { @@ -363,6 +391,7 @@ export function useGitActions({ initGitRepoLoading, revertAllGitChanges, revertGitFile, + applyGitDisplayHunk, stageGitAll, stageGitFile, stageGitSelection, diff --git a/src/features/git/hooks/useGitDiffs.ts b/src/features/git/hooks/useGitDiffs.ts index 6cad7e87c..22f73fb39 100644 --- a/src/features/git/hooks/useGitDiffs.ts +++ b/src/features/git/hooks/useGitDiffs.ts @@ -113,6 +113,7 @@ export function useGitDiffs( diff: entry?.diff ?? "", stagedDiff: entry?.stagedDiff ?? null, unstagedDiff: entry?.unstagedDiff ?? null, + displayHunks: entry?.displayHunks ?? [], oldLines: entry?.oldLines, newLines: entry?.newLines, isImage: entry?.isImage, diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 4d0131594..d9d97130e 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; import * as notification from "@tauri-apps/plugin-notification"; import { + applyGitDisplayHunk, exportMarkdownFile, addWorkspace, compactThread, @@ -233,6 +234,23 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps args for apply_git_display_hunk", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ + applied: true, + appliedLineCount: 2, + warning: null, + }); + + await applyGitDisplayHunk("ws-1", "src/main.ts", "unstaged:1:0:2:1"); + + expect(invokeMock).toHaveBeenCalledWith("apply_git_display_hunk", { + workspaceId: "ws-1", + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:1", + }); + }); + it("maps args for createGitHubRepo", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ status: "ok", repo: "me/repo" }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 830dc6282..25c2732d8 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -654,6 +654,14 @@ export async function stageGitSelection( return invoke("stage_git_selection", { workspaceId, path, op, source, lines }); } +export async function applyGitDisplayHunk( + workspaceId: string, + path: string, + displayHunkId: string, +): Promise { + return invoke("apply_git_display_hunk", { workspaceId, path, displayHunkId }); +} + export async function unstageGitFile(workspaceId: string, path: string) { return invoke("unstage_git_file", { workspaceId, path }); } diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index ba3c41828..795b837e8 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -623,11 +623,29 @@ word-break: break-word; } -.diff-line-action { +.diff-line-action-group { position: absolute; top: 50%; left: 7px; transform: translate3d(0, -50%, 0); + display: inline-flex; + gap: 6px; +} + +.diff-line-action-group--before-gutter { + left: 7px; +} + +.diff-line-action-group--after-gutter { + left: auto; + right: 7px; +} + +.diff-line-action { + position: relative; + top: auto; + left: auto; + transform: none; width: 20px; height: 20px; border-radius: 999px; @@ -654,12 +672,11 @@ } .diff-line-action--before-gutter { - left: 7px; + left: auto; } .diff-line-action--after-gutter { left: auto; - right: 7px; } .diff-line-action--unstage { @@ -678,7 +695,7 @@ .diff-line-action:hover:not(:disabled), .diff-line-action:active:not(:disabled) { - transform: translate3d(0, -50%, 0); + transform: none; box-shadow: none; } diff --git a/src/types.ts b/src/types.ts index 48bca3537..ab1ec1587 100644 --- a/src/types.ts +++ b/src/types.ts @@ -445,11 +445,21 @@ export type GitSelectionApplyResult = { warning?: string | null; }; +export type GitFileDisplayHunk = { + id: string; + source: "staged" | "unstaged"; + action: "stage" | "unstage"; + startDisplayLineIndex: number; + endDisplayLineIndex: number; + lineCount: number; +}; + export type GitFileDiff = { path: string; diff: string; stagedDiff?: string | null; unstagedDiff?: string | null; + displayHunks?: GitFileDisplayHunk[]; oldLines?: string[]; newLines?: string[]; isBinary?: boolean; From dfee6d8a7e9b9e56efcd41c49850d4beccf2c5e3 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Thu, 19 Mar 2026 15:06:37 -0400 Subject: [PATCH 16/18] Fixes issue where some hunks would not be actionable. --- src-tauri/src/shared/git_ui_core/diff.rs | 51 ++++++++++--------- src/features/git/components/GitDiffViewer.tsx | 5 +- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 8f6139a5a..546989f60 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -1132,31 +1132,32 @@ pub(super) async fn get_git_diffs_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; - let path_status = repo.status_file(display_path).unwrap_or(Status::empty()); - let has_staged = - path_status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE); - let has_unstaged = - path_status.intersects(Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE); - let staged_diff = if has_staged { - source_diff_for_path( - &repo_root, - normalized_path.as_str(), - true, - ignore_whitespace_changes, - ) - } else { - None - }; - let unstaged_diff = if has_unstaged { - source_diff_for_path( - &repo_root, - normalized_path.as_str(), - false, - ignore_whitespace_changes, - ) - } else { - None - }; + let staged_diff = source_diff_for_path( + &repo_root, + normalized_path.as_str(), + true, + ignore_whitespace_changes, + ) + .and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + Some(diff) + } + }); + let unstaged_diff = source_diff_for_path( + &repo_root, + normalized_path.as_str(), + false, + ignore_whitespace_changes, + ) + .and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + Some(diff) + } + }); let old_lines = if !is_added { head_tree diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 56f055073..88d41a882 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -301,8 +301,9 @@ export function GitDiffViewer({ return null; } const path = entry.path; - const hasStaged = stagedPathSet.has(path); - const hasUnstaged = unstagedPathSet.has(path); + const hasStaged = stagedPathSet.has(path) || Boolean(entry.stagedDiff?.trim()); + const hasUnstaged = + unstagedPathSet.has(path) || Boolean(entry.unstagedDiff?.trim()); if (!hasStaged && !hasUnstaged) { return null; } From 9119a736ec927a372434757058898edb936b311a Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Fri, 27 Mar 2026 10:51:17 -0400 Subject: [PATCH 17/18] fix: honor whitespace-ignore setting when applying git hunks --- src-tauri/src/bin/codex_monitor_daemon.rs | 1 + src-tauri/src/git/mod.rs | 1 + src-tauri/src/shared/git_ui_core.rs | 14 ++- src-tauri/src/shared/git_ui_core/commands.rs | 18 +++- src-tauri/src/shared/git_ui_core/tests.rs | 107 ++++++++++++++++++- 5 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 5ccfb5215..66ebe9bd4 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1115,6 +1115,7 @@ impl DaemonState { ) -> Result { git_ui_core::apply_git_display_hunk_core( &self.workspaces, + &self.app_settings, workspace_id, path, display_hunk_id, diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index d74cd79db..9ff00ef66 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -237,6 +237,7 @@ pub(crate) async fn apply_git_display_hunk( ); git_ui_core::apply_git_display_hunk_core( &state.workspaces, + &state.app_settings, workspace_id, path, display_hunk_id, diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index a635d3a25..e314c1e0b 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -130,11 +130,23 @@ pub(crate) async fn stage_git_selection_core( pub(crate) async fn apply_git_display_hunk_core( workspaces: &Mutex>, + app_settings: &Mutex, workspace_id: String, path: String, display_hunk_id: String, ) -> Result { - commands::apply_git_display_hunk_inner(workspaces, workspace_id, path, display_hunk_id).await + let ignore_whitespace_changes = { + let settings = app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; + commands::apply_git_display_hunk_inner( + workspaces, + workspace_id, + path, + display_hunk_id, + ignore_whitespace_changes, + ) + .await } pub(crate) async fn unstage_git_file_core( diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs index afa4099e1..57b3ac371 100644 --- a/src-tauri/src/shared/git_ui_core/commands.rs +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -1256,6 +1256,7 @@ pub(super) async fn apply_git_display_hunk_inner( workspace_id: String, path: String, display_hunk_id: String, + ignore_whitespace_changes: bool, ) -> Result { let source = selection_source_from_display_hunk_id(&display_hunk_id)?; let op = match source { @@ -1272,13 +1273,22 @@ pub(super) async fn apply_git_display_hunk_inner( } let action_path = action_paths[0].clone(); - let (diff_args, reverse_apply): (&[&str], bool) = match source { - "unstaged" => (&["diff", "--no-color", "-U0", "--"], false), - "staged" => (&["diff", "--cached", "--no-color", "-U0", "--"], true), + let reverse_apply = match source { + "unstaged" => false, + "staged" => true, _ => unreachable!(), }; - let mut args = diff_args.to_vec(); + let mut args = vec!["diff"]; + if source == "staged" { + args.push("--cached"); + } + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); args.push(action_path.as_str()); let source_patch = String::from_utf8_lossy( &git_core::run_git_diff(&repo_root.to_path_buf(), &args).await?, diff --git a/src-tauri/src/shared/git_ui_core/tests.rs b/src-tauri/src/shared/git_ui_core/tests.rs index 3afcb120b..7381866d0 100644 --- a/src-tauri/src/shared/git_ui_core/tests.rs +++ b/src-tauri/src/shared/git_ui_core/tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use git2::Repository; use serde_json::Value; @@ -9,8 +10,7 @@ use tokio::sync::Mutex; use crate::types::{AppSettings, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; -use super::commands; -use super::diff; +use super::{apply_git_display_hunk_core, commands, diff}; fn create_temp_repo() -> (PathBuf, Repository) { let root = std::env::temp_dir().join(format!("codex-monitor-test-{}", uuid::Uuid::new_v4())); @@ -435,3 +435,106 @@ fn collect_ignored_paths_with_git_handles_large_ignored_output() { assert_eq!(ignored_paths.len(), total); } + +#[test] +fn apply_git_display_hunk_uses_ignore_whitespace_mode_for_hunk_ids() { + let (root, repo) = create_temp_repo(); + let file_path = root.join("example.txt"); + fs::write(&file_path, "alpha\nbeta\ncharlie\n").expect("write baseline"); + + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("example.txt")).expect("add path"); + index.write().expect("write index"); + 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::write(&file_path, "alpha \nbeta updated\ncharlie\n").expect("write changed file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + + let mut settings = AppSettings::default(); + settings.git_diff_ignore_whitespace_changes = true; + let app_settings = Mutex::new(settings); + + let source_patch = Command::new("git") + .args(["diff", "--no-color", "-U0", "-w", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run whitespace-ignored diff"); + assert!( + source_patch.status.success(), + "source diff failed: {}", + String::from_utf8_lossy(&source_patch.stderr) + ); + let source_patch = String::from_utf8_lossy(&source_patch.stdout).to_string(); + let parsed = commands::parse_zero_context_patch(&source_patch).expect("parse source patch"); + let display_hunk_id = parsed + .hunks + .first() + .map(|hunk| commands::parsed_patch_hunk_id("unstaged", hunk)) + .expect("find whitespace-ignored hunk"); + + let runtime = Runtime::new().expect("create tokio runtime"); + let result = runtime + .block_on(apply_git_display_hunk_core( + &workspaces, + &app_settings, + "w1".to_string(), + "example.txt".to_string(), + display_hunk_id, + )) + .expect("apply display hunk"); + + assert!(result.applied, "display hunk should be applied"); + assert_eq!(result.applied_line_count, 2); + + let cached = Command::new("git") + .args(["diff", "--cached", "--no-color", "-U0", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run cached diff"); + assert!( + cached.status.success(), + "cached diff failed: {}", + String::from_utf8_lossy(&cached.stderr) + ); + let cached_diff = String::from_utf8_lossy(&cached.stdout); + assert!( + cached_diff.contains("-beta\n+beta updated"), + "expected substantive line staged, got: {cached_diff}" + ); + + let unstaged = Command::new("git") + .args(["diff", "--no-color", "-U0", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run unstaged diff"); + assert!( + unstaged.status.success(), + "unstaged diff failed: {}", + String::from_utf8_lossy(&unstaged.stderr) + ); + let unstaged_diff = String::from_utf8_lossy(&unstaged.stdout); + assert!( + unstaged_diff.contains("-alpha\n+alpha "), + "expected whitespace-only edit to remain unstaged, got: {unstaged_diff}" + ); + assert!( + !unstaged_diff.contains("beta updated"), + "substantive edit should no longer be unstaged: {unstaged_diff}" + ); +} From 627fbe5aa45624eb41c41e842154203540a046a7 Mon Sep 17 00:00:00 2001 From: Austin Emmons Date: Fri, 27 Mar 2026 12:47:33 -0400 Subject: [PATCH 18/18] fix: handle untracked diffs and hunk mapping after deletions --- src-tauri/src/shared/git_ui_core/diff.rs | 74 ++++++++++++++++++++--- src-tauri/src/shared/git_ui_core/tests.rs | 64 ++++++++++++++++++++ 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 546989f60..39c01f043 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -199,19 +199,32 @@ fn source_diff_for_path( path: &str, cached: bool, ignore_whitespace_changes: bool, + is_untracked_worktree_file: bool, ) -> Option { let git_bin = resolve_git_binary().ok()?; let mut args = vec!["diff"]; - if cached { - args.push("--cached"); - } - args.push("--no-color"); - args.push("-U0"); - if ignore_whitespace_changes { - args.push("-w"); + if is_untracked_worktree_file && !cached { + args.push("--no-index"); + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(if cfg!(windows) { "NUL" } else { "/dev/null" }); + args.push(path); + } else { + if cached { + args.push("--cached"); + } + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(path); } - args.push("--"); - args.push(path); let output = std_command(git_bin) .args(args) @@ -370,7 +383,7 @@ fn map_new_to_old_line_clamped(hunks: &[ParsedPatchHunk], new_line: usize) -> us if new_line < insertion_point { break; } - delta -= hunk.old_count as isize; + delta += hunk.old_count as isize; continue; } @@ -719,6 +732,42 @@ mod display_hunk_tests { assert!(display_hunks[0].start_display_line_index < display_hunks[1].start_display_line_index); } + + #[test] + fn build_display_hunks_maps_unstaged_hunks_after_staged_deletions() { + let diff = concat!( + "@@ -2,1 +2,0 @@\n", + "-line two\n", + "@@ -5,1 +4,1 @@\n", + "-line five\n", + "+line five updated\n" + ); + let staged_diff = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 1111111..2222222 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -2,1 +2,0 @@\n", + "-line two\n" + ); + let unstaged_diff = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 2222222..3333333 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -4,1 +4,1 @@\n", + "-line five\n", + "+line five updated\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:2:1:2:0"); + assert_eq!(display_hunks[1].id, "unstaged:4:1:4:1"); + assert_eq!(display_hunks[1].start_display_line_index, 3); + assert_eq!(display_hunks[1].end_display_line_index, 4); + } } fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { @@ -1132,11 +1181,15 @@ pub(super) async fn get_git_diffs_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; + let file_status = repo.status_file(display_path).unwrap_or(Status::empty()); + let is_untracked_worktree_file = + file_status.contains(Status::WT_NEW) && !file_status.contains(Status::INDEX_NEW); let staged_diff = source_diff_for_path( &repo_root, normalized_path.as_str(), true, ignore_whitespace_changes, + is_untracked_worktree_file, ) .and_then(|diff| { if diff.trim().is_empty() { @@ -1150,6 +1203,7 @@ pub(super) async fn get_git_diffs_inner( normalized_path.as_str(), false, ignore_whitespace_changes, + is_untracked_worktree_file, ) .and_then(|diff| { if diff.trim().is_empty() { diff --git a/src-tauri/src/shared/git_ui_core/tests.rs b/src-tauri/src/shared/git_ui_core/tests.rs index 7381866d0..a0a9cdbca 100644 --- a/src-tauri/src/shared/git_ui_core/tests.rs +++ b/src-tauri/src/shared/git_ui_core/tests.rs @@ -226,6 +226,70 @@ fn get_git_diffs_omits_global_ignored_paths() { assert!(!has_ignored, "ignored files should not appear in diff list"); } +#[test] +fn get_git_diffs_populates_untracked_file_unstaged_diff_and_display_hunks() { + let (root, repo) = create_temp_repo(); + let tracked_path = root.join("tracked.txt"); + fs::write(&tracked_path, "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add tracked path"); + index.write().expect("write index"); + 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::write(root.join("new-file.txt"), "first line\nsecond line\n").expect("write new file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + let app_settings = Mutex::new(AppSettings::default()); + + let runtime = Runtime::new().expect("create tokio runtime"); + let diffs = runtime + .block_on(diff::get_git_diffs_inner( + &workspaces, + &app_settings, + "w1".to_string(), + )) + .expect("get git diffs"); + + let diff = diffs + .iter() + .find(|diff| diff.path == "new-file.txt") + .expect("find new file diff"); + let unstaged_diff = diff + .unstaged_diff + .as_deref() + .expect("untracked file should have unstaged diff"); + + assert!( + unstaged_diff.contains("+++ b/new-file.txt"), + "expected relative untracked diff header, got: {unstaged_diff}" + ); + assert!( + unstaged_diff.contains("+first line\n+second line"), + "expected untracked diff body, got: {unstaged_diff}" + ); + assert!( + !diff.display_hunks.is_empty(), + "untracked file should expose display hunks" + ); + assert_eq!(diff.display_hunks[0].source, "unstaged"); + assert_eq!(diff.display_hunks[0].action, "stage"); +} + #[test] fn check_ignore_with_git_respects_negated_rule_for_specific_file() { let (root, repo) = create_temp_repo();