mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13411 from Kilo-Org/optimize-worktree-diff-loading
perf(agent-manager): optimize worktree diff loading
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user