@@ -21,16 +21,56 @@ function fileBasename(file: FileData): string {
2121 return fullPath . split ( '/' ) . pop ( ) || fullPath
2222}
2323
24+ // A binary delta parses into a FileData with zero hunks, a real path, and
25+ // `type: 'modify'`. The empty-input placeholder that `parseDiff` also emits
26+ // has neither hunks nor a meaningful path, so we can distinguish them by
27+ // checking for a usable path.
28+ //
29+ // ⚠️ `gitdiff-parser` technically defines a `file.isBinary` boolean on its
30+ // File type, but its internal parser NEVER sets it in practice: the branch
31+ // that does is only reached when the outer loop reads a line starting with
32+ // "Binary" directly, whereas the inner `simiLoop` (which processes the
33+ // headers following `diff --git`) aggressively consumes the `Binary files
34+ // … differ` line without matching any case — verified empirically with
35+ // `node -e "console.log(require('gitdiff-parser').parse(…).map(f => f.isBinary))"`.
36+ // Until the upstream parser fixes that, we fall back to the structural
37+ // check below.
38+ function hasRealPath ( file : FileData ) : boolean {
39+ const newPath = file . newPath && file . newPath !== '/dev/null' ? file . newPath : ''
40+ const oldPath = file . oldPath && file . oldPath !== '/dev/null' ? file . oldPath : ''
41+ return newPath !== '' || oldPath !== ''
42+ }
43+
44+ function isBinaryFile ( file : FileData ) : boolean {
45+ // Exclude pure renames (`type === 'rename'`) and pure copies (`type ===
46+ // 'copy'`) — they also parse with zero hunks + real paths but are clearly
47+ // not binary. Mode-only changes (chmod) parse with `type: 'modify'` and
48+ // zero hunks, so they WILL be mis-labelled as "Binary file — not
49+ // displayed"; this is an accepted edge case (rare in practice, recoverable
50+ // mis-label rather than a crash), tracked in `deferred-work.md`.
51+ return (
52+ file . hunks . length === 0 &&
53+ hasRealPath ( file ) &&
54+ file . type !== 'rename' &&
55+ file . type !== 'copy'
56+ )
57+ }
58+
2459export default function DiffViewer ( { session } : DiffViewerProps ) {
2560 const activeFileIndex = useSessionStore ( ( s ) => s . activeFileIndex )
2661 const setActiveFile = useSessionStore ( ( s ) => s . setActiveFile )
2762
2863 // parseDiff always returns at least one file even for empty/garbage input
29- // (empty path, zero hunks). Treat such placeholders as "no changes" so
30- // the user sees the empty state instead of an empty tab bar.
64+ // (empty path, zero hunks). Treat such empty placeholders as "no changes"
65+ // so the user sees the empty state instead of an empty tab bar — but
66+ // KEEP binary entries (zero hunks + real path + non-rename/copy) so a PR
67+ // that only changes images / lockfiles is still discoverable from the tab
68+ // list, with a "Binary file — not displayed" placeholder in the panel
69+ // body. Pure renames and copies are dropped from the tab list (they have
70+ // no content to show anyway).
3171 const files = useMemo < FileData [ ] > ( ( ) => {
3272 const parsed = parseDiff ( session . diff )
33- return parsed . filter ( ( file ) => file . hunks . length > 0 )
73+ return parsed . filter ( ( file ) => file . hunks . length > 0 || isBinaryFile ( file ) )
3474 } , [ session . diff ] )
3575 // Anchor the check to the end of the diff — `includes()` would false-positive
3676 // on any file whose contents legitimately contain the marker comment (e.g.
@@ -160,7 +200,21 @@ export default function DiffViewer({ session }: DiffViewerProps) {
160200 className = "flex-1 min-h-0 overflow-auto bg-surface"
161201 data-testid = "diff-scroll-container"
162202 >
163- { activeFile && (
203+ { activeFile && isBinaryFile ( activeFile ) && (
204+ < div
205+ className = "h-full w-full flex items-center justify-center p-6"
206+ data-testid = "diff-binary-placeholder"
207+ role = "region"
208+ aria-label = { `Binary file: ${ fileDisplayPath ( activeFile ) } ` }
209+ >
210+ < p className = "text-[14px] text-text-muted text-center" >
211+ Binary file — not displayed.
212+ < br />
213+ < span className = "text-[12px]" > { fileDisplayPath ( activeFile ) } </ span >
214+ </ p >
215+ </ div >
216+ ) }
217+ { activeFile && ! isBinaryFile ( activeFile ) && (
164218 < Diff
165219 key = { `${ activeFile . oldRevision } -${ activeFile . newRevision } -${ safeIndex } ` }
166220 viewType = "unified"
0 commit comments