Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-binary-diffs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep binary and audio files collapsed in diff reviews instead of showing empty diff panels.
22 changes: 16 additions & 6 deletions packages/kilo-vscode/src/agent-manager/local-diff.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as fs from "fs/promises"
import * as path from "path"
import { binaryFile } from "../diff/shared/binary"
import type { GitOps } from "./GitOps"
import type { WorktreeDiffEntry } from "./types"

Expand All @@ -12,6 +13,7 @@ type Meta = {
status: Status
tracked: boolean
generatedLike: boolean
binary: boolean
stamp: string
}

Expand Down Expand Up @@ -118,7 +120,7 @@ async function numstat(git: GitOps, dir: string, base: string, file?: string) {
const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base]
if (file) args.push("--", file)
const result = await git.execGit(args, dir)
const map = new Map<string, { additions: number; deletions: number }>()
const map = new Map<string, { additions: number; deletions: number; binary: boolean }>()
if (result.code !== 0) return map
for (const line of result.stdout.trim().split("\n")) {
if (!line) continue
Expand All @@ -130,6 +132,7 @@ async function numstat(git: GitOps, dir: string, base: string, file?: string) {
map.set(name, {
additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0,
deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0,
binary: add === "-" || del === "-",
})
}
return map
Expand Down Expand Up @@ -179,14 +182,15 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
if (!file || !code) continue
seen.add(file)
const status = statusFromCode(code)
const stat = counts.get(file) ?? { additions: 0, deletions: 0 }
const stat = counts.get(file) ?? { additions: 0, deletions: 0, binary: false }
result.push({
file,
additions: stat.additions,
deletions: stat.deletions,
status,
tracked: true,
generatedLike: generatedLike(file),
binary: stat.binary,
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, file),
})
}
Expand All @@ -205,13 +209,15 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
const full = path.join(dir, file)
const exists = await fs.stat(full).catch(() => undefined)
if (!exists) continue
const binary = await binaryFile(full)
result.push({
file,
additions: await lineCount(full),
additions: binary ? 0 : await lineCount(full),
deletions: 0,
status: "added",
tracked: false,
generatedLike: generatedLike(file),
binary,
stamp: await statStamp(dir, file),
})
}
Expand All @@ -230,7 +236,7 @@ function summarize(meta: Meta): WorktreeDiffEntry {
status: meta.status,
tracked: meta.tracked,
generatedLike: meta.generatedLike,
summarized: true,
summarized: !meta.binary,
stamp: meta.stamp,
}
}
Expand All @@ -254,13 +260,15 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
const full = path.join(dir, file)
const exists = await fs.stat(full).catch(() => undefined)
if (!exists) return undefined
const binary = await binaryFile(full)
return {
file,
additions: await lineCount(full),
additions: binary ? 0 : await lineCount(full),
deletions: 0,
status: "added",
tracked: false,
generatedLike: generatedLike(file),
binary,
stamp: await statStamp(dir, file),
}
}
Expand All @@ -278,7 +286,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
if (!code) return undefined

const counts = await numstat(git, dir, anc, file)
const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0 }
const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0, binary: false }
const status = statusFromCode(code)
return {
file: pathPart,
Expand All @@ -287,6 +295,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
status,
tracked: true,
generatedLike: generatedLike(pathPart),
binary: stat.binary,
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart),
}
}
Expand Down Expand Up @@ -345,6 +354,7 @@ export async function diffFile(
if (!anc) return null
const meta = await detailMeta(git, dir, anc, file)
if (!meta) return null
if (meta.binary) return summarize(meta)

// Cheap size probe before materializing content — protects the extension
// host from OOM on huge tracked files. `git cat-file -s` returns the blob
Expand Down
29 changes: 29 additions & 0 deletions packages/kilo-vscode/src/diff/shared/binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as fs from "fs/promises"

const SAMPLE_BYTES = 8_192

function binary(bytes: Uint8Array): boolean {
if (bytes.length === 0) return false

let controls = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) controls++
}
return controls / bytes.length > 0.3
}

export async function binaryFile(file: string): Promise<boolean> {
const stat = await fs.lstat(file).catch(() => undefined)
if (!stat?.isFile()) return false

const handle = await fs.open(file, "r").catch(() => undefined)
if (!handle) return false
const sample = Buffer.alloc(SAMPLE_BYTES)
const read = await handle
.read(sample, 0, sample.length, 0)
.catch(() => undefined)
.finally(() => handle.close())
if (!read) return false
return binary(sample.subarray(0, read.bytesRead))
}
15 changes: 9 additions & 6 deletions packages/kilo-vscode/src/diff/sources/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface FileEntry {
additions: number
deletions: number
tracked: boolean
binary: boolean
stamp?: string
}

Expand All @@ -35,16 +36,17 @@ export function parseNameStatus(stdout: string): { file: string; status: Status
}

/** Parse `git diff --numstat` output into a per-file `{additions, deletions}` map. */
export function parseNumstat(stdout: string): Map<string, { additions: number; deletions: number }> {
const map = new Map<string, { additions: number; deletions: number }>()
export function parseNumstat(stdout: string): Map<string, { additions: number; deletions: number; binary: boolean }> {
const map = new Map<string, { additions: number; deletions: number; binary: boolean }>()
for (const line of stdout.split("\n")) {
if (!line.trim()) continue
const parts = line.split("\t")
if (parts.length < 3) continue
const additions = parts[0] === "-" ? 0 : parseInt(parts[0]!, 10) || 0
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1]!, 10) || 0
const binary = parts[0] === "-" || parts[1] === "-"
const additions = binary ? 0 : parseInt(parts[0]!, 10) || 0
const deletions = binary ? 0 : parseInt(parts[1]!, 10) || 0
const file = parts.slice(2).join("\t")
if (file) map.set(file, { additions, deletions })
if (file) map.set(file, { additions, deletions, binary })
}
return map
}
Expand All @@ -69,7 +71,8 @@ export function summarize(entry: FileEntry): DiffFile {
status: entry.status,
tracked: entry.tracked,
generatedLike: generatedLike(entry.file),
summarized: true,
// Binary metadata is complete because no deferred text body exists.
summarized: !entry.binary,
// Synthetic stamp keyed on the stats we actually polled: any change to
// the file's diff produces new additions/deletions, which invalidates
// the webview-side cached detail via mergeWorktreeDiffs. Callers can
Expand Down
4 changes: 3 additions & 1 deletion packages/kilo-vscode/src/diff/sources/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile {
status: raw.status,
tracked: true,
generatedLike: false,
summarized: raw.patch === "",
// A zero-stat empty patch has no text body to fetch; nonzero stats
// indicate a deferred large-file summary.
summarized: raw.patch === "" && (raw.additions !== 0 || raw.deletions !== 0),
}
}
4 changes: 4 additions & 0 deletions packages/kilo-vscode/src/diff/sources/staged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function createStagedDiffSource(): DiffSource {
additions: counts.get(item.file)?.additions ?? 0,
deletions: counts.get(item.file)?.deletions ?? 0,
tracked: true,
binary: counts.get(item.file)?.binary ?? false,
}))
}

Expand Down Expand Up @@ -80,6 +81,8 @@ export function createStagedDiffSource(): DiffSource {
const entry = await fileEntry(git, dir, file, log)
if (!entry) return null

if (entry.binary) return summarize(entry)

const beforeBytes = entry.status === "added" ? 0 : await blobSize(git, dir, "HEAD", file)
const afterBytes = entry.status === "deleted" ? 0 : await blobSize(git, dir, INDEX_REF, file)
if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) {
Expand Down Expand Up @@ -147,5 +150,6 @@ async function fileEntry(
additions: stats.get(item.file)?.additions ?? 0,
deletions: stats.get(item.file)?.deletions ?? 0,
tracked: true,
binary: stats.get(item.file)?.binary ?? false,
}
}
7 changes: 7 additions & 0 deletions packages/kilo-vscode/src/diff/sources/unstaged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as vscode from "vscode"
import { GitOps } from "../../agent-manager/GitOps"
import { generatedLike } from "../../agent-manager/local-diff"
import { appendOutput, getWorkspaceRoot } from "../../review-utils"
import { binaryFile } from "../shared/binary"
import type { DiffFile } from "../types"
import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "./types"
import {
Expand Down Expand Up @@ -56,6 +57,7 @@ export function createUnstagedDiffSource(): DiffSource {
additions: counts.get(item.file)?.additions ?? 0,
deletions: counts.get(item.file)?.deletions ?? 0,
tracked: true,
binary: counts.get(item.file)?.binary ?? false,
}))
}

Expand All @@ -82,6 +84,7 @@ export function createUnstagedDiffSource(): DiffSource {
additions: 0,
deletions: 0,
tracked: false,
binary: await binaryFile(full),
// Untracked entries always have additions/deletions = 0 (numstat
// can't compute them without an index blob), so fold size+mtime
// into the stamp. Editing the file changes mtime → the webview
Expand Down Expand Up @@ -118,6 +121,8 @@ export function createUnstagedDiffSource(): DiffSource {
const entry = await fileEntry(git, dir, file, log)
if (!entry) return null

if (entry.binary) return summarize(entry)

const beforeBytes = !entry.tracked || entry.status === "added" ? 0 : await blobSize(git, dir, INDEX_REF, file)
const afterBytes = entry.status === "deleted" ? 0 : await fileSize(dir, file)
if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) {
Expand Down Expand Up @@ -191,6 +196,7 @@ async function fileEntry(
additions: stats.get(item.file)?.additions ?? 0,
deletions: stats.get(item.file)?.deletions ?? 0,
tracked: true,
binary: stats.get(item.file)?.binary ?? false,
}
}
}
Expand All @@ -215,6 +221,7 @@ async function fileEntry(
additions: 0,
deletions: 0,
tracked: false,
binary: await binaryFile(full),
stamp: `added:untracked:${stat.size}:${stat.mtimeMs}`,
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-diff-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
eagerDiffFiles,
expandableOpenFiles,
initialOpenFiles,
isDiffExpandable,
sanitizeOpenFiles,
toggleOpenFiles,
} from "../../webview-ui/diff-viewer/diff-open-policy"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
Expand Down Expand Up @@ -72,6 +74,7 @@ describe("agent manager diff state", () => {
initialOpenFiles([
diff({ file: "src/app.ts", generatedLike: false, additions: 3 }),
diff({ file: "node_modules/pkg/index.js", generatedLike: true, additions: 3 }),
diff({ file: "audio/notification.wav", summarized: false, additions: 0 }),
diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }),
]),
).toEqual(["src/app.ts"])
Expand All @@ -85,6 +88,7 @@ describe("agent manager diff state", () => {
expandableOpenFiles([
diff({ file: "src/app.ts", generatedLike: false, additions: 3 }),
diff({ file: "src/generated.ts", generatedLike: true, additions: 3 }),
diff({ file: "assets/archive.zip", summarized: false, additions: 0 }),
diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }),
]),
).toEqual(["src/app.ts"])
Expand All @@ -95,6 +99,7 @@ describe("agent manager diff state", () => {
diff({ file: "src/app.ts" }),
diff({ file: "src/panel.ts" }),
diff({ file: "src/generated.ts", generatedLike: true }),
diff({ file: "audio/alert.mp3", summarized: false, additions: 0 }),
diff({ file: "src/huge.ts", additions: EXTREME_DIFF_CHANGED_LINES + 1 }),
]

Expand All @@ -109,6 +114,15 @@ describe("agent manager diff state", () => {
expect(toggleOpenFiles(diffs, ["src/app.ts"])).toEqual(["src/app.ts", "src/panel.ts"])
expect(toggleOpenFiles(diffs, ["src/app.ts", "src/panel.ts"])).toEqual([])
})

it("prevents non-text diffs from entering open state", () => {
const audio = diff({ file: "audio/alert.wav", summarized: false, additions: 0 })
const text = diff({ file: "src/app.ts" })

expect(isDiffExpandable(audio)).toBe(false)
expect(isDiffExpandable(text)).toBe(true)
expect(sanitizeOpenFiles([audio, text], [audio.file, text.file])).toEqual([text.file])
})
})

describe("eager diff files", () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/kilo-vscode/tests/unit/diff-session-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,20 @@ describe("createSessionDiffSource.fetch", () => {
deletions: 0,
status: "modified",
},
{
file: "large.txt",
patch: "",
additions: 500,
deletions: 200,
status: "modified",
},
]
const { fetch } = recording(raw)
const source = createSessionDiffSource("s2", fetch, "/repo")

const result = await source.fetch()

expect(result.diffs).toHaveLength(2)
expect(result.diffs).toHaveLength(3)

const foo = result.diffs[0]!
expect(foo.file).toBe("foo.ts")
Expand All @@ -78,9 +85,10 @@ describe("createSessionDiffSource.fetch", () => {
expect(foo.summarized).toBe(false)

const big = result.diffs[1]!
expect(big.summarized).toBe(true)
expect(big.summarized).toBe(false)
expect(big.before).toBe("")
expect(big.after).toBe("")
expect(result.diffs[2]?.summarized).toBe(true)
})

it("propagates errors from the underlying fetch", async () => {
Expand Down
Loading
Loading