diff --git a/src/config/theme.rs b/src/config/theme.rs index 10d69f3..447d5bb 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -944,7 +944,7 @@ fn load_user_theme(id: &str) -> Option { // ── Color helpers ──────────────────────────────────────────────────────── /// Mix two RGB colors. `amount` is 0..255 where 0 = all `a`, 255 = all `b`. -fn mix_colors(a: Color, b: Color, amount: u8) -> Color { +pub(crate) fn mix_colors(a: Color, b: Color, amount: u8) -> Color { let (ar, ag, ab) = color_to_rgb(a); let (br, bg, bb) = color_to_rgb(b); let t = amount as f32 / 255.0; diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 76a3512..bbd099f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2959,19 +2959,18 @@ impl Gui { self.diff_loading_since = Some(Instant::now()); self.queue_diff_job(generation, diff_key, move || { let path_refs: Vec<&str> = diff_paths.iter().map(String::as_str).collect(); - // Both sides present: one buffer with unstaged - // hunks under `name (Unstaged)`, then staged hunks - // under `name (Staged)`. Falls back to the - // single-side view when a side is unavailable. + // Single HEAD buffer with coherent line numbers; + // hunks are dimmed/tinted staged vs unstaged via + // overlap with the unstaged diff. Falls back to the + // unstaged-only view when HEAD is unavailable + // (e.g. unborn HEAD). if has_unstaged && has_staged { let exists = git.repo_path().join(¤t_path).exists(); - match ( - git.diff_file_paths(&path_refs), - git.diff_file_staged_paths(&path_refs), - ) { - (Ok(unstaged), Ok(staged)) => { - let parsed = DiffViewState::parse_combined_file_diff( - &name, &unstaged, &staged, 4, exists, + let unstaged = git.diff_file_paths(&path_refs).unwrap_or_default(); + match git.diff_paths_vs_head(&path_refs) { + Ok(head) if !head.is_empty() => { + let parsed = DiffViewState::parse_head_with_staged( + &name, &head, &unstaged, 4, exists, ); if parsed.lines.is_empty() { DiffPayload::Empty @@ -2979,7 +2978,20 @@ impl Gui { DiffPayload::Parsed(parsed) } } - _ => DiffPayload::Empty, + _ => { + let mut payload = parse_file_diff_payload( + &git, + &name, + ¤t_path, + &unstaged, + exists, + false, + ); + if let DiffPayload::Parsed(ref mut parsed) = payload { + parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; + } + payload + } } } else { let diff_result = if has_unstaged { @@ -2993,45 +3005,50 @@ impl Gui { let exists = git.repo_path().join(¤t_path).exists(); match diff_result { Ok(diff) if diff.is_empty() && !tracked => { - if git.is_binary_path(¤t_path) { - DiffPayload::Parsed(DiffViewState::parse_diff_output( + // Untracked file: everything is unstaged. + let parsed = if git.is_binary_path(¤t_path) { + DiffViewState::parse_diff_output( ¤t_path, &synthesize_binary_file_diff(¤t_path), 4, exists, - )) + ) } else { match git.file_content(¤t_path) { Ok(content) if !content.is_empty() => { - DiffPayload::Parsed(DiffViewState::parse_content( + DiffViewState::parse_content( ¤t_path, "", &content, 4, exists, - )) + ) } - _ => DiffPayload::Empty, + _ => return DiffPayload::Empty, } - } + }; + let mut parsed = parsed; + parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; + DiffPayload::Parsed(parsed) } Ok(diff) if diff.is_empty() => DiffPayload::Empty, Ok(diff) => { + // Single-side view: classify every hunk + // so the title counts + dimming + menu + // stay correct even at 0-staged or + // all-staged. + let staged_only = has_staged && !has_unstaged; let mut payload = parse_file_diff_payload( &git, &name, ¤t_path, &diff, exists, - has_staged && !has_unstaged, + staged_only, ); - // Staged-only view: every visible hunk is - // staged, so tint + menu must reflect that. - if has_staged && !has_unstaged { - if let DiffPayload::Parsed(ref mut parsed) = payload { - parsed.hunk_staged = - vec![true; parsed.hunk_starts.len()]; - } + if let DiffPayload::Parsed(ref mut parsed) = payload { + parsed.hunk_staged = + vec![staged_only; parsed.hunk_starts.len()]; } payload } @@ -3069,16 +3086,16 @@ impl Gui { Some(p) => vec![p], None => Vec::new(), }; - // Directory hover: per-file grouping with - // `name (Unstaged)` immediately followed by - // `name (Staged)` — same grouping as the - // single-file view. Untracked children - // only exist unstaged. Hunk actions stay - // Cancel-only here (no single file to act on). + // Directory hover: single HEAD buffer for all + // tracked files under the directory, with + // hunks classified staged/unstaged via + // overlap with the unstaged diff. Untracked + // children only exist unstaged. Hunk actions + // stay Cancel-only here (no single file). + let mut combined_diff = + git.diff_paths_vs_head(&paths).unwrap_or_default(); let mut unstaged_combined = git.diff_file_paths(&paths).unwrap_or_default(); - let staged_combined = - git.diff_staged_paths(&paths).unwrap_or_default(); for path in &untracked { if gen_counter.load(Ordering::Relaxed) != generation { return DiffPayload::Empty; @@ -3092,38 +3109,34 @@ impl Gui { } synthesize_new_file_diff(path, &content) }; + if !combined_diff.is_empty() { + combined_diff.push('\n'); + } + combined_diff.push_str(&synth); + // Mirror the synthesized section so the + // untracked file classifies as unstaged. if !unstaged_combined.is_empty() { unstaged_combined.push('\n'); } unstaged_combined.push_str(&synth); } - if unstaged_combined.trim().is_empty() - && staged_combined.trim().is_empty() - { + if combined_diff.trim().is_empty() { DiffPayload::Empty } else { - use crate::pager::side_by_side::DiffViewState as DVS; - let mut unstaged_parsed = DVS::parse_diff_output( - &dir_name, + use crate::pager::side_by_side::{ + DiffViewState as DVS, head_block_staged_flags, + }; + let mut parsed = + DVS::parse_diff_output(&dir_name, &combined_diff, 4, true); + parsed.hunk_staged = head_block_staged_flags( + &combined_diff, &unstaged_combined, 4, - true, - ); - unstaged_parsed.hunk_staged = - vec![false; unstaged_parsed.hunk_starts.len()]; - let mut staged_parsed = DVS::parse_diff_output( - &dir_name, - &staged_combined, - 4, - true, - ); - staged_parsed.hunk_staged = - vec![true; staged_parsed.hunk_starts.len()]; - let parsed = DVS::combine_parsed_with_separator( - unstaged_parsed, - staged_parsed, ); + if parsed.hunk_staged.len() != parsed.hunk_starts.len() { + parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; + } if parsed.lines.is_empty() { DiffPayload::Empty } else { @@ -7799,7 +7812,7 @@ impl Gui { items.push(popup::MenuItem { label: "Unstage hunk".to_string(), description: String::new(), - key: Some("u".to_string()), + key: Some("s".to_string()), action: Some(Box::new(move |gui| { if let Err(err) = gui.unstage_selected_file_hunk(hunk_idx) { gui.popup = PopupState::Message { @@ -7821,6 +7834,39 @@ impl Gui { }; } + /// Match the viewed HEAD block against the blocks of a freshly fetched + /// per-side diff, returning their slice ranges for patch building. + /// Matching runs on the side both diffs share (worktree for unstaged, + /// HEAD for staged) so staged edits elsewhere in the file can't shift + /// the mapping. Sorted deepest-first so sequential applies don't shift + /// the line numbers of pending ones. + fn matching_side_blocks( + &self, + hunk_idx: usize, + side_diff: &str, + new_side: bool, + ) -> Vec<(Option<(usize, usize)>, Option<(usize, usize)>)> { + use crate::pager::side_by_side::DiffViewState as DVS; + let view_spans = DVS::block_spans(&self.diff_view.lines, &self.diff_view.hunk_line_offsets); + let Some(view) = view_spans.get(hunk_idx) else { + return Vec::new(); + }; + let mut matched: Vec<(usize, Option<(usize, usize)>, Option<(usize, usize)>)> = + DVS::block_spans_for_diff(side_diff, 4) + .into_iter() + .filter(|s| view.overlaps(s, new_side)) + .map(|s| { + let anchor = s.old.map(|(lo, _)| lo).unwrap_or(s.old_point); + (anchor, s.old, s.new) + }) + .collect(); + matched.sort_by(|a, b| b.0.cmp(&a.0)); + matched + .into_iter() + .map(|(_, old, new)| (old, new)) + .collect() + } + fn stage_selected_file_hunk(&mut self, hunk_idx: usize) -> Result<()> { let Some(file_idx) = self.selected_file_index() else { return Ok(()); @@ -7831,15 +7877,6 @@ impl Gui { }; drop(model); - // Each stacked section keeps its own side's line numbers, so the - // viewed hunk's ranges slice the unstaged diff directly. - let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { - return Ok(()); - }; - if want_old.is_none() && want_new.is_none() { - return Ok(()); - } - let path_refs: Vec = file.diff_paths().into_iter().map(str::to_string).collect(); let refs: Vec<&str> = path_refs.iter().map(String::as_str).collect(); let diff = self.git.diff_file_paths(&refs)?; @@ -7854,8 +7891,25 @@ impl Gui { return Ok(()); } - self.git - .stage_visual_block(file.current_path(), &diff, want_old, want_new)?; + // The view may be a HEAD buffer, so map the hunk onto the unstaged + // diff's blocks via the shared worktree side. + let targets = self.matching_side_blocks(hunk_idx, &diff, true); + if targets.is_empty() { + self.popup = PopupState::Message { + title: "Stage hunk".to_string(), + message: "That hunk moved — the diff was refreshed.".to_string(), + kind: MessageKind::Info, + }; + self.needs_diff_refresh = true; + return Ok(()); + } + for (want_old, want_new) in targets { + if want_old.is_none() && want_new.is_none() { + continue; + } + self.git + .stage_visual_block(file.current_path(), &diff, want_old, want_new)?; + } self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) @@ -7871,15 +7925,6 @@ impl Gui { }; drop(model); - // The staged section keeps staged line numbers, so the viewed - // hunk's ranges slice the staged diff directly. - let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { - return Ok(()); - }; - if want_old.is_none() && want_new.is_none() { - return Ok(()); - } - let path_refs: Vec = file.diff_paths().into_iter().map(str::to_string).collect(); let refs: Vec<&str> = path_refs.iter().map(String::as_str).collect(); let diff = self.git.diff_file_staged_paths(&refs)?; @@ -7887,8 +7932,25 @@ impl Gui { return Ok(()); } - self.git - .unstage_visual_block(file.current_path(), &diff, want_old, want_new)?; + // The view may be a HEAD buffer, so map the hunk onto the staged + // diff's blocks via the shared HEAD side. + let targets = self.matching_side_blocks(hunk_idx, &diff, false); + if targets.is_empty() { + self.popup = PopupState::Message { + title: "Unstage hunk".to_string(), + message: "That hunk moved — the diff was refreshed.".to_string(), + kind: MessageKind::Info, + }; + self.needs_diff_refresh = true; + return Ok(()); + } + for (want_old, want_new) in targets { + if want_old.is_none() && want_new.is_none() { + continue; + } + self.git + .unstage_visual_block(file.current_path(), &diff, want_old, want_new)?; + } self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) @@ -7916,17 +7978,21 @@ impl Gui { let file_name = file.name.clone(); drop(model); - // Revert is only offered on unstaged hunks, whose ranges slice the - // unstaged diff directly. - let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { - return Ok(()); - }; - if want_old.is_none() && want_new.is_none() { + let diff = self.git.diff_file(&file_name)?; + if diff.is_empty() { return Ok(()); } - let diff = self.git.diff_file(&file_name)?; - if diff.is_empty() { + // The view may be a HEAD buffer, so map the hunk onto the unstaged + // diff's blocks via the shared worktree side. + let targets = self.matching_side_blocks(hunk_idx, &diff, true); + if targets.is_empty() { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "That hunk moved — the diff was refreshed.".to_string(), + kind: MessageKind::Info, + }; + self.needs_diff_refresh = true; return Ok(()); } @@ -7936,8 +8002,13 @@ impl Gui { let abs_path = self.git.repo_path().join(&file_name); let pre_bytes = std::fs::read(&abs_path).ok(); - self.git - .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + for (want_old, want_new) in targets { + if want_old.is_none() && want_new.is_none() { + continue; + } + self.git + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + } if let Some(bytes) = pre_bytes { let stack = &mut self.diff_view.revert_undo_stack; diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 91a6943..fbaeca6 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -353,22 +353,6 @@ pub struct DiffViewState { pub revert_undo_high_water: usize, } -/// Suffix a file header with its index side so unstaged/staged hunks stay -/// distinguishable when both are shown in one buffer: -/// `README.md (Unstaged)` / `README.md (Staged)`. -fn staged_file_header(name: &str, staged: bool) -> String { - format!("{} ({})", name, if staged { "Staged" } else { "Unstaged" }) -} - -/// Strip a ` (Staged)` / ` (Unstaged)` suffix back to the plain path so -/// header text can still resolve to a file on disk. -fn strip_staged_suffix(header: &str) -> &str { - header - .strip_suffix(" (Staged)") - .or_else(|| header.strip_suffix(" (Unstaged)")) - .unwrap_or(header) -} - impl Default for DiffViewState { fn default() -> Self { Self { @@ -466,7 +450,7 @@ impl DiffViewState { pub fn file_at_line(&self, line_idx: usize) -> &str { for i in (0..=line_idx).rev() { if let Some(ref header) = self.lines.get(i).and_then(|l| l.file_header.as_ref()) { - return strip_staged_suffix(header); + return header; } } &self.filename @@ -605,393 +589,128 @@ impl DiffViewState { } } - /// Parse a single file's unstaged + staged diffs into one scrollable - /// buffer: unstaged hunks under a `name (Unstaged)` header, then the - /// staged hunks under `name (Staged)`. `hunk_staged` records which - /// section each hunk came from so the hunk menu can offer Stage vs - /// Unstage per hunk. Either side may be empty (all-staged / - /// all-unstaged file); the per-side headers only appear when both - /// sides have content. - pub fn parse_combined_file_diff( + /// Parse a `git diff HEAD` buffer for a file that has both staged and + /// unstaged changes, classifying each hunk via `unstaged_diff`. + /// + /// True single buffer: one coherent set of line numbers, no separator, + /// no duplicated context. Both diffs share worktree (new-side) + /// numbering, so a HEAD hunk overlapping no unstaged hunk is fully + /// staged; anything touching unstaged work counts as unstaged. + pub fn parse_head_with_staged( filename: &str, + head_diff: &str, unstaged_diff: &str, - staged_diff: &str, tab_width: usize, file_exists_on_disk: bool, ) -> ParsedDiff { - let actual_name = parse_multi_file_diff(unstaged_diff) - .first() - .map(|(name, _)| name.clone()) - .or_else(|| { - parse_multi_file_diff(staged_diff) - .first() - .map(|(name, _)| name.clone()) - }) - .unwrap_or_else(|| filename.to_string()); - - let (unstaged_old, unstaged_new) = parse_unified_diff(unstaged_diff); - let (staged_old, staged_new) = parse_unified_diff(staged_diff); - - let unstaged_lines = diff_lines_from_unified_or_rename_only(unstaged_diff, tab_width); - let staged_lines = if staged_diff.trim().is_empty() { - Vec::new() - } else { - diff_lines_from_unified_or_rename_only(staged_diff, tab_width) - }; - - // Single-side fast path: keep the previous headerless buffer. - if unstaged_lines.is_empty() || staged_lines.is_empty() { - let mut lines = unstaged_lines; - let mut hunk_starts = super::diff_algo::find_hunk_starts(&lines); - let mut hunk_staged = vec![false; hunk_starts.len()]; - let hunks_u = parse_hunk_headers(unstaged_diff); - let mut hunk_line_offsets = build_hunk_line_offsets(&hunks_u, &lines, 0); - let mut sections = vec![FileSection { - old_highlighter: FileHighlighter::new(&unstaged_old, &actual_name), - new_highlighter: FileHighlighter::new(&unstaged_new, &actual_name), - }]; - if lines.is_empty() { - let mut staged_lines = staged_lines; - for line in &mut staged_lines { - line.section_index = 1; - } - let staged_starts = super::diff_algo::find_hunk_starts(&staged_lines); - for s in staged_starts { - hunk_starts.push(s); - hunk_staged.push(true); + let mut parsed = + Self::parse_diff_output(filename, head_diff, tab_width, file_exists_on_disk); + parsed.hunk_staged = head_block_staged_flags(head_diff, unstaged_diff, tab_width); + if parsed.hunk_staged.len() != parsed.hunk_starts.len() { + parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; + } + parsed + } + + /// File-relative (old, new) line spans for every visual change block of + /// a raw unified diff. Used to map a hunk the user acts on in the HEAD + /// buffer onto the matching block(s) of the staged/unstaged diff that + /// patch slicing consumes. + pub fn block_spans_for_diff(diff_text: &str, tab_width: usize) -> Vec { + let lines = diff_lines_from_unified_or_rename_only(diff_text, tab_width); + let hunks = parse_hunk_headers(diff_text); + let file_header_count = lines.iter().take_while(|l| l.file_header.is_some()).count(); + let offsets = build_hunk_line_offsets(&hunks, &lines, file_header_count); + Self::block_spans(&lines, &offsets) + } + + /// File-relative spans for visual change blocks from already-parsed + /// lines. `old`/`new` are `None` for pure insertions/deletions; the + /// `*_point` gap positions still allow matching those blocks across + /// diffs that share a side (worktree for unstaged, HEAD for staged). + pub fn block_spans( + lines: &[DiffLine], + hunk_line_offsets: &[(usize, usize, usize)], + ) -> Vec { + let offsets_at = |idx: usize| { + let mut offsets = (0usize, 0usize); + for &(start_idx, old_off, new_off) in hunk_line_offsets { + if start_idx <= idx { + offsets = (old_off, new_off); + } else { + break; } - lines = staged_lines; - let hunks_s = parse_hunk_headers(staged_diff); - hunk_line_offsets = build_hunk_line_offsets(&hunks_s, &lines, 0); - sections.push(FileSection { - old_highlighter: FileHighlighter::new(&staged_old, &actual_name), - new_highlighter: FileHighlighter::new(&staged_new, &actual_name), - }); - sections.remove(0); } - // Left leans HEAD-ish (staged old), right leans worktree - // (unstaged new), falling back to whichever side exists. - let old_content = if staged_old.is_empty() { - unstaged_old - } else { - staged_old - }; - let new_content = if unstaged_new.is_empty() { - staged_new + offsets + }; + let file_num = |idx: usize, new_side: bool| -> Option { + let (old_off, new_off) = offsets_at(idx); + let line = lines.get(idx)?; + if new_side { + line.new_line.as_ref().map(|(n, _)| *n + new_off) } else { - unstaged_new - }; - return ParsedDiff { - filename: actual_name, - old_content, - new_content, - lines, - hunk_starts, - hunk_staged, - hunk_line_offsets, - sections, - file_exists_on_disk, - }; - } - - fn header_line(name: &str, staged: bool, section_index: usize) -> DiffLine { - DiffLine { - old_line: None, - new_line: None, - change_type: ChangeType::Equal, - old_segments: None, - new_segments: None, - file_header: Some(staged_file_header(name, staged)), - section_index, + line.old_line.as_ref().map(|(n, _)| *n + old_off) } - } - - let mut lines = Vec::with_capacity(unstaged_lines.len() + staged_lines.len() + 2); - lines.push(header_line(&actual_name, false, 0)); - let unstaged_start = lines.len(); - lines.extend(unstaged_lines); - lines.push(header_line(&actual_name, true, 1)); - let staged_start = lines.len(); - let mut staged_lines = staged_lines; - for line in &mut staged_lines { - line.section_index = 1; - } - lines.extend(staged_lines); - - let hunks_u = parse_hunk_headers(unstaged_diff); - let hunks_s = parse_hunk_headers(staged_diff); - let mut hunk_starts = Vec::new(); - let mut hunk_staged = Vec::new(); - let mut hunk_line_offsets = Vec::new(); - for (idx, old_off, new_off) in - build_hunk_line_offsets(&hunks_u, &lines[unstaged_start..staged_start - 1], 0) - { - hunk_line_offsets.push((unstaged_start + idx, old_off, new_off)); - } - for s in super::diff_algo::find_hunk_starts(&lines[unstaged_start..staged_start - 1]) { - hunk_starts.push(unstaged_start + s); - hunk_staged.push(false); - } - for (idx, old_off, new_off) in build_hunk_line_offsets(&hunks_s, &lines[staged_start..], 0) - { - hunk_line_offsets.push((staged_start + idx, old_off, new_off)); - } - for s in super::diff_algo::find_hunk_starts(&lines[staged_start..]) { - hunk_starts.push(staged_start + s); - hunk_staged.push(true); - } - let sections = vec![ - FileSection { - old_highlighter: FileHighlighter::new(&unstaged_old, &actual_name), - new_highlighter: FileHighlighter::new(&unstaged_new, &actual_name), - }, - FileSection { - old_highlighter: FileHighlighter::new(&staged_old, &actual_name), - new_highlighter: FileHighlighter::new(&staged_new, &actual_name), - }, - ]; - - // Left leans HEAD-ish (staged old), right leans worktree - // (unstaged new), falling back to whichever side exists. - let old_content = if staged_old.is_empty() { - unstaged_old - } else { - staged_old - }; - let new_content = if unstaged_new.is_empty() { - staged_new - } else { - unstaged_new }; - ParsedDiff { - filename: actual_name, - old_content, - new_content, - lines, - hunk_starts, - hunk_staged, - hunk_line_offsets, - sections, - file_exists_on_disk, - } - } - - /// Group two already-parsed diffs (e.g. multi-file directory hovers) by - /// file: each file's unstaged hunks appear under `name (Unstaged)` - /// immediately followed by its staged hunks under `name (Staged)`, so a - /// staged file never reads as a second copy of the buffer. Files only - /// on one side keep a single suffixed header. Returns either half - /// unchanged (plain headers, no suffix) when the other half is empty. - /// Callers set each half's `hunk_staged` before combining. - pub fn combine_parsed_with_separator(first: ParsedDiff, second: ParsedDiff) -> ParsedDiff { - if second.lines.is_empty() { - return first; - } - if first.lines.is_empty() { - return second; - } - - struct Chunk { - name: String, - lines: Vec, - section_idx: usize, - hunks: Vec<(usize, bool)>, - offsets: Vec<(usize, usize, usize)>, - } - - // Split a parsed half into per-file chunks, keyed by plain filename. - // Hunk starts/offsets are rebased to content-relative indices. - // Repeat headers for one file stay as separate chunks so original - // indices always map exactly; emission merges them per filename. - fn split(parsed: &ParsedDiff) -> Vec { - let mut chunks: Vec = Vec::new(); - // (chunk_idx, content_start, content_end) for hunk attribution. - let mut spans: Vec<(usize, usize, usize)> = Vec::new(); - let mut open_name: Option = None; - let mut open_section: usize = 0; - let mut open_start: usize = 0; - let mut open_lines: Vec = Vec::new(); - - let flush = |chunks: &mut Vec, - spans: &mut Vec<(usize, usize, usize)>, - name: &mut Option, - lines: &mut Vec, - section: usize, - start: usize| { - if let Some(n) = name.take() { - let idx = chunks.len(); - spans.push((idx, start, start + lines.len())); - chunks.push(Chunk { - name: n, - lines: std::mem::take(lines), - section_idx: section, - hunks: Vec::new(), - offsets: Vec::new(), - }); - } - }; - - for (idx, line) in parsed.lines.iter().enumerate() { - if let Some(ref header) = line.file_header { - flush( - &mut chunks, - &mut spans, - &mut open_name, - &mut open_lines, - open_section, - open_start, - ); - open_name = Some(strip_staged_suffix(header).to_string()); - open_section = line.section_index; - open_start = idx + 1; - } else { - if open_name.is_none() { - // Headerless (single-file) half: synthesize the name. - open_name = Some(strip_staged_suffix(&parsed.filename).to_string()); - open_section = line.section_index; - open_start = idx; - } - open_lines.push(line.clone()); - } + let mut spans = Vec::new(); + let mut start = 0usize; + while start < lines.len() { + if lines[start].file_header.is_some() + || matches!(lines[start].change_type, ChangeType::Equal) + { + start += 1; + continue; } - flush( - &mut chunks, - &mut spans, - &mut open_name, - &mut open_lines, - open_section, - open_start, - ); - - // Attribute hunks/offsets to the chunk whose original span holds them. - for (hi, &s) in parsed.hunk_starts.iter().enumerate() { - let staged = parsed.hunk_staged.get(hi).copied().unwrap_or(false); - if let Some((ci, start, _)) = - spans.iter().find(|(_, start, end)| s >= *start && s < *end) - { - chunks[*ci].hunks.push((s - start, staged)); + let mut end = start; + while end < lines.len() + && lines[end].file_header.is_none() + && !matches!(lines[end].change_type, ChangeType::Equal) + { + end += 1; + } + let mut old_range: Option<(usize, usize)> = None; + let mut new_range: Option<(usize, usize)> = None; + for idx in start..end { + let line = &lines[idx]; + if matches!(line.change_type, ChangeType::Delete | ChangeType::Modified) { + if let Some(n) = file_num(idx, false) { + old_range = Some(match old_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } } - } - for (idx, old_off, new_off) in parsed.hunk_line_offsets.iter().copied() { - if let Some((ci, start, _)) = spans - .iter() - .find(|(_, start, end)| idx >= *start && idx < *end) - { - chunks[*ci].offsets.push((idx - start, old_off, new_off)); + if matches!(line.change_type, ChangeType::Insert | ChangeType::Modified) { + if let Some(n) = file_num(idx, true) { + new_range = Some(match new_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } } } - chunks - } - - let first_chunks = split(&first); - let second_chunks = split(&second); - if first_chunks.is_empty() && second_chunks.is_empty() { - return first; - } - - // Emission order: unstaged file order, then staged-only files. - let mut order: Vec = Vec::new(); - for c in &first_chunks { - if !order.contains(&c.name) { - order.push(c.name.clone()); - } - } - for c in &second_chunks { - if !order.contains(&c.name) { - order.push(c.name.clone()); - } - } - - let mut first_sections: Vec> = - first.sections.into_iter().map(Some).collect(); - let mut second_sections: Vec> = - second.sections.into_iter().map(Some).collect(); - let take_section = - |sections: &mut Vec>, idx: usize, fallback_name: &str| { - sections - .get_mut(idx) - .and_then(|s| s.take()) - .unwrap_or_else(|| FileSection { - old_highlighter: FileHighlighter::new("", fallback_name), - new_highlighter: FileHighlighter::new("", fallback_name), - }) - }; - - fn header_line(name: &str, staged: bool, section_index: usize) -> DiffLine { - DiffLine { - old_line: None, - new_line: None, - change_type: ChangeType::Equal, - old_segments: None, - new_segments: None, - file_header: Some(staged_file_header(name, staged)), - section_index, - } - } - - let first_name = first.filename.clone(); - let first_old = first.old_content.clone(); - let first_new = first.new_content.clone(); - let first_exists = first.file_exists_on_disk; - let mut lines: Vec = Vec::new(); - let mut sections: Vec = Vec::new(); - let mut hunk_starts: Vec = Vec::new(); - let mut hunk_staged: Vec = Vec::new(); - let mut hunk_line_offsets: Vec<(usize, usize, usize)> = Vec::new(); - - // Emit one suffixed header + content block per chunk so repeat - // headers for a file each keep their own section/highlighter. - let mut emit = - |chunk: &Chunk, staged: bool, take: &mut dyn FnMut(usize, &str) -> FileSection| { - let section = take(chunk.section_idx, &chunk.name); - let section_index = sections.len(); - lines.push(header_line(&chunk.name, staged, section_index)); - let content_start = lines.len(); - for mut line in chunk.lines.clone() { - line.section_index = section_index; - lines.push(line); - } - for (rel, s) in &chunk.hunks { - hunk_starts.push(content_start + rel); - hunk_staged.push(*s); - } - for (rel, old_off, new_off) in &chunk.offsets { - hunk_line_offsets.push((content_start + rel, *old_off, *new_off)); + let gap_point = |new_side: bool| -> usize { + for idx in (0..start).rev() { + if let Some(n) = file_num(idx, new_side) { + return n + 1; + } } - sections.push(section); + 0 }; - - for name in &order { - for chunk in first_chunks.iter().filter(|c| &c.name == name) { - let mut take = - |idx: usize, fallback: &str| take_section(&mut first_sections, idx, fallback); - emit(chunk, false, &mut take); - } - for chunk in second_chunks.iter().filter(|c| &c.name == name) { - let mut take = - |idx: usize, fallback: &str| take_section(&mut second_sections, idx, fallback); - emit(chunk, true, &mut take); - } - } - - // Keep hunk indices ascending in emission order. - let mut hunk_order: Vec = (0..hunk_starts.len()).collect(); - hunk_order.sort_by_key(|&i| hunk_starts[i]); - let hunk_starts = hunk_order.iter().map(|&i| hunk_starts[i]).collect(); - let hunk_staged = hunk_order.iter().map(|&i| hunk_staged[i]).collect(); - - ParsedDiff { - filename: first_name, - old_content: first_old, - new_content: first_new, - lines, - hunk_starts, - hunk_staged, - hunk_line_offsets, - sections, - file_exists_on_disk: first_exists, + spans.push(BlockSpan { + old: old_range, + new: new_range, + old_point: old_range + .map(|(lo, _)| lo) + .unwrap_or_else(|| gap_point(false)), + new_point: new_range + .map(|(lo, _)| lo) + .unwrap_or_else(|| gap_point(true)), + }); + start = end; } + spans } /// Parse raw diff output into a ParsedDiff on any thread (no &self needed). @@ -1022,7 +741,7 @@ impl DiffViewState { old_content: old, new_content: new, lines, - hunk_staged: vec![false; hunk_starts.len()], + hunk_staged: Vec::new(), hunk_starts, hunk_line_offsets, sections, @@ -1073,7 +792,7 @@ impl DiffViewState { old_content: String::new(), new_content: String::new(), lines, - hunk_staged: vec![false; hunk_starts.len()], + hunk_staged: Vec::new(), hunk_starts, hunk_line_offsets, sections, @@ -1631,66 +1350,41 @@ impl DiffViewState { self.hunk_staged.get(hunk_idx).copied().unwrap_or(false) } - /// For the visual hunk at `block_idx`, return the (old, new) line-number - /// ranges (inclusive) the block covers in the underlying file. Either - /// side may be `None` when the block is a pure insertion (no `-` lines) - /// or pure deletion (no `+` lines). Used to slice a sub-patch out of the - /// raw unified diff so revert affects only this visual block, not the - /// surrounding `@@` hunk that may contain other change blocks. - pub fn visual_block_line_ranges( - &self, - block_idx: usize, - ) -> Option<(Option<(usize, usize)>, Option<(usize, usize)>)> { - let start = *self.hunk_starts.get(block_idx)?; - let mut end = start; - while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { - end += 1; + /// `(staged, unstaged)` hunk counts. Returns `None` when the view has + /// no staged/unstaged classification (commits, stash, …). + pub fn staged_counts(&self) -> Option<(usize, usize)> { + if self.hunk_staged.len() != self.hunk_starts.len() || self.hunk_starts.is_empty() { + return None; } - // DiffLine line numbers are content-relative (positions inside the - // concatenated old/new strings produced by parse_unified_diff), but - // the unified-diff walker we feed these ranges to tracks file-relative - // line numbers. `hunk_line_offsets` provides the per-`@@` deltas we - // need to bridge the two. - let (old_offset, new_offset) = self.line_number_offsets_at(start); - let mut old_range: Option<(usize, usize)> = None; - let mut new_range: Option<(usize, usize)> = None; - for line in &self.lines[start..end] { - if matches!(line.change_type, ChangeType::Delete | ChangeType::Modified) { - if let Some((n, _)) = &line.old_line { - let n = *n + old_offset; - old_range = Some(match old_range { - None => (n, n), - Some((lo, hi)) => (lo.min(n), hi.max(n)), - }); - } - } - if matches!(line.change_type, ChangeType::Insert | ChangeType::Modified) { - if let Some((n, _)) = &line.new_line { - let n = *n + new_offset; - new_range = Some(match new_range { - None => (n, n), - Some((lo, hi)) => (lo.min(n), hi.max(n)), - }); - } - } + let staged = self.hunk_staged.iter().filter(|&&s| s).count(); + Some((staged, self.hunk_starts.len() - staged)) + } + + /// Zero-based hunk index owning `line_idx`, or `None` for file headers + /// and context lines outside any change block. + pub fn hunk_index_for_line(&self, line_idx: usize) -> Option { + let line = self.lines.get(line_idx)?; + if line.file_header.is_some() || line.change_type == ChangeType::Equal { + return None; } - Some((old_range, new_range)) - } - - /// Find the `@@` hunk that owns the DiffLine at `line_idx` and return - /// its (old, new) content→file line-number offsets. Returns (0, 0) when - /// no hunk metadata is available (e.g. the `load(...)` raw-content path, - /// where content and file numbering already coincide). - fn line_number_offsets_at(&self, line_idx: usize) -> (usize, usize) { - let mut offsets = (0usize, 0usize); - for &(start_idx, old_off, new_off) in &self.hunk_line_offsets { - if start_idx <= line_idx { - offsets = (old_off, new_off); - } else { - break; - } + let pos = self.hunk_starts.partition_point(|&start| start <= line_idx); + if pos == 0 { + return None; + } + Some(pos - 1) + } + + /// True when `line_idx` sits in a staged hunk. Context/header lines + /// (no owning hunk) count as staged so their `Reset` backgrounds pass + /// through undimmed — as does every line when the view carries no + /// staged/unstaged classification (commits, stash, …). + pub fn is_staged_line(&self, line_idx: usize) -> bool { + if self.hunk_staged.len() != self.hunk_starts.len() { + return true; } - offsets + self.hunk_index_for_line(line_idx) + .map(|i| self.is_staged_hunk(i)) + .unwrap_or(true) } /// Jump to the next hunk and select it as the revert target. Always @@ -1745,6 +1439,28 @@ impl DiffViewState { } } +/// Keep the rightmost `budget` columns of `text`, prefixed with `…` when +/// truncated. Tail-preserving so `src/gui/mod.rs` still reads as `…i/mod.rs`. +fn truncate_front_ellipsis(text: &str, budget: usize) -> String { + if Span::raw(text.to_string()).width() <= budget { + return text.to_string(); + } + if budget == 0 { + return String::new(); + } + let mut kept = String::new(); + let mut width = 1; // the `…` prefix + for ch in text.chars().rev() { + let cw = Span::raw(ch.to_string()).width().max(1); + if width + cw > budget { + break; + } + kept.insert(0, ch); + width += cw; + } + format!("…{kept}") +} + /// Render a side-by-side diff view into the given area. /// Uses direct buffer writes instead of per-cell Paragraph widgets for performance. pub fn render_diff( @@ -1782,18 +1498,69 @@ pub fn render_diff( DiffSideView::NewOnly => " [new] ", DiffSideView::Both => "", }; - let title = if side_label.is_empty() { - format!(" {} ", state.filename) - } else { - format!(" {}{} ", state.filename, side_label) + // Single-list Files view: `README.md 2 Staged 0 Unstaged`, reusing + // the `MM` badge colors (staged green, unstaged yellow) so the two + // counts read as the same index/worktree split as the file list. + // Overflow: the right `[c/t]` hunk counter wins over the left title. + // Reserve its width first, then ellipsis-truncate the filename to fit; + // only drop the staged counts when even a stub filename won't fit. + let hunk_pos = state.hunk_position(); + let right_width = hunk_pos + .map(|(c, t)| Line::from(format!(" [{c}/{t}] ")).width()) + .unwrap_or(0); + let available = area.width.saturating_sub(2) as usize; + let left_budget = available.saturating_sub(right_width + 1); + let leading = " "; + let trailing = " "; + let fixed = leading.len() + side_label.len() + trailing.len(); + let counts_width = match state.staged_counts() { + Some((s, u)) => format!(" {s} Staged").len() + format!(" {u} Unstaged").len(), + None => 0, }; + // Minimum readable filename stub (`…a.rs`-sized) before counts give way. + const MIN_FILENAME: usize = 4; + let keep_counts = state.staged_counts().is_some() + && left_budget.saturating_sub(fixed + counts_width) + >= MIN_FILENAME.min(state.filename.len()); + let active_counts_width = if keep_counts { counts_width } else { 0 }; + let filename_budget = left_budget.saturating_sub(fixed + active_counts_width); + let mut filename = state.filename.clone(); + if Span::raw(filename.clone()).width() > filename_budget { + filename = truncate_front_ellipsis(&filename, filename_budget); + } + let mut title_spans = vec![Span::raw(format!("{leading}{filename}{side_label}"))]; + if keep_counts { + if let Some((s, u)) = state.staged_counts() { + // Zero counts mute to dimmed so the nonzero side pops. + let staged_fg = if s == 0 { + theme.text_dimmed + } else { + theme.file_staged.fg.unwrap_or(theme.text_dimmed) + }; + let unstaged_fg = if u == 0 { + theme.text_dimmed + } else { + theme.file_unstaged.fg.unwrap_or(theme.text_dimmed) + }; + title_spans.push(Span::styled( + format!(" {s} Staged"), + Style::default().fg(staged_fg), + )); + title_spans.push(Span::styled( + format!(" {u} Unstaged"), + Style::default().fg(unstaged_fg), + )); + } + } + title_spans.push(Span::raw(trailing.to_string())); + let title = Line::from(title_spans); let mut block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style); - if let Some((current, total)) = state.hunk_position() { + if let Some((current, total)) = hunk_pos { block = block.title( Line::from(Span::styled( format!(" [{current}/{total}] "), @@ -1916,6 +1683,7 @@ pub fn render_diff( ), }; + let staged = state.is_staged_line(line_idx); let bg = if is_new_file { theme.diff_add_bg } else { @@ -1927,6 +1695,7 @@ pub fn render_diff( _ => Color::Reset, } }; + let bg = dim_unstaged_bg(bg, staged, theme); let gutter_bg = if is_new_file { theme.diff_add_gutter_bg } else { @@ -1938,6 +1707,7 @@ pub fn render_diff( _ => Color::Reset, } }; + let gutter_bg = dim_unstaged_bg(gutter_bg, staged, theme); let gutter_fg = if is_new_file { theme.diff_add_gutter_fg } else { @@ -1966,6 +1736,7 @@ pub fn render_diff( bg, theme, usize::MAX / 2, + staged, ); let wrapped = wrap_spans(&spans, content_width as usize); for (chunk_idx, chunk) in wrapped.iter().enumerate() { @@ -1995,6 +1766,7 @@ pub fn render_diff( bg, theme, content_width as usize, + staged, ); buf_write_spans( buf, @@ -2058,8 +1830,10 @@ pub fn render_diff( .highlighters_for_section(diff_line.section_index) .unwrap_or((&default_hl, &default_hl)); - let (left_bg, right_bg) = line_bg_colors(diff_line.change_type, theme); - let (left_gutter_bg, right_gutter_bg) = gutter_bg_colors(diff_line.change_type, theme); + let staged = state.is_staged_line(line_idx); + let (left_bg, right_bg) = line_bg_colors(diff_line.change_type, theme, staged); + let (left_gutter_bg, right_gutter_bg) = + gutter_bg_colors(diff_line.change_type, theme, staged); let (left_gutter_fg, right_gutter_fg) = gutter_fg_colors(diff_line.change_type, theme); let gutter_style = Style::default().fg(left_gutter_fg).bg(left_gutter_bg); let right_gutter_style = Style::default().fg(right_gutter_fg).bg(right_gutter_bg); @@ -2091,6 +1865,7 @@ pub fn render_diff( left_bg, theme, usize::MAX / 2, + staged, ); wrap_spans(&spans, panel_width as usize) }; @@ -2106,6 +1881,7 @@ pub fn render_diff( right_bg, theme, usize::MAX / 2, + staged, ); wrap_spans(&spans, right_content_width as usize) }; @@ -2257,6 +2033,7 @@ pub fn render_diff( left_bg, theme, panel_width as usize, + staged, ) }; buf_write_spans( @@ -2320,6 +2097,7 @@ pub fn render_diff( right_bg, theme, panel_width as usize, + staged, ) }; buf_write_spans( @@ -2438,12 +2216,14 @@ fn render_unified_diff_body( None, theme, start_chunk, + true, ); line_idx += 1; continue; } let block_end = next_unified_change_block_end(&state.lines, line_idx); + let block_staged = state.is_staged_line(line_idx); let mut marker_hunk_idx = if show_revert_markers && state.is_hunk_start_line(line_idx) { state.hunk_index_for_start_line(line_idx) } else { @@ -2490,13 +2270,14 @@ fn render_unified_diff_body( ChangeType::Delete, true, old_highlighter, - theme.diff_remove_bg, - theme.diff_remove_gutter_bg, + dim_unstaged_bg(theme.diff_remove_bg, block_staged, theme), + dim_unstaged_bg(theme.diff_remove_gutter_bg, block_staged, theme), theme.diff_remove_gutter_fg, content_width, marker_hunk_idx.take(), theme, start_chunk, + block_staged, ); } } @@ -2539,13 +2320,14 @@ fn render_unified_diff_body( ChangeType::Insert, false, new_highlighter, - theme.diff_add_bg, - theme.diff_add_gutter_bg, + dim_unstaged_bg(theme.diff_add_bg, block_staged, theme), + dim_unstaged_bg(theme.diff_add_gutter_bg, block_staged, theme), theme.diff_add_gutter_fg, content_width, marker_hunk_idx.take(), theme, start_chunk, + block_staged, ); } } @@ -2576,6 +2358,7 @@ fn render_unified_row( marker_hunk_idx: Option, theme: &Theme, start_chunk: usize, + staged: bool, ) { if *row >= visible_height { return; @@ -2609,6 +2392,7 @@ fn render_unified_row( } else { content_width as usize }, + staged, ); let rows = if state.wrap { wrap_spans(&spans, content_width as usize) @@ -2676,10 +2460,9 @@ fn unified_line_number_text(num: Option, chunk_idx: usize) -> String { } } -/// Glyph + foreground for a hunk marker. Sections carry `(Unstaged)` / -/// `(Staged)` headers, so every marker uses the same `󰧛` glyph: -/// blue when focused, dim otherwise. No hover color — hover only shows -/// the tooltip, selection alone drives the highlight. +/// Glyph + foreground for a hunk marker: blue `󰧛` for unstaged hunks, +/// green `✓` for staged hunks. No hover color — hover only shows the +/// tooltip, selection alone drives the highlight. fn hunk_marker_glyph_and_fg( state: &DiffViewState, hunk_idx: usize, @@ -2688,10 +2471,17 @@ fn hunk_marker_glyph_and_fg( let is_selected = Some(hunk_idx) == state.selected_revert_hunk; let fg = if is_selected { theme.accent + } else if state.is_staged_hunk(hunk_idx) { + theme.file_staged.fg.unwrap_or(Color::Green) } else { theme.separator }; - ("󰧛", fg) + let glyph = if state.is_staged_hunk(hunk_idx) { + "✓" + } else { + "󰧛" + }; + (glyph, fg) } fn render_revert_tooltip( @@ -3042,25 +2832,44 @@ fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { } /// Get background colors for a diff line based on change type. -fn line_bg_colors(change_type: ChangeType, theme: &Theme) -> (Color, Color) { - match change_type { +/// Unstaged hunks recede toward the panel tone so staged hunks (full +/// `diff_add_bg`/`diff_remove_bg`) pop. `Reset` passes through undimmed. +fn line_bg_colors(change_type: ChangeType, theme: &Theme, staged: bool) -> (Color, Color) { + let (l, r) = match change_type { ChangeType::Equal => (Color::Reset, Color::Reset), ChangeType::Delete => (theme.diff_remove_bg, Color::Reset), ChangeType::Insert => (Color::Reset, theme.diff_add_bg), ChangeType::Modified => (theme.diff_remove_bg, theme.diff_add_bg), + }; + ( + dim_unstaged_bg(l, staged, theme), + dim_unstaged_bg(r, staged, theme), + ) +} + +/// Dim one background for an unstaged hunk. Staged rows and `Reset` +/// (context) rows pass through untouched. +fn dim_unstaged_bg(bg: Color, staged: bool, theme: &Theme) -> Color { + if staged || bg == Color::Reset { + return bg; } + crate::config::theme::mix_colors(bg, theme.diff_grid_bg, 150) } /// Get gutter background colors for a diff line. Slightly darker than the /// content background so the gutter visually separates from the code area -/// (lumen-style). -fn gutter_bg_colors(change_type: ChangeType, theme: &Theme) -> (Color, Color) { - match change_type { +/// (lumen-style). Dims with the row for unstaged hunks. +fn gutter_bg_colors(change_type: ChangeType, theme: &Theme, staged: bool) -> (Color, Color) { + let (l, r) = match change_type { ChangeType::Equal => (Color::Reset, Color::Reset), ChangeType::Delete => (theme.diff_remove_gutter_bg, Color::Reset), ChangeType::Insert => (Color::Reset, theme.diff_add_gutter_bg), ChangeType::Modified => (theme.diff_remove_gutter_bg, theme.diff_add_gutter_bg), - } + }; + ( + dim_unstaged_bg(l, staged, theme), + dim_unstaged_bg(r, staged, theme), + ) } /// Foreground color for the gutter line number on the (left, right) side. @@ -3084,6 +2893,7 @@ fn build_content_spans<'a>( bg: Color, theme: &Theme, max_width: usize, + staged: bool, ) -> Vec> { let Some((line_num, text)) = line_data else { // Empty side — fill with background @@ -3093,7 +2903,7 @@ fn build_content_spans<'a>( // If we have word-level segments, use those if let Some(segs) = segments { - return build_word_diff_spans(segs, is_old_side, bg, theme, max_width); + return build_word_diff_spans(segs, is_old_side, bg, theme, max_width, staged); } // Otherwise, try syntax highlighting @@ -3133,6 +2943,7 @@ fn build_word_diff_spans<'a>( bg: Color, theme: &Theme, _max_width: usize, + staged: bool, ) -> Vec> { segments .iter() @@ -3146,7 +2957,7 @@ fn build_word_diff_spans<'a>( Span::styled( seg.text.clone(), Style::default() - .bg(emphasis_bg) + .bg(dim_unstaged_bg(emphasis_bg, staged, theme)) .fg(theme.text_strong) .add_modifier(Modifier::BOLD), ) @@ -3615,6 +3426,72 @@ fn parse_hunk_headers(diff: &str) -> Vec<(usize, usize, usize, usize)> { hunks } +/// File-relative span of one visual change block. +pub struct BlockSpan { + pub old: Option<(usize, usize)>, + pub new: Option<(usize, usize)>, + pub old_point: usize, + pub new_point: usize, +} + +impl BlockSpan { + /// Effective inclusive span on one side, using the gap point for pure + /// insertions (`old`) and pure deletions (`new`). + pub fn eff(&self, new_side: bool) -> (usize, usize) { + if new_side { + self.new.unwrap_or((self.new_point, self.new_point)) + } else { + self.old.unwrap_or((self.old_point, self.old_point)) + } + } + + /// True when both spans touch on the given side. + pub fn overlaps(&self, other: &BlockSpan, new_side: bool) -> bool { + let (lo1, hi1) = self.eff(new_side); + let (lo2, hi2) = other.eff(new_side); + lo1 <= hi2 && lo2 <= hi1 + } +} + +/// One `staged` flag per visual change block of a `git diff HEAD` buffer, +/// in block order. Both diffs share worktree (new-side) numbering, so a +/// HEAD block overlapping no unstaged block is fully staged; anything +/// touching unstaged work counts as unstaged. Handles multi-file buffers +/// by matching sections on filename; files missing from the unstaged diff +/// are fully staged. +pub fn head_block_staged_flags( + head_diff: &str, + unstaged_diff: &str, + tab_width: usize, +) -> Vec { + let head_sections = parse_multi_file_diff(head_diff); + if head_sections.is_empty() { + return head_blocks_staged_flags(head_diff, unstaged_diff, tab_width); + } + let unstaged_sections = parse_multi_file_diff(unstaged_diff); + let mut flags = Vec::new(); + for (name, body) in &head_sections { + let peer = unstaged_sections + .iter() + .find(|(n, _)| n == name) + .map(|(_, b)| *b) + .unwrap_or(""); + flags.extend(head_blocks_staged_flags(body, peer, tab_width)); + } + flags +} + +/// One `staged` flag per visual change block of a single-file +/// `git diff HEAD` buffer. +fn head_blocks_staged_flags(head_diff: &str, unstaged_diff: &str, tab_width: usize) -> Vec { + let head_spans = DiffViewState::block_spans_for_diff(head_diff, tab_width); + let unstaged_spans = DiffViewState::block_spans_for_diff(unstaged_diff, tab_width); + head_spans + .iter() + .map(|h| !unstaged_spans.iter().any(|u| h.overlaps(u, true))) + .collect() +} + /// Build the hunk line offset table from parsed hunk headers and /// computed DiffLines. Each entry is `(first_diff_line_idx, old_offset, new_offset)`. fn build_hunk_line_offsets( @@ -3694,109 +3571,63 @@ mod tests { } #[test] - fn combined_file_diff_stacks_sections_with_separator() { - let unstaged = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n"; - let staged = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -5,3 +5,3 @@\n x\n-y\n+Y\n z\n"; - let parsed = DiffViewState::parse_combined_file_diff("f.txt", unstaged, staged, 4, true); + fn head_buffer_classifies_staged_hunks_without_separator() { + // Real `git diff HEAD` output: staged edit at line 2, unstaged edit + // at line 9, merged into a single `@@` by git. Block-level overlap + // still tells them apart. + let head = "diff --git a/f.txt b/f.txt\nindex f00c965..edee7ac 100644\n--- a/f.txt\n+++ b/f.txt\n@@ -1,10 +1,10 @@\n 1\n-2\n+TWO\n 3\n 4\n 5\n 6\n 7\n 8\n-9\n+NINE\n 10\n"; + let unstaged = "diff --git a/f.txt b/f.txt\nindex 9935360..edee7ac 100644\n--- a/f.txt\n+++ b/f.txt\n@@ -6,5 +6,5 @@ TWO\n 6\n 7\n 8\n-9\n+NINE\n 10\n"; + let parsed = DiffViewState::parse_head_with_staged("f.txt", head, unstaged, 4, true); assert_eq!(parsed.hunk_starts.len(), 2); - assert_eq!(parsed.hunk_staged, vec![false, true]); - // Per-side headers label each section; no global separator. - let headers: Vec<&str> = parsed - .lines - .iter() - .filter_map(|l| l.file_header.as_deref()) - .collect(); - assert_eq!(headers, vec!["f.txt (Unstaged)", "f.txt (Staged)"]); - // Each section keeps its own side's file numbers, which is what - // stage/unstage sub-patch slicing consumes directly. + assert_eq!(parsed.hunk_staged, vec![true, false]); + // No separator: a single coherent buffer. + assert!( + parsed.lines.iter().all(|l| l.file_header.is_none()), + "single buffer must not contain section separators" + ); let mut state = DiffViewState::new(); state.apply_parsed(parsed); - assert_eq!( - state.visual_block_line_ranges(0), - Some((Some((2, 2)), Some((2, 2)))) - ); - assert_eq!( - state.visual_block_line_ranges(1), - Some((Some((6, 6)), Some((6, 6)))) - ); - assert!(!state.is_staged_hunk(0)); - assert!(state.is_staged_hunk(1)); + assert_eq!(state.staged_counts(), Some((1, 1))); + assert!(state.is_staged_hunk(0)); + assert!(!state.is_staged_hunk(1)); + // Staged hunk keeps full color; unstaged line dims. + assert!(state.is_staged_line(state.hunk_starts[0])); + assert!(!state.is_staged_line(state.hunk_starts[1])); } #[test] - fn combined_file_diff_omits_separator_for_single_side() { - let unstaged = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n"; - let parsed = DiffViewState::parse_combined_file_diff("f.txt", unstaged, "", 4, true); - assert_eq!(parsed.hunk_staged, vec![false]); - assert!(parsed.lines.iter().all(|l| l.file_header.is_none())); - - let staged = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -5,3 +5,3 @@\n x\n-y\n+Y\n z\n"; - let parsed = DiffViewState::parse_combined_file_diff("f.txt", "", staged, 4, true); - assert_eq!(parsed.hunk_staged, vec![true]); - assert!(parsed.lines.iter().all(|l| l.file_header.is_none())); + fn staged_deletion_classifies_as_staged() { + // Staged deletion of line 2; unstaged edit of line 9. + let head = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,4 +1,3 @@\n 1\n-2\n 3\n 4\n@@ -7,4 +6,4 @@\n 7\n 8\n-9\n+NINE\n"; + let unstaged = "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -5,5 +5,5 @@\n 6\n 7\n 8\n-9\n+NINE\n 10\n"; + let parsed = DiffViewState::parse_head_with_staged("f.txt", head, unstaged, 4, true); + assert_eq!(parsed.hunk_staged, vec![true, false]); } #[test] - fn combine_parsed_with_separator_rebases_second_half() { - let a = DiffViewState::parse_diff_output( - "dir", - "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n", - 4, - true, - ); - let b = DiffViewState::parse_diff_output( - "dir", - "diff --git a/c.txt b/c.txt\n--- a/c.txt\n+++ b/c.txt\n@@ -1,2 +1,2 @@\n x\n-y\n+Y\n", - 4, - true, - ); - assert_eq!(a.hunk_starts.len(), 1); - assert_eq!(b.hunk_starts.len(), 1); - let mut b = b; - b.hunk_staged = vec![true; b.hunk_starts.len()]; - let combined = DiffViewState::combine_parsed_with_separator(a, b); - assert_eq!(combined.hunk_starts.len(), 2); - assert_eq!(combined.hunk_staged, vec![false, true]); - // Distinct files keep their own suffixed headers, unstaged order first. - let headers: Vec<&str> = combined - .lines - .iter() - .filter_map(|l| l.file_header.as_deref()) - .collect(); - assert_eq!(headers, vec!["a.txt (Unstaged)", "c.txt (Staged)"]); - // The staged hunk moved past the first half + header. - assert!(combined.hunk_starts[1] > combined.hunk_starts[0]); + fn head_flags_mark_missing_unstaged_files_as_staged() { + // Multi-file HEAD buffer where b.txt has no unstaged counterpart. + let head = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+B\ndiff --git a/b.txt b/b.txt\n--- a/b.txt\n+++ b/b.txt\n@@ -1,2 +1,2 @@\n x\n-y\n+Y\n"; + let unstaged = + "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n"; + let flags = head_block_staged_flags(head, unstaged, 4); + assert_eq!(flags, vec![false, true]); } #[test] - fn combine_parsed_groups_same_file_staged_below_unstaged() { - let a = DiffViewState::parse_diff_output( - "dir", - "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n", - 4, - true, - ); - let b = DiffViewState::parse_diff_output( - "dir", - "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -5,2 +5,2 @@\n x\n-y\n+Y\n", + fn unclassified_diff_has_no_staged_counts() { + // Commits/stash carry no staged classification: full color, no counts. + let parsed = DiffViewState::parse_diff_output( + "f.txt", + "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n", 4, true, ); - let mut b = b; - b.hunk_staged = vec![true; b.hunk_starts.len()]; - let combined = DiffViewState::combine_parsed_with_separator(a, b); - assert_eq!(combined.hunk_staged, vec![false, true]); - let headers: Vec<&str> = combined - .lines - .iter() - .filter_map(|l| l.file_header.as_deref()) - .collect(); - assert_eq!(headers, vec!["a.txt (Unstaged)", "a.txt (Staged)"]); - // Plain filenames still resolve for editor navigation. + assert!(parsed.hunk_staged.is_empty()); let mut state = DiffViewState::new(); - state.apply_parsed(combined); - assert_eq!(state.file_at_line(1), "a.txt"); - assert_eq!(state.file_at_line(state.hunk_starts[1]), "a.txt"); + state.apply_parsed(parsed); + assert_eq!(state.staged_counts(), None); + assert!(state.is_staged_line(state.hunk_starts[0])); } #[test]