Merge pull request #13411 from Kilo-Org/optimize-worktree-diff-loading

perf(agent-manager): optimize worktree diff loading
This commit is contained in:
Marius
2026-08-25 17:32:07 +02:00
committed by GitHub
24 changed files with 805 additions and 202 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees.
@@ -213,7 +213,7 @@ export class AgentManagerProvider implements Disposable {
log: (msg) => this.log(msg),
})
const local = createLocalDiff(this.gitOps, (...args) => this.log(...args))
this.diffCatalog = new DiffSourceCatalog(this.connectionService)
this.diffCatalog = new DiffSourceCatalog(this.connectionService, local)
this.diffs = new WorktreeDiffController({
getState: () => this.getStateManager(),
getRoot: () => this.getRoot(),
@@ -44,6 +44,7 @@ interface ExecOptions {
env?: NodeJS.ProcessEnv
stdin?: string
timeout?: number
signal?: AbortSignal
}
export interface ExecResult {
@@ -594,12 +595,12 @@ export class GitOps {
* suitable for callers that need to tolerate legitimate failures (e.g.
* `merge-base` on an orphan branch, `ls-files --error-unmatch`).
*/
execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise<ExecResult> {
execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise<ExecResult> {
return this.exec(args, cwd, options)
}
execGitBuffer(args: string[], cwd: string): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd)
execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd, options)
}
private async exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
@@ -616,7 +617,7 @@ export class GitOps {
return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }
}
const invoke = () => this.invoke(cmd, args, cwd, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
return this.semaphore ? this.semaphore.run(invoke, options?.signal) : invoke()
}
private executable(): Promise<string> {
@@ -642,7 +643,7 @@ export class GitOps {
}
private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
if (this.controller.signal.aborted) {
if (this.controller.signal.aborted || options?.signal?.aborted) {
return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" })
}
@@ -664,6 +665,7 @@ export class GitOps {
: undefined
this.controller.signal.addEventListener("abort", abort, { once: true })
options?.signal?.addEventListener("abort", abort, { once: true })
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
@@ -673,6 +675,7 @@ export class GitOps {
child.on("close", (code) => {
if (timeout) clearTimeout(timeout)
this.controller.signal.removeEventListener("abort", abort)
options?.signal?.removeEventListener("abort", abort)
resolve({
code: code ?? 1,
stdout: Buffer.concat(out),
@@ -117,26 +117,29 @@ async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Prom
return result.stdout.trim()
}
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; binary: boolean }>()
if (result.code !== 0) return map
for (const line of result.stdout.trim().split("\n")) {
if (!line) continue
function counts(value: string) {
const result = new Map<string, { additions: number; deletions: number; binary: boolean }>()
for (const line of value.trim().split("\n")) {
if (!line || line.startsWith(":")) continue
const parts = line.split("\t")
const add = parts[0]
const del = parts[1]
const name = parts.slice(2).join("\t")
if (!name) continue
map.set(name, {
const file = parts.slice(2).join("\t")
if (!file) continue
result.set(file, {
additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0,
deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0,
binary: add === "-" || del === "-",
})
}
return map
return result
}
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)
return counts(result.code === 0 ? result.stdout : "")
}
async function statStamp(dir: string, file: string): Promise<string> {
@@ -144,7 +147,22 @@ async function statStamp(dir: string, file: string): Promise<string> {
if (!full) return `missing:${file}`
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat) return `missing:${file}`
return `${stat.size}:${stat.mtimeMs}`
return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}`
}
async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) {
return Promise.all([
readBefore(git, dir, anc, meta.file, meta.status, signal),
readAfter(dir, meta.file, meta.status),
meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""),
])
}
async function sizes(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) {
return Promise.all([
meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal),
meta.status === "deleted" ? 0 : fileSize(dir, meta.file),
])
}
async function lineCount(file: string): Promise<number> {
@@ -166,28 +184,28 @@ function statusFromCode(code: string): Status {
}
async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<Meta[]> {
const nameStatus = await git.execGit(
["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc],
dir,
)
if (nameStatus.code !== 0) {
log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() })
const [tracked, untracked] = await Promise.all([
git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir),
git.execGit(["ls-files", "--others", "--exclude-standard"], dir),
])
if (tracked.code !== 0) {
log?.("git diff --raw --numstat failed", { code: tracked.code, stderr: tracked.stderr.trim() })
return []
}
const counts = await numstat(git, dir, anc)
const result: Meta[] = []
const seen = new Set<string>()
const stats = counts(tracked.stdout)
for (const line of nameStatus.stdout.trim().split("\n")) {
if (!line) continue
for (const line of tracked.stdout.trim().split("\n")) {
if (!line.startsWith(":")) continue
const parts = line.split("\t")
const code = parts[0]
const code = parts[0]?.split(" ").at(-1)
const file = parts.slice(1).join("\t")
if (!file || !code) continue
seen.add(file)
const status = statusFromCode(code)
const stat = counts.get(file) ?? { additions: 0, deletions: 0, binary: false }
const stat = stats.get(file) ?? { additions: 0, deletions: 0, binary: false }
result.push({
file,
additions: stat.additions,
@@ -201,7 +219,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<M
})
}
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir)
if (untracked.code !== 0) {
log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() })
return result
@@ -265,28 +282,83 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?:
export function createLocalDiff(git: GitOps, log?: Log) {
const states = new Map<string, { anc: string; metas: Map<string, Meta> }>()
const generations = new Map<string, number>()
const details = new Map<string, { value: WorktreeDiffEntry; bytes: number; stamp: string }>()
const pending = new Map<string, { signal?: AbortSignal; work: Promise<WorktreeDiffEntry> }>()
let bytes = 0
const forget = (id: string) => {
const value = details.get(id)
if (!value) return
bytes -= value.bytes
details.delete(id)
}
const remember = (id: string, value: WorktreeDiffEntry, stamp: string) => {
const size = [value.before, value.after, value.patch, value.image?.before?.data, value.image?.after?.data].reduce(
(sum, value) => sum + Buffer.byteLength(value ?? ""),
0,
)
const current = details.get(id)
if (current) bytes -= current.bytes
details.delete(id)
details.set(id, { value, bytes: size, stamp })
bytes += size
while (details.size > 128 || bytes > 64 * 1024 * 1024) {
const key = details.keys().next().value!
bytes -= details.get(key)!.bytes
details.delete(key)
}
}
return {
summary: async (dir: string, base: string): Promise<WorktreeDiffEntry[]> => {
const id = `${dir}\0${base}`
const generation = (generations.get(id) ?? 0) + 1
generations.set(id, generation)
const anc = await ancestor(git, dir, base, log)
if (!anc) {
states.delete(id)
if (generations.get(id) === generation) states.delete(id)
return []
}
const items = await list(git, dir, anc, log)
if (generations.get(id) !== generation) return items.map(summarize)
states.delete(id)
states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) })
if (states.size > 8) states.delete(states.keys().next().value!)
return items.map(summarize)
},
file: async (dir: string, base: string, file: string): Promise<WorktreeDiffEntry | null> => {
file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise<WorktreeDiffEntry | null> => {
const state = states.get(`${dir}\0${base}`)
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)
const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}`
const cached = details.get(id)
if (cached) {
if (cached.stamp === meta.stamp) {
remember(id, cached.value, meta.stamp)
return cached.value
}
forget(id)
}
const current = pending.get(id)
if (current && !current.signal?.aborted) return current.work
const work = materialize(git, dir, state.anc, meta, log, signal)
pending.set(id, { signal, work })
work.then(
(value) => {
if (pending.get(id)?.work !== work) return
pending.delete(id)
if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return
remember(id, value, meta.stamp)
},
() => {
if (pending.get(id)?.work === work) pending.delete(id)
},
)
return work
},
}
}
@@ -343,9 +415,9 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
}
}
async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise<number> {
const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir)
if (result.code !== 0) return 0
async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise<number> {
const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal })
if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`)
return parseInt(result.stdout.trim(), 10) || 0
}
@@ -356,8 +428,14 @@ async function fileSize(dir: string, file: string): Promise<number> {
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)
async function readBlob(
git: GitOps,
dir: string,
ref: string,
file: string,
signal?: AbortSignal,
): Promise<Buffer | undefined> {
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir, { signal })
return result.code === 0 ? result.stdout : undefined
}
@@ -369,29 +447,47 @@ async function readFile(dir: string, file: string): Promise<Buffer | undefined>
return readImageFile(full)
}
async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
async function readBefore(
git: GitOps,
dir: string,
anc: string,
file: string,
status: Status,
signal?: AbortSignal,
): Promise<string> {
if (status === "added") return ""
const result = await git.execGit(["show", `${anc}:${file}`], dir)
return result.code === 0 ? result.stdout : ""
const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal })
if (result.code !== 0) throw new Error(`Could not read base file for ${file}`)
return result.stdout
}
async function readAfter(dir: string, file: string, status: Status): Promise<string> {
if (status === "deleted") return ""
const full = resolveInside(dir, file)
if (!full) return ""
if (!full) throw new Error(`Could not resolve working file for ${file}`)
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat) return ""
if (!stat) throw new Error(`Could not read working file for ${file}`)
if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "")
if (!stat.isFile()) return ""
return fs.readFile(full, "utf-8").catch(() => "")
if (!stat.isFile()) throw new Error(`Working path is not a file: ${file}`)
return fs.readFile(full, "utf-8").catch(() => {
throw new Error(`Could not read working file for ${file}`)
})
}
async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise<string> {
async function unifiedPatch(
git: GitOps,
dir: string,
anc: string,
file: string,
signal?: AbortSignal,
): Promise<string> {
const result = await git.execGit(
["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file],
dir,
{ signal },
)
return result.code === 0 ? result.stdout : ""
if (result.code !== 0) throw new Error(`Could not create diff for ${file}`)
return result.stdout
}
function linesOf(text: string): number {
@@ -418,17 +514,27 @@ export async function diffFile(
return materialize(git, dir, anc, meta, log)
}
async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise<WorktreeDiffEntry> {
async function materialize(
git: GitOps,
dir: string,
anc: string,
meta: Meta,
log?: Log,
signal?: AbortSignal,
): Promise<WorktreeDiffEntry> {
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)
const [beforeBytes, afterBytes] = await sizes(git, dir, anc, meta, signal)
if (signal?.aborted) throw new Error("Diff detail aborted")
if (mime) {
const image = await loadImage(
meta.file,
meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) },
meta.status === "added"
? undefined
: { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) },
meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) },
)
if (signal?.aborted) throw new Error("Diff detail aborted")
return { ...summarize(meta), summarized: false, image }
}
// Cheap size probe before materializing content — protects the extension
@@ -444,9 +550,9 @@ async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, lo
return summarize(meta)
}
const before = await readBefore(git, dir, anc, meta.file, meta.status)
const after = await readAfter(dir, meta.file, meta.status)
const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after)
const [before, after, tracked] = await detailReads(git, dir, anc, meta, signal)
if (signal?.aborted) throw new Error("Diff detail aborted")
const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after)
const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions
return {
file: meta.file,
@@ -7,12 +7,12 @@
*/
export class Semaphore {
private running = 0
private readonly pending: (() => void)[] = []
private readonly pending: { resolve: () => void; abort?: () => void }[] = []
constructor(private readonly limit: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
async run<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
await this.acquire(signal)
try {
return await fn()
} finally {
@@ -20,22 +20,32 @@ export class Semaphore {
}
}
private acquire(): Promise<void> {
private acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(signal.reason)
if (this.running < this.limit) {
this.running++
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pending.push(() => {
this.running++
resolve()
})
return new Promise<void>((resolve, reject) => {
const item: { resolve: () => void; abort: () => void } = {
resolve: () => {
signal?.removeEventListener("abort", item.abort)
this.running++
resolve()
},
abort: () => {
const index = this.pending.indexOf(item)
if (index !== -1) this.pending.splice(index, 1)
reject(signal?.reason)
},
}
signal?.addEventListener("abort", item.abort, { once: true })
this.pending.push(item)
})
}
private release(): void {
this.running--
const next = this.pending.shift()
if (next) next()
this.pending.shift()?.resolve()
}
}
@@ -67,6 +67,7 @@ export class SourceController {
private interval: ReturnType<typeof setInterval> | undefined
private lastHash: string | undefined
private epoch = 0
private readonly fetches = new Map<DiffSource, Promise<boolean>>()
constructor(
private readonly build: (id: string, ctx: PanelContext) => DiffSource,
@@ -91,6 +92,7 @@ export class SourceController {
stop(): void {
this.epoch++
this.stopPolling()
this.fetches.clear()
this.active?.dispose?.()
this.active = undefined
this.activeId = undefined
@@ -117,7 +119,7 @@ export class SourceController {
if (opts.fetch === false) return
const keepPolling = await this.runFetch(source, epoch, true)
const keepPolling = await this.fetch(source, epoch, true)
// Prevents the polling interval from starting after teardown or swap.
if (this.epoch !== epoch || this.activeId !== id) return
if (opts.poll !== false && keepPolling) this.startPolling(source, epoch)
@@ -151,7 +153,7 @@ export class SourceController {
// Push fresh diffs immediately after a successful revert so the webview
// doesn't have to wait for the next polling tick.
if (result.ok && this.epoch === epoch && this.active === source) {
await this.runFetch(source, epoch, false)
await this.fetch(source, epoch, true, true)
}
}
@@ -160,7 +162,7 @@ export class SourceController {
const source = this.active
if (!source) return
const epoch = this.epoch
await this.runFetch(source, epoch, true)
await this.fetch(source, epoch, true, true)
}
/**
@@ -180,6 +182,13 @@ export class SourceController {
this.send(this.messages.diffFile(source, file, null))
return
}
// Yield once so a worktree switch can advance the epoch before queued
// detail work enters the shared Git semaphore.
await new Promise<void>((resolve) => setTimeout(resolve, 0))
if (this.epoch !== epoch || this.active !== source) {
this.send(this.messages.diffFile(source, file, null))
return
}
const diff = await source.fetchFile(file).catch(() => null)
// Discard stale content after disposal/swap, but still complete the request
// so consumers can clear per-file loading state.
@@ -239,10 +248,15 @@ export class SourceController {
private startPolling(source: DiffSource, epoch: number): void {
this.stopPolling()
let busy = false
this.interval = setInterval(async () => {
if (busy) return
busy = true
// Self-cancel when the tick reports the source is done
const keep = await this.runFetch(source, epoch, false)
if (!keep) this.stopPolling()
const keep = await this.fetch(source, epoch, false).finally(() => {
busy = false
})
if (!keep && this.epoch === epoch && this.active === source) this.stopPolling()
}, DIFF_POLL_INTERVAL_MS)
}
@@ -252,4 +266,22 @@ export class SourceController {
this.interval = undefined
}
}
private fetch(source: DiffSource, epoch: number, initial: boolean, force = false): Promise<boolean> {
const current = this.fetches.get(source)
if (current && !force) return current
if (current) {
return current.then(() => {
if (this.epoch !== epoch || this.active !== source) return false
return this.fetch(source, epoch, initial)
})
}
const work = this.runFetch(source, epoch, initial)
this.fetches.set(source, work)
const clear = () => {
if (this.fetches.get(source) === work) this.fetches.delete(source)
}
void work.finally(clear).catch(() => undefined)
return work
}
}
@@ -18,6 +18,12 @@ import {
import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn"
import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged"
import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged"
import type { WorktreeDiffEntry } from "../../agent-manager/types"
export interface LocalDiffSource {
summary: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
export interface WorkspaceBranchesResult {
branches: BranchListItem[]
@@ -68,7 +74,10 @@ export class DiffSourceCatalog implements vscode.Disposable {
private branchGit: GitOps | undefined
private branchOutput: vscode.OutputChannel | undefined
constructor(private readonly connection: KiloConnectionService) {}
constructor(
private readonly connection: KiloConnectionService,
private readonly local?: LocalDiffSource,
) {}
listAvailable(ctx: PanelContext): DiffSourceDescriptor[] {
if (ctx.hidePicker) return []
@@ -96,6 +105,8 @@ export class DiffSourceCatalog implements vscode.Disposable {
...opts,
baseBranchOverride: ctx.baseBranchOverride,
baseBranch: ctx.baseBranch,
summary: this.local?.summary,
file: this.local?.file,
})
}
@@ -45,6 +45,8 @@ export interface WorktreeDiffSourceOptions {
/** Shared GitOps / log so sources don't each spawn their own channel. */
git?: GitOps
log?: (...args: unknown[]) => void
summary?: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
/**
@@ -57,6 +59,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace")
const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args))
const git = opts.git ?? new GitOps({ log })
const controller = new AbortController()
const root = (): string | undefined => {
const dir = opts.dir?.()
@@ -101,7 +104,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
}
const status: StatusResolver = async (current, file) => {
const entry = await diffFile(git, current.directory, current.baseBranch, file, log)
const entry = opts.file
? await opts.file(current.directory, current.baseBranch, file)
: await diffFile(git, current.directory, current.baseBranch, file, log)
return entry?.status
}
@@ -112,7 +117,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
const current = await resolveTarget()
if (!current) return { diffs: [] }
const entries = await diffSummary(git, current.directory, current.baseBranch, log)
const entries = opts.summary
? await opts.summary(current.directory, current.baseBranch)
: await diffSummary(git, current.directory, current.baseBranch, log)
const diffs = entries.map(toDiffFile)
log(`Diff: ${diffs.length} file(s)`)
return { diffs }
@@ -124,7 +131,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
if (!current) return null
try {
const entry = await diffFile(git, current.directory, current.baseBranch, file, log)
const entry = opts.file
? await opts.file(current.directory, current.baseBranch, file, controller.signal)
: await diffFile(git, current.directory, current.baseBranch, file, log)
if (!entry) return null
return toDiffFile(entry)
} catch (err) {
@@ -152,6 +161,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}):
// owned by the caller.
if (!opts.git) git.dispose()
output?.dispose()
controller.abort()
target = undefined
},
}
@@ -29,6 +29,8 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"),
path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"),
path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"),
@@ -34,7 +34,7 @@ describe("createWorktreeDiffs", () => {
it("stores full diffs per session", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
expect(diffs.diffDatas()["s1"]).toHaveLength(1)
expect(diffs.diffDatas()["single\0s1"]).toHaveLength(1)
})
})
@@ -56,7 +56,7 @@ describe("createWorktreeDiffs", () => {
file: "a.ts",
diff: diff("a.ts", 9),
})
expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9)
expect(diffs.diffDatas()["single\0s1"]![0]!.additions).toBe(9)
expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0)
})
})
@@ -65,11 +65,22 @@ describe("createWorktreeDiffs", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true })
expect(diffs.diffLoading()).toBe(true)
expect(diffs.diffLoadingFor(() => "s1")).toBe(true)
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [] })
expect(diffs.diffLoadingFor(() => "s1")).toBe(false)
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false })
expect(diffs.diffLoading()).toBe(false)
})
})
it("keeps loading isolated to its composite diff id", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1#branch", loading: true })
expect(diffs.diffLoadingFor(() => "s1#branch")).toBe(true)
expect(diffs.diffLoadingFor(() => "s2#branch")).toBe(false)
})
})
it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => {
withDiffs((diffs, sent) => {
diffs.requestDiffFile("s1", "a.ts")
@@ -667,6 +667,20 @@ describe("GitOps", () => {
})
})
it("kills an in-flight exec when its request signal aborts", async () => {
await withRepo(async (cwd) => {
const git = new GitOps({ log: () => undefined, binary: async () => process.execPath })
const ctl = new AbortController()
const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal })
await sleep(25)
ctl.abort()
const result = await pending
expect(result.code).not.toBe(0)
git.dispose()
})
})
it("is safe to call multiple times", () => {
const git = ops(async () => "ok")
git.dispose()
@@ -352,6 +352,52 @@ describe("diffFile", () => {
})
})
it("reuses cached detail while the summary stamp is unchanged", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
const local = createLocalDiff(git())
await local.summary(dir, base)
const first = await local.file(dir, base, "seed.txt")
const second = await local.file(dir, base, "seed.txt")
expect(second).toBe(first)
})
})
it("does not cache detail that is aborted before Git completes", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n")
const local = createLocalDiff(git())
await local.summary(dir, base)
const ctl = new AbortController()
const pending = local.file(dir, base, "seed.txt", ctl.signal)
ctl.abort()
await expect(pending).rejects.toThrow()
const result = await local.file(dir, base, "seed.txt")
expect(result?.after).toBe("seed\ncached\n")
})
})
it("invalidates cached detail after the summary stamp changes", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n")
const local = createLocalDiff(git())
await local.summary(dir, base)
const first = await local.file(dir, base, "seed.txt")
await new Promise((resolve) => setTimeout(resolve, 5))
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nsecond value\n")
await local.summary(dir, base)
const second = await local.file(dir, base, "seed.txt")
expect(second).not.toBe(first)
expect(second?.after).toBe("seed\nsecond value\n")
})
})
it("does not materialize binary detail from a cached summary", async () => {
await withRepo(async (dir, base) => {
await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03]))
@@ -69,6 +69,22 @@ describe("Semaphore", () => {
expect(order).toEqual([1, 2, 3])
})
it("removes an aborted task from the pending queue", async () => {
const sem = new Semaphore(1)
let release: () => void = () => {}
const first = sem.run(() => new Promise<void>((resolve) => (release = resolve)))
const controller = new AbortController()
const aborted = sem.run(async () => "aborted", controller.signal)
const next = sem.run(async () => "next")
controller.abort(new Error("cancelled"))
await expect(aborted).rejects.toThrow("cancelled")
release()
expect(await next).toBe("next")
await first
})
it("allows full concurrency when limit exceeds task count", async () => {
const sem = new Semaphore(10)
let running = 0
@@ -346,6 +346,36 @@ describe("SourceController.requestFile", () => {
controller.stop()
})
it("does not start queued detail work after the source changes", async () => {
let details = 0
const workspace: DiffSource = {
descriptor: WORKSPACE_DESC,
async fetch() {
return { diffs: [] }
},
async fetchFile() {
details++
return null
},
}
const session: DiffSource = {
descriptor: SESSION_DESC,
async fetch() {
return { diffs: [] }
},
}
const { controller } = make({ workspace, "session:s1": session })
controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" })
await controller.activate("workspace")
const request = controller.requestFile("foo.ts")
await controller.activate("session:s1")
await request
expect(details).toBe(0)
controller.stop()
})
it("posts null when a pending fetchFile result is invalidated by stop", async () => {
let release: () => void = () => {}
const workspace: DiffSource = {
@@ -459,4 +489,29 @@ describe("SourceController.refresh", () => {
controller.stop()
})
it("runs a forced refresh after an in-flight fetch", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
let fetches = 0
const source: DiffSource = {
descriptor: SESSION_DESC,
async fetch() {
fetches++
await gate
return { diffs: [] }
},
}
const { controller } = make({ "session:s1": source })
controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" })
const activation = controller.activate("session:s1", { poll: false })
const refresh = controller.refresh()
release()
await Promise.all([activation, refresh])
expect(fetches).toBe(2)
controller.stop()
})
})
@@ -162,14 +162,14 @@ import {
import { createEmbeddedTerminalReader } from "./terminal/output"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
import { DiffPanel } from "./DiffPanel"
import { DiffPanelCache } from "./DiffPanelCache"
import { PRPanelHost } from "./pr/PRPanelHost"
import { createRevertFile } from "./revert-file"
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
import { createApplyToLocal } from "./apply-to-local"
import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs"
import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs"
import type { ReviewComment } from "../diff-viewer/review-comments"
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
import { createReviewComposers } from "./review-composers"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
import { randomColor } from "./section-colors"
@@ -320,6 +320,7 @@ const AgentManagerContent: Component = () => {
}
}
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
const [diffMounted, setDiffMounted] = createSignal(false)
const diffOpen = () => sidePanel() === SidePanel.Diff
const prOpen = () => sidePanel() === SidePanel.PR
const activePR = createMemo(() => {
@@ -330,6 +331,7 @@ const AgentManagerContent: Component = () => {
return { pr, selected, wt: worktrees().find((w) => w.id === selected) }
})
const diffs = createWorktreeDiffs(vscode, activeProjectId)
createEffect(on(activeProjectId, diffs.reset, { defer: true }))
const diffDatas = diffs.diffDatas
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
@@ -341,7 +343,8 @@ const AgentManagerContent: Component = () => {
setReviewActive(false)
setSidePanel(SidePanel.Terminal)
}
const reviewComposer = createReviewComposer()
const composers = createReviewComposers(currentProjectId)
createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true }))
const reviewState = createReviewState()
const reviewOpenByContext = reviewState.open
const setReviewOpenByContext = reviewState.setOpen
@@ -535,7 +538,6 @@ const AgentManagerContent: Component = () => {
setPendingDelete(null)
}
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
createEffect(
on(
selection,
@@ -559,16 +561,6 @@ const AgentManagerContent: Component = () => {
if (sel === null) return
setReviewOpenForContext(sel, open)
}
const reviewComments = createMemo(() => {
const sel = selection()
if (sel === null) return [] as ReviewComment[]
return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel)
})
const setReviewCommentsForSelection = (comments: ReviewComment[]) => {
const sel = selection()
if (sel === null) return
setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments))
}
const apply = createApplyToLocal({
vscode,
dialog,
@@ -729,6 +721,7 @@ const AgentManagerContent: Component = () => {
})
createEffect(() => {
const ids = new Set(worktrees().map((wt) => wt.id))
composers.prune(ids)
setReviewOpenByContext((prev) => {
const next = pruneReviewState(prev, currentProjectId() ?? "single", ids)
if (Object.keys(next).length === Object.keys(prev).length) return prev
@@ -1509,18 +1502,11 @@ const AgentManagerContent: Component = () => {
}
}
// Set per-session model selection without clearing busy state.
// Used during Phase 1 of multi-version creation so the UI selector
// reflects the correct model as soon as the worktree appears.
if ((msg as { type: string }).type === "agentManager.setSessionModel") {
const ev = msg as { type: string; sessionId: string; providerID: string; modelID: string }
session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID)
}
// Handle initial message send for multi-version sessions.
// The extension creates the worktrees/sessions, then asks the webview
// to send the prompt through the normal KiloProvider sendMessage path.
// Once the message is sent, clear the loading state for that worktree.
if ((msg as { type: string }).type === "agentManager.sendInitialMessage") {
const ev = msg as unknown as AgentManagerSendInitialMessage
@@ -1671,6 +1657,17 @@ const AgentManagerContent: Component = () => {
// The composite id (ctx#scope) the extension keys diff data by.
const diffScopeId = review.id
const reviewComments = createMemo(() => {
const key = diffScopeId()
if (!key) return [] as ReviewComment[]
return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key)
})
const setReviewCommentsForSelection = (comments: ReviewComment[]) => {
const key = diffScopeId()
if (!key) return
setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", key, comments))
}
const diffScopeControls = (compact: boolean) => (
<DiffScopeControls
descriptors={review.descriptors()}
@@ -1736,24 +1733,19 @@ const AgentManagerContent: Component = () => {
tabFocus.restore()
}
// Data for the review tab / side panel: keyed by the composite diff id
// (ctx#scope) the extension pushes, so each scope keeps its own file set and
// switching back to a fetched scope is instant.
const reviewDiffs = createMemo(() => {
const data = diffDatas()
const key = diffScopeId()
if (!key) return []
return data[key] ?? []
return data[diffDataKey(activeProjectId(), key)] ?? []
})
const diffSessionKey = createMemo(() => diffScopeId() ?? "")
// Source-level notice for the active composite id (e.g. snapshots disabled
// for the Session scope), shown as a banner instead of the empty state.
const diffNotice = createMemo(() => {
const key = diffScopeId()
if (!key) return undefined
return diffNotices()[key]
return diffNotices()[diffDataKey(activeProjectId(), key)]
})
const requestDiffFile = (file: string) => {
@@ -1763,9 +1755,12 @@ const AgentManagerContent: Component = () => {
}
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId))
const diffLoadingForCurrent = createMemo(() => diffs.diffLoadingFor(diffScopeId))
const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId)
createEffect(() => diffOpen() && setDiffMounted(true))
const handleShowKeyboardShortcuts = () => {
const categories = buildShortcutCategories(kb(), t)
dialog.show(() => (
@@ -2403,9 +2398,6 @@ const AgentManagerContent: Component = () => {
</div>
<div class="am-detail">
{/* Tab bar — full version with tabs renders when a section is selected
and has tabs; otherwise a minimal version still renders so the
sidebar toggle button stays at a fixed position. */}
<TabBar
t={t}
bindings={kb}
@@ -2508,23 +2500,16 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<Show when={showDetailStack()}>
{/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
<div class={`am-detail-stack ${history() ? "am-detail-stack-hidden" : ""}`} inert={history()}>
{/* Chat/terminal + side diff panel. Keep it mounted under the
review tab so live xterm canvases never leave the paint tree. */}
<div
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""} ${reviewActive() ? "am-detail-content-hidden" : ""}`}
>
<div class={`am-main-pane ${terms.activeId() ? "am-main-pane-terminal-active" : ""}`}>
{/* Keep terminal tabs mounted so output streams across worktree switches. */}
{renderTerminalLayer({
state: terms,
onFocusPrompt: focusCtl.focus,
onFocusChange: focusCtl.report,
})}
{/* Session-less context (e.g. a worktree mid-provisioning): the
empty state lives in the main pane so the side terminal
panel can render next to it. */}
<Show when={contextEmpty()}>
<div class="am-empty-state">
<Show
@@ -2619,11 +2604,9 @@ const AgentManagerContent: Component = () => {
</Show>
</div>
</div>
{/* One inspector host for all right-side modes. It stays
mounted while a side terminal is alive — hidden via
.am-side-host-hidden (absolute + opacity), never
unmounted, so xterm render loops keep streaming. */}
<Show when={sidePanel() !== null || terms.sides().length > 0 || subagents.tabs().length > 0}>
<Show
when={sidePanel() !== null || diffMounted() || terms.sides().length > 0 || subagents.tabs().length > 0}
>
<div
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
style={{ width: `${panelWidth()}px` }}
@@ -2640,42 +2623,54 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<div class="am-diff-panel-wrapper">
<Show when={sidePanel() === SidePanel.Diff}>
<DiffPanel
diffs={reviewDiffs()}
loading={diffLoading()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={activeDiffSession()}
sessionKey={diffSessionKey()}
notice={diffNotice()}
lead={diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onSendClick={() => metrics.track("send_review_comments", "side_review")}
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
onExpand={
selection() !== null
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
: undefined
}
onRequestDiff={requestDiffFile}
onOpenFile={(file, line) => {
const id = diffCtx()
if (id)
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
}}
onOpenDocument={documentInspector.open}
onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
/>
</Show>
<DiffPanelCache
current={diffScopeId}
context={diffCtx}
project={activeProjectId}
active={() => diffOpen() && !history() && !reviewActive()}
data={diffDatas}
loading={(key) => diffs.diffLoadingFor(() => key)}
loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)}
notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]}
comments={(key) =>
readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key)
}
setComments={(key, comments) =>
setReviewCommentsByContext((prev) =>
setReviewComments(prev, currentProjectId() ?? "single", key, comments),
)
}
composer={composers.get}
lead={() => diffScopeControls(true)}
canRevert={scopeCapabilities(review.scope()).revert}
diffStyle={diffStyle.style()}
onDiffStyleChange={setSharedDiffStyle}
markdownRender={markdown.render()}
onMarkdownRenderChange={markdown.update}
onSendClick={() => metrics.track("send_review_comments", "side_review")}
onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))}
onExpand={
selection() !== null
? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" })
: undefined
}
onRequestDiff={diffs.requestDiffFile}
onOpenFile={(ctx, file, line) =>
vscode.postMessage({ type: "agentManager.openFile", sessionId: ctx, filePath: file, line })
}
onOpenDocument={documentInspector.open}
onRevertFile={(key, ctx, file) => {
metrics.track("revert_file", "side_review")
revertCtl.revertFor(key, ctx, review.scope(), file)
}}
revertingFiles={revertCtl.revertingFor}
activeTerminalId={terms.activeId()}
contexts={() => new Set(worktrees().map((wt) => wt.id))}
onEvict={(key) => {
composers.drop(key)
diffs.drop(key)
}}
/>
<Show when={sidePanel() === SidePanel.PR && activePR()}>
<PRPanelHost
pr={activePR()!.pr}
@@ -2746,7 +2741,7 @@ const AgentManagerContent: Component = () => {
<div class="am-review-host" style={{ display: reviewActive() && !terms.activeId() ? undefined : "none" }}>
<FullScreenDiffView
diffs={reviewDiffs()}
loading={diffLoading()}
loading={diffLoadingForCurrent()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={activeDiffSession()}
sessionKey={diffSessionKey()}
@@ -2756,7 +2751,7 @@ const AgentManagerContent: Component = () => {
canComment={scopeCapabilities(review.scope()).comments}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
composer={composers.get(`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`)}
onSendAll={closeReviewTab}
onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")}
diffStyle={diffStyle.style()}
@@ -1,4 +1,13 @@
import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js"
import {
type Component,
createSignal,
createMemo,
Show,
createEffect,
createRenderEffect,
on,
type JSXElement,
} from "solid-js"
import type { VirtualizerHandle } from "virtua/solid"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Accordion } from "@kilocode/kilo-ui/accordion"
@@ -8,7 +17,6 @@ import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
@@ -74,6 +82,7 @@ const DIFF_NOTICE_KEYS: Record<string, string> = {
interface DiffPanelProps {
diffs: WorktreeFileDiff[]
loading: boolean
active?: boolean
loadingFiles?: Set<string>
sessionId?: string
sessionKey?: string
@@ -159,6 +168,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
},
),
)
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
@@ -197,6 +207,20 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
// so pierre's annotation cache doesn't invalidate and destroy the textarea.
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
createRenderEffect(
on(
() => props.active,
(active) => {
if (!active) return
const value = reviewComposerDraft(composer())
const edit = reviewComposerEdit(composer())
setDraft(value)
setEditing(edit)
draftMeta = composer().draft
editMeta = composer().edit
},
),
)
// Ref to the scrollable container — used to preserve scroll position when
// annotation changes cause pierre to fully re-render diffs
@@ -249,6 +273,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
on(
() => props.sessionKey,
() => {
if (props.active === false) return
setDraft(null)
draftMeta = null
setEditing(null)
@@ -264,7 +289,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
diffs: () => props.diffs,
open,
loading: () => props.loadingFiles,
send: () => props.onRequestDiff,
send: () => (props.active === false ? undefined : props.onRequestDiff),
})
// --- CRUD ---
@@ -327,6 +352,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
on(
() => [props.diffs, comments()] as const,
([diffs, current]) => {
if (props.active === false) return
const valid = sanitizeReviewComments(current, diffs)
if (valid.length !== current.length) {
setComments(valid)
@@ -392,8 +418,10 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
draftMeta = result.draftMeta
editMeta = result.editMeta
composer().draft = draft() ? draftMeta : null
composer().edit = editing() ? editMeta : null
if (props.active !== false) {
composer().draft = draft() ? draftMeta : null
composer().edit = editing() ? editMeta : null
}
return result.annotations
}
@@ -553,7 +581,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
<Show when={props.loading && props.diffs.length === 0}>
<div class="am-diff-loading">
<Spinner />
<span>{t("session.review.loadingChanges")}</span>
</div>
</Show>
@@ -714,10 +741,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
fallback={
<div class="am-diff-summary-state">
<Show when={isLoadingDetail()} fallback={<span>Diff preview loads on demand.</span>}>
<>
<Spinner />
<span>Loading diff...</span>
</>
<span>Loading diff...</span>
</Show>
</div>
}
@@ -0,0 +1,129 @@
import { For, createEffect, createMemo, createSignal, type Accessor, type Component, type JSX } from "solid-js"
import type { WorktreeFileDiff } from "../src/types/messages"
import type { ReviewComment } from "../diff-viewer/review-comments"
import type { ReviewComposer } from "../diff-viewer/review-annotations"
import { DiffPanel } from "./DiffPanel"
import { diffDataKey } from "./worktree-diffs"
const CACHE_SIZE = 4
interface Entry {
key: string
cacheKey: string
ctx: string
used: number
}
interface Props {
current: Accessor<string | undefined>
context: Accessor<string | undefined>
project: Accessor<string | undefined>
active: Accessor<boolean>
onEvict?: (key: string) => void
contexts: Accessor<Set<string>>
data: Accessor<Record<string, WorktreeFileDiff[]>>
loading: (key: string) => boolean
loadingFiles: (key: string) => Set<string>
notice: (key: string) => string | undefined
comments: (ctx: string) => ReviewComment[]
setComments: (ctx: string, comments: ReviewComment[]) => void
composer: (key: string) => ReviewComposer
lead: () => JSX.Element
canRevert: boolean
diffStyle: "unified" | "split"
onDiffStyleChange: (style: "unified" | "split") => void
markdownRender: boolean
onMarkdownRenderChange: (render: boolean) => void
onSendClick: () => void
onClose: () => void
onExpand?: () => void
onRequestDiff: (key: string, file: string) => void
onOpenFile: (ctx: string, file: string, line?: number) => void
onOpenDocument: (file: string) => void
onRevertFile: (key: string, ctx: string, file: string) => void
revertingFiles: (key: string) => Set<string>
activeTerminalId?: string
}
export const DiffPanelCache: Component<Props> = (props) => {
const [entries, setEntries] = createSignal<Entry[]>([])
let used = 0
createEffect(() => {
const contexts = props.contexts()
setEntries((prev) => {
const next = prev.filter((entry) => entry.ctx === "local" || contexts.has(entry.ctx))
for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey)
return next
})
})
createEffect(() => {
if (!props.active()) return
const key = props.current()
const ctx = props.context()
const project = props.project() ?? "single"
if (!key || !ctx) return
const cacheKey = `${project}\0${key}`
setEntries((prev) => {
const prefix = `${project}\0`
const scoped = prev.filter((item) => item.cacheKey.startsWith(prefix))
const current = scoped.find((item) => item.cacheKey === cacheKey)
if (current) {
current.used = ++used
for (const item of prev) if (!scoped.includes(item)) props.onEvict?.(item.cacheKey)
return scoped
}
const next = [...scoped, { key, cacheKey, ctx, used: ++used }]
if (next.length <= CACHE_SIZE) {
for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey)
return next
}
const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry))
const result = next.filter((item) => item !== oldest)
props.onEvict?.(oldest.cacheKey)
for (const item of prev) if (!result.includes(item)) props.onEvict?.(item.cacheKey)
return result
})
})
return (
<For each={entries()}>
{(entry) => {
const active = createMemo(
() => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey,
)
return (
<div class="am-diff-panel-cache" classList={{ "am-diff-panel-cache-active": active() }} inert={!active()}>
<DiffPanel
diffs={props.data()[diffDataKey(props.project(), entry.key)] ?? []}
loading={props.loading(entry.key)}
active={active()}
loadingFiles={props.loadingFiles(entry.key)}
sessionKey={entry.key}
notice={props.notice(entry.key)}
lead={active() ? props.lead() : undefined}
canRevert={props.canRevert}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
markdownRender={props.markdownRender}
onMarkdownRenderChange={props.onMarkdownRenderChange}
comments={props.comments(entry.key)}
onCommentsChange={(comments) => props.setComments(entry.key, comments)}
composer={props.composer(entry.cacheKey)}
onSendClick={props.onSendClick}
onClose={props.onClose}
onExpand={props.onExpand}
onRequestDiff={(file) => props.onRequestDiff(entry.key, file)}
onOpenFile={(file, line) => props.onOpenFile(entry.ctx, file, line)}
onOpenDocument={props.onOpenDocument}
onRevertFile={(file) => props.onRevertFile(entry.key, entry.ctx, file)}
revertingFiles={props.revertingFiles(entry.key)}
activeTerminalId={props.activeTerminalId}
/>
</div>
)
}}
</For>
)
}
@@ -1851,6 +1851,24 @@ body.am-wt-dragging-active * {
overflow: hidden;
}
.am-diff-panel-cache {
position: absolute;
inset: 0;
display: flex;
min-width: 0;
min-height: 0;
opacity: 0;
pointer-events: none;
z-index: 0;
background: var(--surface-base);
}
.am-diff-panel-cache-active {
opacity: 1;
pointer-events: auto;
z-index: 2;
}
.am-diff-panel-wrapper > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
@@ -15,6 +15,7 @@ import { showToast } from "@kilocode/kilo-ui/toast"
import { groupApplyConflicts } from "./apply-conflicts"
import { ApplyDialog } from "./ApplyDialog"
import { composeDiffId } from "./diff-scope-state"
import { diffDataKey } from "./worktree-diffs"
import type { tracker } from "./telemetry"
import type { useDialog } from "@kilocode/kilo-ui/context/dialog"
import type { useLanguage } from "../src/context/language"
@@ -74,7 +75,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) {
const applyDiffs = createMemo(() => {
const key = applyDiffKey()
if (!key) return [] as WorktreeFileDiff[]
return diffDatas()[key] ?? ([] as WorktreeFileDiff[])
return diffDatas()[diffDataKey(opts.projectId?.(), key)] ?? ([] as WorktreeFileDiff[])
})
const applyStateForTarget = createMemo(() => {
@@ -50,8 +50,9 @@ export function pruneReviewState<T>(
): Record<string, T> {
return Object.fromEntries(
Object.entries(values).filter(([key]) => {
const [owner, context] = key.split(":")
return owner !== project || context === "local" || contexts.has(context)
const [owner, value] = key.split(":")
const context = value?.split("#", 1)[0]
return owner !== project || context === "local" || (context !== undefined && contexts.has(context))
}),
)
}
@@ -21,38 +21,45 @@ export function createRevertFile(
projectId?: Accessor<string | undefined>,
) {
const [files, setFiles] = createSignal<Record<string, Set<string>>>({})
const key = (project: string | undefined, scope: string) => `${project ?? "single"}\0${scope}`
const reverting = createMemo(() => {
const id = diffScopeId()
if (!id) return new Set<string>()
return files()[id] ?? new Set<string>()
return files()[key(projectId?.(), id)] ?? new Set<string>()
})
function revert(file: string) {
const id = diffScopeId()
const context = ctx()
const revertingFor = (id: string) => files()[key(projectId?.(), id)] ?? new Set<string>()
function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) {
if (!id || !context) return
const data = key(projectId?.(), id)
setFiles((prev) => {
const set = new Set(prev[id] ?? [])
const set = new Set(prev[data] ?? [])
set.add(file)
return { ...prev, [id]: set }
return { ...prev, [data]: set }
})
vscode.postMessage({
type: "agentManager.revertWorktreeFile",
projectId: projectId?.(),
sessionId: context,
file,
scope: scope(),
scope: source,
})
}
function revert(file: string) {
revertFor(diffScopeId(), ctx(), scope(), file)
}
function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) {
const data = key(ev.projectId, ev.sessionId)
setFiles((prev) => {
const set = new Set(prev[ev.sessionId] ?? [])
const set = new Set(prev[data] ?? [])
set.delete(ev.file)
const next = { ...prev }
if (set.size === 0) delete next[ev.sessionId]
else next[ev.sessionId] = set
if (set.size === 0) delete next[data]
else next[data] = set
return next
})
if (ev.status === "success") {
@@ -62,5 +69,5 @@ export function createRevertFile(
}
}
return { reverting, revert, onResult }
return { reverting, revertingFor, revert, revertFor, onResult }
}
@@ -0,0 +1,42 @@
import type { Accessor } from "solid-js"
import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations"
export function createReviewComposers(project: Accessor<string | undefined>) {
const values = new Map<string, ReviewComposer>()
const get = (key: string) => {
const current = values.get(key)
if (current) return current
const next = createReviewComposer()
values.set(key, next)
return next
}
const clear = (ctx: string | null) => {
if (!ctx) return
const prefix = `${project() ?? "single"}\0${ctx}`
for (const key of values.keys()) {
if (key === prefix || key.startsWith(`${prefix}#`)) values.delete(key)
}
}
const drop = (key: string) => values.delete(key)
const clearProject = (id: string) => {
const prefix = `${id}\0`
for (const key of values.keys()) {
if (key.startsWith(prefix)) values.delete(key)
}
}
const prune = (contexts: Set<string>) => {
const prefix = `${project() ?? "single"}\0`
for (const key of values.keys()) {
if (!key.startsWith(prefix)) continue
const ctx = key.slice(prefix.length).split("#", 1)[0]
if (ctx !== "local" && !contexts.has(ctx)) values.delete(key)
}
}
return { get, clear, drop, clearProject, prune }
}
@@ -29,15 +29,43 @@ export function wireDiffId(id: string) {
return { sessionId: ctx, scope, diffSessionId: sessionId }
}
export function diffDataKey(project: string | undefined, id: string): string {
return `${project ?? "single"}\0${id}`
}
export function createWorktreeDiffs(
vscode: ReturnType<typeof useVSCode>,
project: () => string | undefined = () => undefined,
) {
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
const [diffLoading, setDiffLoading] = createSignal(false)
const [diffLoadings, setDiffLoadings] = createSignal<Record<string, true>>({})
const diffLoading = () => Object.keys(diffLoadings()).length > 0
const [diffNotices, setDiffNotices] = createSignal<Record<string, string | undefined>>({})
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
const key = (id: string) => diffDataKey(project(), id)
const reset = () => {
setDiffDatas({})
setDiffLoadings({})
setDiffNotices({})
setDiffFileLoading({})
}
const drop = (id: string) => {
const data = id.includes("\0") ? id : key(id)
const remove = <T extends Record<string, unknown>>(prev: T): T => {
if (!(data in prev)) return prev
const next = { ...prev }
delete next[data]
return next
}
setDiffDatas(remove)
setDiffLoadings(remove)
setDiffNotices(remove)
setDiffFileLoading(remove)
}
const setDiffFilePending = (sessionId: string, file: string, value: boolean) => {
setDiffFileLoading((prev) => {
const session = prev[sessionId] ?? {}
@@ -66,20 +94,21 @@ export function createWorktreeDiffs(
/** Lazily load a single file's full diff for the given composite diff id. */
const requestDiffFile = (id: string, file: string) => {
if (diffFileLoading()[id]?.[file]) return
setDiffFilePending(id, file, true)
const data = key(id)
if (diffFileLoading()[data]?.[file]) return
setDiffFilePending(data, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) })
}
/** Files the backend flagged as stale in a merged update need a fresh fetch. */
const refreshStaleDiffs = (id: string, files: Set<string>) => {
const loading = diffFileLoading()[id] ?? {}
const refreshStaleDiffs = (id: string, files: Set<string>, data = key(id), owner = project()) => {
const loading = diffFileLoading()[data] ?? {}
for (const file of files) {
if (loading[file]) continue
setDiffFilePending(id, file, true)
setDiffFilePending(data, file, true)
vscode.postMessage({
type: "agentManager.requestWorktreeDiffFile",
projectId: project(),
projectId: owner,
file,
...wireDiffId(id),
})
@@ -90,53 +119,79 @@ export function createWorktreeDiffs(
const diffFileLoadingFor = (sessionId: Accessor<string | undefined>) => {
const id = sessionId()
if (!id) return new Set<string>()
return new Set(Object.keys(diffFileLoading()[id] ?? {}))
return new Set(Object.keys(diffFileLoading()[key(id)] ?? {}))
}
/** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */
const diffLoadingFor = (sessionId: Accessor<string | undefined>) => {
const id = sessionId()
if (!id) return false
const data = key(id)
return diffLoadings()[data] === true && !(data in diffDatas())
}
// Backend messages.
const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => {
const data = diffDataKey(ev.projectId, ev.sessionId)
let staleFiles: Set<string> | undefined
setDiffDatas((prev) => {
const existing = prev[ev.sessionId]
const existing = prev[data]
const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set<string>() }
staleFiles = merged.stale
const next = merged.diffs
if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev
return { ...prev, [ev.sessionId]: next }
return { ...prev, [data]: next }
})
if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles)
if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles, data, ev.projectId)
}
const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => {
const data = diffDataKey(ev.projectId, ev.sessionId)
if (ev.diff) {
setDiffDatas((prev) => {
const existing = prev[ev.sessionId] ?? []
const existing = prev[data] ?? []
const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item))
return { ...prev, [ev.sessionId]: next }
return { ...prev, [data]: next }
})
setDiffFilePending(ev.sessionId, ev.diff.file, false)
setDiffFilePending(data, ev.diff.file, false)
return
}
setDiffFilePending(ev.sessionId, ev.file, false)
setDiffFilePending(data, ev.file, false)
}
const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => {
setDiffLoading(ev.loading)
const data = diffDataKey(ev.projectId, ev.sessionId)
// One source is active per project. Replacing the map on start also clears
// an interrupted source whose stale completion is intentionally discarded.
if (ev.loading) {
setDiffLoadings({ [data]: true })
return
}
setDiffLoadings((prev) => {
if (!prev[data]) return prev
const next = { ...prev }
delete next[data]
return next
})
}
const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => {
setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice }))
setDiffNotices((prev) => ({ ...prev, [diffDataKey(ev.projectId, ev.sessionId)]: ev.notice }))
}
return {
diffDatas,
diffLoading,
setDiffLoading,
setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}),
diffNotices,
requestDiffFile,
refreshStaleDiffs,
diffFileLoadingFor,
diffLoadingFor,
diffDataKey,
drop,
reset,
onWorktreeDiff,
onWorktreeDiffFile,
onWorktreeDiffLoading,
@@ -13,6 +13,7 @@ interface DiffRequestOptions {
export function createDiffRequests(opts: DiffRequestOptions) {
const requested = new Map<string, string>()
let active = false
createEffect(
on(
@@ -36,8 +37,17 @@ export function createDiffRequests(opts: DiffRequestOptions) {
createEffect(
on(
() => [opts.open(), opts.diffs(), opts.loading()] as const,
() => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const,
([open, diffs]) => {
if (!opts.send()) {
requested.clear()
active = false
return
}
if (!active) {
requested.clear()
active = true
}
const files = new Set(open)
for (const file of requested.keys()) {
if (!files.has(file)) requested.delete(file)