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/render-image-diffs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Render image changes in diff viewers and open images with VS Code's image preview.
27 changes: 21 additions & 6 deletions packages/kilo-vscode/src/agent-manager/GitOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export interface ExecResult {
stderr: string
}

export interface ExecBufferResult {
code: number
stdout: Buffer
stderr: string
}

/**
* Fixed SSH command injected by {@link nonInteractiveEnv} when the user has
* not already configured their own. Exported so callers can check whether a
Expand Down Expand Up @@ -532,12 +538,21 @@ export class GitOps {
return this.exec(args, cwd, options)
}

private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
execGitBuffer(args: string[], cwd: string): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd)
}

private async exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
const result = await this.execBuffer(args, cwd, options)
return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr }
}

private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
if (this.controller.signal.aborted) {
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" })
}
const invoke = () =>
new Promise<ExecResult>((resolve) => {
new Promise<ExecBufferResult>((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
Expand All @@ -547,7 +562,7 @@ export class GitOps {

if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" })
return
}
child.stdin.end(options.stdin)
Expand All @@ -559,12 +574,12 @@ export class GitOps {
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))

child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stdout: Buffer.concat(out),
stderr: Buffer.concat(err).toString("utf8"),
})
})
Expand Down
83 changes: 63 additions & 20 deletions packages/kilo-vscode/src/agent-manager/local-diff.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "fs/promises"
import * as path from "path"
import { binaryFile } from "../diff/shared/binary"
import { imageMime, loadImage, readImageFile } from "../diff/shared/image"
import { resolveInside } from "../diff/shared/path"
import type { GitOps } from "./GitOps"
import type { WorktreeDiffEntry } from "./types"

Expand Down Expand Up @@ -139,16 +140,20 @@ async function numstat(git: GitOps, dir: string, base: string, file?: string) {
}

async function statStamp(dir: string, file: string): Promise<string> {
const stat = await fs.stat(path.join(dir, file)).catch(() => undefined)
const full = resolveInside(dir, file)
if (!full) return `missing:${file}`
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat) return `missing:${file}`
return `${stat.size}:${stat.mtimeMs}`
}

async function lineCount(file: string): Promise<number> {
const stat = await fs.stat(file).catch(() => undefined)
const stat = await fs.lstat(file).catch(() => undefined)
if (!stat || stat.size === 0) return 0
if (stat.size > MAX_UNTRACKED_BYTES) return 0
const content = await fs.readFile(file, "utf-8").catch(() => "")
const content = stat.isSymbolicLink()
? await fs.readlink(file).catch(() => "")
: await fs.readFile(file, "utf-8").catch(() => "")
if (!content) return 0
if (content.endsWith("\n")) return content.split("\n").length - 1
return content.split("\n").length
Expand Down Expand Up @@ -191,7 +196,8 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
tracked: true,
generatedLike: generatedLike(file),
binary: stat.binary,
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, file),
stamp:
status === "deleted" ? `deleted:${anc}` : `${imageMime(file) ? `${anc}:` : ""}${await statStamp(dir, file)}`,
})
}

Expand All @@ -206,8 +212,9 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M

for (const file of files.split("\n")) {
if (!file || seen.has(file)) continue
const full = path.join(dir, file)
const exists = await fs.stat(full).catch(() => undefined)
const full = resolveInside(dir, file)
if (!full) continue
const exists = await fs.lstat(full).catch(() => undefined)
if (!exists) continue
const binary = await binaryFile(full)
result.push({
Expand All @@ -226,6 +233,7 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
}

function summarize(meta: Meta): WorktreeDiffEntry {
const image = imageMime(meta.file) !== undefined
return {
file: meta.file,
patch: "",
Expand All @@ -236,8 +244,9 @@ function summarize(meta: Meta): WorktreeDiffEntry {
status: meta.status,
tracked: meta.tracked,
generatedLike: meta.generatedLike,
summarized: !meta.binary,
summarized: image || !meta.binary,
stamp: meta.stamp,
kind: image ? "image" : undefined,
}
}

Expand Down Expand Up @@ -274,18 +283,22 @@ export function createLocalDiff(git: GitOps, log?: Log) {
},
file: async (dir: string, base: string, file: string): Promise<WorktreeDiffEntry | null> => {
const state = states.get(`${dir}\0${base}`)
const meta = state?.metas.get(file)
if (!state || !meta) return diffFile(git, dir, base, file, log)
if (!state) return diffFile(git, dir, base, file, log)
const meta = state.metas.get(file)
if (!meta) return null
return materialize(git, dir, state.anc, meta, log)
},
}
}

async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise<Meta | undefined> {
const full = resolveInside(dir, file)
if (!full) return undefined
const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir)
if (tracked.code !== 0) {
const full = path.join(dir, file)
const exists = await fs.stat(full).catch(() => undefined)
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard", "--", file], dir)
if (untracked.code !== 0 || !untracked.stdout.split("\n").includes(file)) return undefined
const exists = await fs.lstat(full).catch(() => undefined)
if (!exists) return undefined
const binary = await binaryFile(full)
return {
Expand Down Expand Up @@ -323,7 +336,10 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
tracked: true,
generatedLike: generatedLike(pathPart),
binary: stat.binary,
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart),
stamp:
status === "deleted"
? `deleted:${anc}`
: `${imageMime(pathPart) ? `${anc}:` : ""}${await statStamp(dir, pathPart)}`,
}
}

Expand All @@ -334,10 +350,25 @@ async function blobSize(git: GitOps, dir: string, anc: string, file: string): Pr
}

async function fileSize(dir: string, file: string): Promise<number> {
const stat = await fs.stat(path.join(dir, file)).catch(() => undefined)
const full = resolveInside(dir, file)
if (!full) return 0
const stat = await fs.lstat(full).catch(() => undefined)
return stat?.size ?? 0
}

async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise<Buffer | undefined> {
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir)
return result.code === 0 ? result.stdout : undefined
}

async function readFile(dir: string, file: string): Promise<Buffer | undefined> {
const full = resolveInside(dir, file)
if (!full) return undefined
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat?.isFile()) return undefined
return readImageFile(full)
}

async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
if (status === "added") return ""
const result = await git.execGit(["show", `${anc}:${file}`], dir)
Expand All @@ -346,9 +377,12 @@ async function readBefore(git: GitOps, dir: string, anc: string, file: string, s

async function readAfter(dir: string, file: string, status: Status): Promise<string> {
if (status === "deleted") return ""
const full = path.join(dir, file)
const exists = await fs.stat(full).catch(() => undefined)
if (!exists) return ""
const full = resolveInside(dir, file)
if (!full) return ""
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat) return ""
if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "")
if (!stat.isFile()) return ""
return fs.readFile(full, "utf-8").catch(() => "")
}

Expand Down Expand Up @@ -385,12 +419,21 @@ export async function diffFile(
}

async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise<WorktreeDiffEntry> {
if (meta.binary) return summarize(meta)
const mime = imageMime(meta.file)
if (meta.binary && !mime) return summarize(meta)
const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file)
const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file)
if (mime) {
const image = await loadImage(
meta.file,
meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) },
meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) },
)
return { ...summarize(meta), summarized: false, image }
}
// Cheap size probe before materializing content — protects the extension
// host from OOM on huge tracked files. `git cat-file -s` returns the blob
// size without streaming its contents, and `fs.stat` is a plain syscall.
const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file)
const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file)
if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) {
log?.("diffFile: file too large for detail view, returning summarized entry", {
file: meta.file,
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-vscode/src/agent-manager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client"
import type { DiffImage } from "../diff/types"
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { ApplyConflict } from "./GitOps"
Expand All @@ -33,6 +34,8 @@ export type WorktreeDiffEntry = SnapshotFileDiff & {
generatedLike?: boolean
summarized?: boolean
stamp?: string
kind?: "image"
image?: DiffImage
}

// ---------------------------------------------------------------------------
Expand Down
69 changes: 69 additions & 0 deletions packages/kilo-vscode/src/diff/shared/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createReadStream } from "fs"
import * as path from "path"
import type { DiffImage, DiffImageSide } from "../types"

const MIMES: Record<string, string> = {
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".ico": "image/x-icon",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}

export const MAX_IMAGE_BYTES = 5_000_000

export interface DiffImageSource {
bytes: number
read: () => Promise<Buffer | undefined>
}

export function imageMime(file: string): string | undefined {
return MIMES[path.extname(file).toLowerCase()]
}

export function encodeImageSide(mime: string, data: Buffer | undefined, bytes = data?.byteLength ?? 0): DiffImageSide {
if (bytes > MAX_IMAGE_BYTES) return { mime, bytes, error: "too-large" }
if (!data || data.byteLength === 0) return { mime, bytes, error: "unreadable" }
if (data.byteLength > MAX_IMAGE_BYTES) return { mime, bytes: data.byteLength, error: "too-large" }
return { mime, bytes: data.byteLength, data: data.toString("base64") }
}

export function readImageFile(file: string): Promise<Buffer | undefined> {
return new Promise((resolve) => {
const chunks: Buffer[] = []
let bytes = 0
const stream = createReadStream(file, { end: MAX_IMAGE_BYTES, highWaterMark: 64 * 1024 })
stream.on("data", (chunk) => {
const data = typeof chunk === "string" ? Buffer.from(chunk) : chunk
chunks.push(data)
bytes += data.byteLength
})
stream.on("error", () => resolve(undefined))
stream.on("end", () => resolve(Buffer.concat(chunks, bytes)))
})
}

async function load(mime: string, source: DiffImageSource): Promise<DiffImageSide> {
if (source.bytes > MAX_IMAGE_BYTES) return { mime, bytes: source.bytes, error: "too-large" }
const data = await source.read().catch(() => undefined)
return encodeImageSide(mime, data, source.bytes)
}

export async function loadImage(
file: string,
before?: DiffImageSource,
after?: DiffImageSource,
): Promise<DiffImage | undefined> {
const mime = imageMime(file)
if (!mime) return undefined
const [left, right] = await Promise.all([
before ? load(mime, before) : undefined,
after ? load(mime, after) : undefined,
])
return { before: left, after: right }
}
9 changes: 9 additions & 0 deletions packages/kilo-vscode/src/diff/shared/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as path from "path"

export function resolveInside(dir: string, file: string): string | undefined {
if (path.isAbsolute(file)) return undefined
const full = path.resolve(dir, file)
const base = path.resolve(dir)
if (full !== base && !full.startsWith(base + path.sep)) return undefined
return full
}
Loading
Loading