From 93ddf72670c4bf80b24b58b4708869e77910fdb2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 4 May 2026 16:06:26 +1000 Subject: [PATCH 1/4] feat(diff): show relative file change indicators Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/diff_cache.rs | 106 +++++++++- .../features/diff/DiffFileTreeSection.svelte | 83 +++++++- .../src/lib/features/diff/diffModalHelpers.ts | 2 + crates/git-diff/src/diff.rs | 185 ++++++++++++++---- crates/git-diff/src/types.rs | 14 ++ packages/diff-viewer/src/lib/types.ts | 2 + .../src/lib/utils/diffModalHelpers.test.ts | 52 ++++- .../src/lib/utils/diffModalHelpers.ts | 21 ++ packages/diff-viewer/src/lib/utils/index.ts | 2 + 9 files changed, 417 insertions(+), 50 deletions(-) diff --git a/apps/staged/src-tauri/src/diff_cache.rs b/apps/staged/src-tauri/src/diff_cache.rs index 65d55f0c7..7c450c8a9 100644 --- a/apps/staged/src-tauri/src/diff_cache.rs +++ b/apps/staged/src-tauri/src/diff_cache.rs @@ -487,6 +487,41 @@ fn decode_file(b64: &str, path: &str) -> git_diff::File { } } +fn supports_line_stats(file: &Option) -> bool { + match file { + Some(git_diff::File { + content: git_diff::FileContent::Binary | git_diff::FileContent::ImageBase64 { .. }, + .. + }) => false, + _ => true, + } +} + +fn line_stats_from_patch( + patch: &str, + before: &Option, + after: &Option, +) -> (Option, Option) { + if !supports_line_stats(before) || !supports_line_stats(after) { + return (None, None); + } + + let mut added = 0; + let mut deleted = 0; + for line in patch.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + added += 1; + } else if line.starts_with('-') { + deleted += 1; + } + } + + (Some(added), Some(deleted)) +} + /// Parse file entries from the collection script into summaries and full diffs. fn parse_script_files( entries: &[CollectScriptFile], @@ -511,11 +546,6 @@ fn parse_script_files( Some(entry.after_path.clone()) }; - files.push(git_diff::FileDiffSummary { - before: before_path.as_deref().map(PathBuf::from), - after: after_path.as_deref().map(PathBuf::from), - }); - let canonical_path = after_path.as_deref().or(before_path.as_deref()); let canonical_path = match canonical_path { Some(p) if !p.is_empty() => p, @@ -532,6 +562,14 @@ fn parse_script_files( decode_file(b64, path) }); + let (added_lines, deleted_lines) = line_stats_from_patch(&entry.patch, &before, &after); + files.push(git_diff::FileDiffSummary { + before: before_path.as_deref().map(PathBuf::from), + after: after_path.as_deref().map(PathBuf::from), + added_lines, + deleted_lines, + }); + let hunks = parse_unified_hunks(&entry.patch); let alignments = compute_remote_alignments(&hunks, &before, &after); @@ -764,3 +802,61 @@ fn cache_branch_diff_background( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cached_branch_index_deserializes_summaries_without_line_stats() { + let json = r#"{ + "branch_id": "branch-1", + "base_sha": "base", + "head_sha": "head", + "files": [ + { "before": "src/old.ts", "after": "src/new.ts" } + ], + "cached_at": 0 + }"#; + + let index: CachedBranchIndex = serde_json::from_str(json).unwrap(); + assert_eq!(index.files.len(), 1); + assert_eq!(index.files[0].added_lines, None); + assert_eq!(index.files[0].deleted_lines, None); + } + + #[test] + fn line_stats_from_patch_counts_text_changes() { + let patch = "\ +diff --git a/file.txt b/file.txt +--- a/file.txt ++++ b/file.txt +@@ -1,2 +1,3 @@ +-old ++new ++added + unchanged"; + + let before = Some(git_diff::File { + path: "file.txt".to_string(), + content: git_diff::FileContent::Text { lines: vec![] }, + }); + let after = before.clone(); + + assert_eq!( + line_stats_from_patch(patch, &before, &after), + (Some(2), Some(1)) + ); + } + + #[test] + fn line_stats_from_patch_skips_binary_content() { + let before = Some(git_diff::File { + path: "image.png".to_string(), + content: git_diff::FileContent::Binary, + }); + let after = before.clone(); + + assert_eq!(line_stats_from_patch("", &before, &after), (None, None)); + } +} diff --git a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte index 07d3c17e9..0cec9645b 100644 --- a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte +++ b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte @@ -13,7 +13,12 @@ } from 'lucide-svelte'; import { FileSearchResults } from '@builderbot/diff-viewer/components'; import { getMatchSnippet, getTextLines, type SearchMatch } from '@builderbot/diff-viewer/utils'; - import type { FileEntry, TreeNode } from './diffModalHelpers'; + import { + fileChangeScale, + fileChangeTotal, + type FileEntry, + type TreeNode, + } from './diffModalHelpers'; import type { FileDiff, FileDiffSummary } from '@builderbot/diff-viewer/types'; import type { FileSearchResult } from '@builderbot/diff-viewer/state'; import '@builderbot/diff-viewer/components/search.css'; @@ -83,9 +88,27 @@ fileEntries.map((entry) => ({ before: entry.status === 'added' ? null : entry.path, after: entry.status === 'deleted' ? null : entry.path, + addedLines: entry.addedLines, + deletedLines: entry.deletedLines, })) ); + const maxFileChangeTotal = $derived( + fileEntries.reduce((max, entry) => Math.max(max, fileChangeTotal(entry) ?? 0), 0) + ); + + function lineCount(value: number | null | undefined): number { + return typeof value === 'number' ? Math.max(0, value) : 0; + } + + function changeIndicatorTitle(added: number, deleted: number): string { + return `+${added} / -${deleted}`; + } + + function changeIndicatorWidth(total: number): number { + return Math.round(4 + fileChangeScale(total, maxFileChangeTotal) * 14); + } + // Helper to get snippet for a search result function getSnippet(match: SearchMatch, filePath: string): string { if (!diffViewerState) return ''; @@ -163,6 +186,34 @@ {/if} {/snippet} +{#snippet changeIndicator(file: FileEntry)} + {@const total = fileChangeTotal(file)} + {@const added = lineCount(file.addedLines)} + {@const deleted = lineCount(file.deletedLines)} + 0 ? changeIndicatorTitle(added, deleted) : undefined} + aria-hidden="true" + > + {#if total !== null && total > 0} + + {#if added > 0} + + {/if} + {#if deleted > 0} + + {/if} + + {/if} + +{/snippet} + {#snippet treeNodes(nodes: TreeNode[], depth: number, showReviewedSection: boolean)} {#each nodes as node (node.path)} {#if node.isDir} @@ -227,6 +278,7 @@ {/if} {/if} {@render fileIcon(node.file, showReviewedSection)} + {@render changeIndicator(node.file)} {node.name} {#if node.file.commentCount > 0} Result { // Commit range - use git diff let args = ["diff", "--name-status", "-z", base.as_str(), head.as_str()]; let output = cli::run(repo, &args)?; - parse_name_status(&output) + let mut files = parse_name_status(&output)?; + enrich_with_numstat( + repo, + &["diff", "--numstat", "-z", base.as_str(), head.as_str()], + &mut files, + ); + Ok(files) } (GitRef::WorkingTree, _) | (GitRef::Index, _) => Err(GitError::CommandFailed( "Cannot use working tree or index as base".to_string(), @@ -130,6 +143,8 @@ fn list_working_tree_changes(repo: &Path, base: &str) -> Result Result = result_map.into_values().collect(); + enrich_with_numstat(repo, &["diff", "--numstat", "-z", base], &mut files); + Ok(files) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LineStats { + added: Option, + deleted: Option, +} + +fn enrich_with_numstat(repo: &Path, args: &[&str], files: &mut [FileDiffSummary]) { + match cli::run(repo, args) { + Ok(output) => apply_numstat_output(files, &output), + Err(e) => log::warn!("git-diff: failed to enrich file list with numstat: {e}"), + } +} + +fn parse_numstat_count(value: &str) -> Option { + if value == "-" { + None + } else { + value.parse().ok() + } +} + +fn parse_numstat(output: &str) -> HashMap { + let mut stats = HashMap::new(); + let mut parts = output.split('\0').peekable(); + + while let Some(record) = parts.next() { + if record.is_empty() { + continue; + } + + let mut columns = record.split('\t'); + let added = match columns.next() { + Some(value) => parse_numstat_count(value), + None => continue, + }; + let deleted = match columns.next() { + Some(value) => parse_numstat_count(value), + None => continue, + }; + let path_field = columns.next().unwrap_or_default(); + + let path = if path_field.is_empty() { + let _old = parts.next(); + parts.next() + } else { + Some(path_field) + }; + + if let Some(path) = path.filter(|p| !p.is_empty()) { + stats.insert(path.into(), LineStats { added, deleted }); + } + } + + stats +} + +fn apply_numstat_output(files: &mut [FileDiffSummary], output: &str) { + let stats = parse_numstat(output); + for file in files { + if let Some(line_stats) = stats.get(file.path()) { + file.added_lines = line_stats.added; + file.deleted_lines = line_stats.deleted; + } + } } /// Parse `git status --porcelain -z` output. @@ -202,42 +285,30 @@ fn parse_porcelain_status(repo: &Path, output: &str) -> Result { - results.push(FileDiffSummary { - before: None, - after: new_path.map(Into::into), - }); + results.push(FileDiffSummary::new(None, new_path.map(Into::into))); } ('D', _) | (_, 'D') => { - results.push(FileDiffSummary { - before: new_path.map(Into::into), - after: None, - }); + results.push(FileDiffSummary::new(new_path.map(Into::into), None)); } ('R', _) | ('C', _) => { - results.push(FileDiffSummary { - before: old_path.map(Into::into), - after: new_path.map(Into::into), - }); + results.push(FileDiffSummary::new( + old_path.map(Into::into), + new_path.map(Into::into), + )); } _ => { - results.push(FileDiffSummary { - before: new_path.clone().map(Into::into), - after: new_path.map(Into::into), - }); + results.push(FileDiffSummary::new( + new_path.clone().map(Into::into), + new_path.map(Into::into), + )); } }; } @@ -277,38 +348,26 @@ pub fn parse_name_status(output: &str) -> Result, GitError> 'A' => { // Added: just one path if let Some(path) = parts.next() { - results.push(FileDiffSummary { - before: None, - after: Some(path.into()), - }); + results.push(FileDiffSummary::new(None, Some(path.into()))); } } 'D' => { // Deleted: just one path if let Some(path) = parts.next() { - results.push(FileDiffSummary { - before: Some(path.into()), - after: None, - }); + results.push(FileDiffSummary::new(Some(path.into()), None)); } } 'M' | 'T' => { // Modified or Type changed: just one path if let Some(path) = parts.next() { - results.push(FileDiffSummary { - before: Some(path.into()), - after: Some(path.into()), - }); + results.push(FileDiffSummary::new(Some(path.into()), Some(path.into()))); } } 'R' | 'C' => { // Renamed or Copied: two paths (old, new) // Status might include similarity percentage like R100 if let (Some(old), Some(new)) = (parts.next(), parts.next()) { - results.push(FileDiffSummary { - before: Some(old.into()), - after: Some(new.into()), - }); + results.push(FileDiffSummary::new(Some(old.into()), Some(new.into()))); } } _ => { @@ -755,6 +814,46 @@ mod tests { assert_eq!(result.len(), 3); } + #[test] + fn test_apply_numstat_added() { + let mut result = parse_name_status("A\0new_file.txt\0").unwrap(); + apply_numstat_output(&mut result, "12\t0\tnew_file.txt\0"); + assert_eq!(result[0].added_lines, Some(12)); + assert_eq!(result[0].deleted_lines, Some(0)); + } + + #[test] + fn test_apply_numstat_deleted() { + let mut result = parse_name_status("D\0old_file.txt\0").unwrap(); + apply_numstat_output(&mut result, "0\t8\told_file.txt\0"); + assert_eq!(result[0].added_lines, Some(0)); + assert_eq!(result[0].deleted_lines, Some(8)); + } + + #[test] + fn test_apply_numstat_modified() { + let mut result = parse_name_status("M\0changed.txt\0").unwrap(); + apply_numstat_output(&mut result, "3\t2\tchanged.txt\0"); + assert_eq!(result[0].added_lines, Some(3)); + assert_eq!(result[0].deleted_lines, Some(2)); + } + + #[test] + fn test_apply_numstat_renamed() { + let mut result = parse_name_status("R100\0old_name.txt\0new_name.txt\0").unwrap(); + apply_numstat_output(&mut result, "4\t1\t\0old_name.txt\0new_name.txt\0"); + assert_eq!(result[0].added_lines, Some(4)); + assert_eq!(result[0].deleted_lines, Some(1)); + } + + #[test] + fn test_apply_numstat_binary() { + let mut result = parse_name_status("M\0image.png\0").unwrap(); + apply_numstat_output(&mut result, "-\t-\timage.png\0"); + assert_eq!(result[0].added_lines, None); + assert_eq!(result[0].deleted_lines, None); + } + #[test] fn test_parse_porcelain_untracked() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/git-diff/src/types.rs b/crates/git-diff/src/types.rs index 8b067c68c..4beba9d25 100644 --- a/crates/git-diff/src/types.rs +++ b/crates/git-diff/src/types.rs @@ -137,12 +137,26 @@ pub struct File { /// Status inferred: Added (before=None), Deleted (after=None), /// Renamed (both Some, different paths), Modified (both Some, same path) #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct FileDiffSummary { pub before: Option, pub after: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub added_lines: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_lines: Option, } impl FileDiffSummary { + pub fn new(before: Option, after: Option) -> Self { + Self { + before, + after, + added_lines: None, + deleted_lines: None, + } + } + /// The primary path to use for this file (after if exists, else before) pub fn path(&self) -> &PathBuf { self.after.as_ref().or(self.before.as_ref()).unwrap() diff --git a/packages/diff-viewer/src/lib/types.ts b/packages/diff-viewer/src/lib/types.ts index d741367b1..1eb31d0d2 100644 --- a/packages/diff-viewer/src/lib/types.ts +++ b/packages/diff-viewer/src/lib/types.ts @@ -31,6 +31,8 @@ export interface File { export interface FileDiffSummary { before: string | null; after: string | null; + addedLines?: number | null; + deletedLines?: number | null; } /** Maps a region in the before file to a region in the after file. */ diff --git a/packages/diff-viewer/src/lib/utils/diffModalHelpers.test.ts b/packages/diff-viewer/src/lib/utils/diffModalHelpers.test.ts index 3665ac3fc..42e9017e4 100644 --- a/packages/diff-viewer/src/lib/utils/diffModalHelpers.test.ts +++ b/packages/diff-viewer/src/lib/utils/diffModalHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { pathsMatch } from './diffModalHelpers'; +import { buildFileEntries, fileChangeScale, fileChangeTotal, pathsMatch } from './diffModalHelpers'; describe('pathsMatch', () => { it('returns true for identical paths', () => { @@ -45,3 +45,53 @@ describe('pathsMatch', () => { expect(pathsMatch('', '')).toBe(true); }); }); + +describe('fileChangeTotal', () => { + it('returns null when a summary has no line stats', () => { + expect(fileChangeTotal({ before: 'a.ts', after: 'a.ts' })).toBeNull(); + }); + + it('adds available added and deleted counts', () => { + expect(fileChangeTotal({ before: 'a.ts', after: 'a.ts', addedLines: 5, deletedLines: 2 })).toBe( + 7 + ); + }); +}); + +describe('fileChangeScale', () => { + it('returns 0 when totals are missing or empty', () => { + expect(fileChangeScale(null, 10)).toBe(0); + expect(fileChangeScale(0, 10)).toBe(0); + expect(fileChangeScale(10, 0)).toBe(0); + }); + + it('uses logarithmic scaling against the largest file total', () => { + expect(fileChangeScale(9, 99)).toBeCloseTo(Math.log1p(9) / Math.log1p(99)); + expect(fileChangeScale(99, 99)).toBe(1); + }); +}); + +describe('buildFileEntries', () => { + it('propagates optional line stats to file entries', () => { + const entries = buildFileEntries( + [{ before: 'src/file.ts', after: 'src/file.ts', addedLines: 3, deletedLines: 1 }], + [], + [] + ); + + expect(entries[0]).toMatchObject({ + path: 'src/file.ts', + addedLines: 3, + deletedLines: 1, + }); + }); + + it('keeps missing line stats as null for older cached summaries', () => { + const entries = buildFileEntries([{ before: 'src/file.ts', after: 'src/file.ts' }], [], []); + + expect(entries[0]).toMatchObject({ + addedLines: null, + deletedLines: null, + }); + }); +}); diff --git a/packages/diff-viewer/src/lib/utils/diffModalHelpers.ts b/packages/diff-viewer/src/lib/utils/diffModalHelpers.ts index af70d8564..51e083b43 100644 --- a/packages/diff-viewer/src/lib/utils/diffModalHelpers.ts +++ b/packages/diff-viewer/src/lib/utils/diffModalHelpers.ts @@ -4,6 +4,8 @@ import { fileSummaryPath } from '../state/diffViewerState.svelte'; export interface FileEntry { path: string; status: 'added' | 'deleted' | 'modified' | 'renamed'; + addedLines?: number | null; + deletedLines?: number | null; isReviewed: boolean; commentCount: number; /** The distinct comment types present on this file (e.g. 'warning', 'suggestion'). */ @@ -34,6 +36,23 @@ export function fileStatus(summary: FileDiffSummary): 'added' | 'deleted' | 'mod return 'modified'; } +export function fileChangeTotal( + file: Pick +): number | null { + const added = file.addedLines; + const deleted = file.deletedLines; + if (typeof added !== 'number' && typeof deleted !== 'number') return null; + return Math.max(0, added ?? 0) + Math.max(0, deleted ?? 0); +} + +export function fileChangeScale( + fileTotal: number | null | undefined, + maxTotalInDiff: number +): number { + if (!fileTotal || fileTotal <= 0 || maxTotalInDiff <= 0) return 0; + return Math.min(1, Math.log1p(fileTotal) / Math.log1p(maxTotalInDiff)); +} + /** Aggregate comment count and distinct types for a single path. */ interface CommentAgg { count: number; @@ -102,6 +121,8 @@ export function buildFileEntries( return { path, status: fileStatus(summary), + addedLines: summary.addedLines ?? null, + deletedLines: summary.deletedLines ?? null, isReviewed: reviewedSet.has(path), commentCount, commentTypes: [...commentTypes], diff --git a/packages/diff-viewer/src/lib/utils/index.ts b/packages/diff-viewer/src/lib/utils/index.ts index 5fca9a54c..0b86c9cde 100644 --- a/packages/diff-viewer/src/lib/utils/index.ts +++ b/packages/diff-viewer/src/lib/utils/index.ts @@ -13,6 +13,8 @@ export { buildFileEntries, buildTree, compactTree, + fileChangeScale, + fileChangeTotal, formatLineRange, pathsMatch, truncateText, From 74e3273205b26bd3b82e34d4cba3346d85bcd6ad Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 4 May 2026 17:17:27 +1000 Subject: [PATCH 2/4] style(diff): make change indicators vertical Render file change indicators as vertical fills and add a muted track so the full scale remains visible. Signed-off-by: Matt Toohey --- .../features/diff/DiffFileTreeSection.svelte | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte index 0cec9645b..c38d20cd6 100644 --- a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte +++ b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte @@ -105,8 +105,8 @@ return `+${added} / -${deleted}`; } - function changeIndicatorWidth(total: number): number { - return Math.round(4 + fileChangeScale(total, maxFileChangeTotal) * 14); + function changeIndicatorHeight(total: number): number { + return Math.round(4 + fileChangeScale(total, maxFileChangeTotal) * 12); } // Helper to get snippet for a search result @@ -192,21 +192,22 @@ {@const deleted = lineCount(file.deletedLines)} 0 ? changeIndicatorTitle(added, deleted) : undefined} aria-hidden="true" > {#if total !== null && total > 0} - + {#if added > 0} {/if} {#if deleted > 0} {/if} @@ -581,23 +582,29 @@ .change-indicator { display: inline-flex; - align-items: center; - justify-content: flex-start; + align-items: flex-end; + justify-content: center; flex-shrink: 0; - width: 18px; - height: 12px; + width: 8px; + height: 16px; + overflow: hidden; + border-radius: 2px; + } + + .change-indicator-visible { + background: color-mix(in srgb, var(--border-muted) 35%, transparent); } .change-indicator-fill { display: flex; - height: 6px; + flex-direction: column; + width: 100%; overflow: hidden; - border-radius: 2px; - background: var(--border-muted); + border-radius: inherit; } .change-segment { - height: 100%; + width: 100%; } .change-segment-added { From f587eb2a361c2cf9990afedaf0b463484ab0b63d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 4 May 2026 19:38:54 +1000 Subject: [PATCH 3/4] style(diff): slim relative change bars Render the relative +/- indicator in the file icon slot and reduce the vertical bar width to 4px. Signed-off-by: Matt Toohey --- .../features/diff/DiffFileTreeSection.svelte | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte index c38d20cd6..c04f2649e 100644 --- a/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte +++ b/apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte @@ -7,9 +7,9 @@ ChevronDown, Folder, MessageSquare, - CirclePlus, - CircleMinus, - CircleArrowUp, + MoveRight, + Plus, + X, } from 'lucide-svelte'; import { FileSearchResults } from '@builderbot/diff-viewer/components'; import { getMatchSnippet, getTextLines, type SearchMatch } from '@builderbot/diff-viewer/utils'; @@ -109,6 +109,10 @@ return Math.round(4 + fileChangeScale(total, maxFileChangeTotal) * 12); } + function hasLineChanges(file: FileEntry): boolean { + return (fileChangeTotal(file) ?? 0) > 0; + } + // Helper to get snippet for a search result function getSnippet(match: SearchMatch, filePath: string): string { if (!diffViewerState) return ''; @@ -146,20 +150,24 @@ {#snippet fileIcon(file: FileEntry, showReviewedSection: boolean)} {#if isReadonly} - + - {#if file.status === 'added'} - - {:else if file.status === 'deleted'} - - {:else} - - {/if} + {@render fileStatusVisual(file)} {:else} onToggleReviewed(e, file)} onkeydown={(e) => e.key === 'Enter' && onToggleReviewed(e, file)} role="button" @@ -167,13 +175,7 @@ title={showReviewedSection ? 'Mark as needs review' : 'Mark as reviewed'} > - {#if file.status === 'added'} - - {:else if file.status === 'deleted'} - - {:else} - - {/if} + {@render fileStatusVisual(file)} {#if showReviewedSection} @@ -186,6 +188,18 @@ {/if} {/snippet} +{#snippet fileStatusVisual(file: FileEntry)} + {#if file.status === 'added'} + + {:else if file.status === 'deleted'} + + {:else if file.status === 'renamed'} + + {:else} + {@render changeIndicator(file)} + {/if} +{/snippet} + {#snippet changeIndicator(file: FileEntry)} {@const total = fileChangeTotal(file)} {@const added = lineCount(file.addedLines)} @@ -211,6 +225,8 @@ > {/if} + {:else} + {/if} {/snippet} @@ -279,7 +295,6 @@ {/if} {/if} {@render fileIcon(node.file, showReviewedSection)} - {@render changeIndicator(node.file)} {node.name} {#if node.file.commentCount > 0} Date: Mon, 4 May 2026 19:53:33 +1000 Subject: [PATCH 4/4] fix(diff): require trailing space in patch header detection The file header check in line_stats_from_patch matched any line starting with '+++' or '---', causing content lines like '+++flag' (an added line containing '++flag') to be skipped instead of counted. Restrict the check to lines starting with '+++ ' or '--- ' which matches actual unified diff file headers (e.g. '+++ b/path' or '--- /dev/null'). Also convert supports_line_stats to use matches! macro per clippy lint. Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/diff_cache.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/staged/src-tauri/src/diff_cache.rs b/apps/staged/src-tauri/src/diff_cache.rs index 7c450c8a9..77389fbcf 100644 --- a/apps/staged/src-tauri/src/diff_cache.rs +++ b/apps/staged/src-tauri/src/diff_cache.rs @@ -488,13 +488,13 @@ fn decode_file(b64: &str, path: &str) -> git_diff::File { } fn supports_line_stats(file: &Option) -> bool { - match file { + !matches!( + file, Some(git_diff::File { content: git_diff::FileContent::Binary | git_diff::FileContent::ImageBase64 { .. }, .. - }) => false, - _ => true, - } + }) + ) } fn line_stats_from_patch( @@ -509,7 +509,7 @@ fn line_stats_from_patch( let mut added = 0; let mut deleted = 0; for line in patch.lines() { - if line.starts_with("+++") || line.starts_with("---") { + if line.starts_with("+++ ") || line.starts_with("--- ") { continue; } if line.starts_with('+') {