Files
orca/src/shared/binary-file-extensions.ts
T
Neil ad4f068040 fix(diff): close large-diff deferral review findings from #17521 (#17758)
* fix(diff): close large-diff deferral review findings from #17521

Deferral keyed "no line counts" off the untracked area, which both prompted
ordinary untracked binaries and silently auto-loaded every tracked row when a
status pass skipped counting (entry cap hit, numstat failed) — the freeze case
the deferral exists for. Decide from the path instead: rows that render as a
preview or a binary stub stay automatic, everything Monaco would open as text
defers.

Also give all three combined-diff virtualizers one shared row estimate, so the
PR-review viewers stop estimating a deferred/in-flight large row at 88px while
DiffSectionItem renders it at 188px, and drop the dead isLoadOnDemand
parameter that estimate covered.

* fix(diff): stop deferring cheap uncounted rows the extension list misses

The path-only rule relocated friction rather than removing it: every uncounted
row deferred unless its extension was in BINARY_FILE_EXTENSIONS, so two classes
of tracked row flipped to a "Large diffs are not rendered by default" prompt
they had never shown. Tracked binaries outside the list (this repo's own
resources/build/icon.icns, plus .tiff/.avif/.psd/.parquet and every
extensionless binary) get '-\t-' from `git diff --numstat`, and a submodule
whose only change is untracked content inside it gets no numstat row at all
while porcelain v2 still reports `1 .M S..U ... sub`. Both are cheap, and both
are unreachable from a hardcoded extension list — verified against real git.

OR the extension check with two signals already on the entry. A submodule row
diffs to a "Subproject commit" line or two whatever it contains, so it is
always cheap. And an uncounted row whose siblings in the same pass DID get
counts is uncounted for a reason of its own: for a tracked row that reason can
only be numstat's binary marker. Untracked rows keep deferring either way,
since the scan also skips them past MAX_UNTRACKED_LINE_COUNT_BYTES and their
size is exactly what is unknown. No new field crosses git status, the wire, or
the section cache; `submodule` and the sibling counts are already there.

Fan-out, accepted deliberately: when a pass counts nothing at all — didHitLimit
at DEFAULT_GIT_STATUS_LIMIT, or runNumstat returning null — no row has a
counted sibling, so the whole combined diff renders as Load prompts. Keeping
it. Over 1000 changed entries is precisely the freeze this deferral exists for,
and auto-loading that many unbounded Monaco models is the bug, not the
mitigation; a numstat failure leaves every size genuinely unknown. Each row
still has its own Load diff button, so nothing is unreachable — the only thing
missing is a bulk "load all", which would reinstate the freeze on demand.

* fix(diff): scope the counted-siblings signal to one counting pass

hasCountedSiblings was one boolean over the whole entries array, but that array
is not one counting pass. combined-all — the default whenever a branch compare
exists — concatenates uncommitted rows with branch-compare rows, and even within
the uncommitted set staged and unstaged are separate numstat calls that fail
separately. So a single counted branch row vouched for an uncommitted pass that
counted nothing (numstat null, or didHitLimit at DEFAULT_GIT_STATUS_LIMIT), and
every uncounted row in it auto-loaded into exactly the Monaco freeze the
deferral exists to prevent: the guard was off in the default view.

Collect the passes that actually counted something, keyed by staging area for
status rows and 'compare' for branch/commit rows, and ask that set per row.
Untracked rows are unaffected — they never consult the signal.

Class 1 of the charter (tracked binaries outside BINARY_FILE_EXTENSIONS) stays
open, deliberately. Porcelain v2 reports a modified binary as `1 .M N... 100644`
— indistinguishable from text — so only `git diff --numstat`'s `-\t-` knows, and
that stdout is parsed on the host (shared/git-uncommitted-line-stats.ts) for
both the local and relay status paths. The renderer sees entries, not numstat,
so surfacing it per row means a new field on GitStatusEntry and
GitBranchChangeEntry that also has to be re-applied in two attachLineStats
copies and in the line-stats reuse cache, which persists only {added, removed}
and would silently drop it. The one existing field that could carry it —
added/removed set to 0 — changes what the host publishes to old clients and
mobile, contradicts the documented "undefined for binary files" contract, and
collapses the undefined-vs-zero distinction the virtualizer's height estimate
reads. So a lone tracked .icns still shows the load prompt; not worth a wire
field, and not worth another hardcoded extension.

* fix(diff): stop calling an uncounted diff large in the load prompt

The deferral prompt had one sentence for two different reasons. A row over
MAX_AUTOMATIC_DIFF_CHANGED_LINES really is large. A row with no counts at all —
numstat's binary marker, a pass that skipped counting — is deferred because its
size is unknown, and "Large diffs are not rendered by default." is simply false
for it: a lone tracked resources/build/icon.icns with no counted sibling in its
own pass is 4 KB and still says large.

Split the copy on the counts the section already carries. No new field on the
entry, nothing across the wire, no change to attachLineStats or the line-stats
cache — the predicate is renderer-local and mirrors the uncounted branch of
shouldLoadCombinedDiffOnDemand, so the two stay in step.
2026-08-31 17:51:56 -07:00

91 lines
1.8 KiB
TypeScript

import { IMAGE_FILE_EXTENSIONS } from './image-file-extensions'
// SVG is an image format that editors open as source text, so it stays out of
// the binary set even though it lives in IMAGE_FILE_EXTENSIONS.
const TEXT_IMAGE_EXTENSIONS = new Set(['.svg'])
const NON_IMAGE_BINARY_EXTENSIONS = [
// Archives
'.7z',
'.bz2',
'.gz',
'.jar',
'.rar',
'.tar',
'.tgz',
'.war',
'.xz',
'.zip',
'.zst',
// Audio and video
'.aac',
'.avi',
'.flac',
'.m4a',
'.mkv',
'.mov',
'.mp3',
'.mp4',
'.ogg',
'.wav',
'.webm',
// Documents
'.doc',
'.docx',
'.pdf',
'.ppt',
'.pptx',
'.xls',
'.xlsx',
// Fonts
'.eot',
'.otf',
'.ttc',
'.ttf',
'.woff',
'.woff2',
// Compiled artifacts and datastores
'.a',
'.bin',
'.class',
'.dll',
'.dylib',
'.exe',
'.idx',
'.lockb',
'.node',
'.o',
'.pack',
'.pyc',
'.pyd',
'.so',
'.sqlite',
'.sqlite3',
'.wasm'
]
export const BINARY_FILE_EXTENSIONS: readonly string[] = Object.freeze([
...IMAGE_FILE_EXTENSIONS.filter((extension) => !TEXT_IMAGE_EXTENSIONS.has(extension)),
...NON_IMAGE_BINARY_EXTENSIONS
])
const BINARY_FILE_EXTENSION_SET = new Set(BINARY_FILE_EXTENSIONS)
/**
* Extension-only guess at "this file is not text". Content-based detection
* lives in `isBinaryBuffer`; use this only where the bytes are unavailable.
*/
export function hasBinaryFileExtension(filePath: string | undefined): boolean {
if (filePath === undefined) {
return false
}
const lowerPath = filePath.toLowerCase()
const dotIndex = lowerPath.lastIndexOf('.')
const separatorIndex = Math.max(lowerPath.lastIndexOf('/'), lowerPath.lastIndexOf('\\'))
// A leading dot is a dotfile (.gitignore), not an extension.
if (dotIndex <= separatorIndex + 1) {
return false
}
return BINARY_FILE_EXTENSION_SET.has(lowerPath.slice(dotIndex))
}