mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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.
|
||||
@@ -212,7 +212,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),
|
||||
|
||||
@@ -166,16 +166,16 @@ 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,
|
||||
)
|
||||
const [nameStatus, counts, untracked] = await Promise.all([
|
||||
git.execGit(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], dir),
|
||||
numstat(git, dir, anc),
|
||||
git.execGit(["ls-files", "--others", "--exclude-standard"], dir),
|
||||
])
|
||||
if (nameStatus.code !== 0) {
|
||||
log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() })
|
||||
return []
|
||||
}
|
||||
|
||||
const counts = await numstat(git, dir, anc)
|
||||
const result: Meta[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -201,7 +201,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 +264,74 @@ 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 }>()
|
||||
const pending = new Map<string, { signal?: AbortSignal; work: Promise<WorktreeDiffEntry> }>()
|
||||
let bytes = 0
|
||||
|
||||
const remember = (id: string, value: WorktreeDiffEntry) => {
|
||||
const size =
|
||||
(value.before?.length ?? 0) +
|
||||
(value.after?.length ?? 0) +
|
||||
(value.patch?.length ?? 0) +
|
||||
(value.image?.before?.data?.length ?? 0) +
|
||||
(value.image?.after?.data?.length ?? 0)
|
||||
const current = details.get(id)
|
||||
if (current) bytes -= current.bytes
|
||||
details.delete(id)
|
||||
details.set(id, { value, bytes: size })
|
||||
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.stamp}`
|
||||
const cached = details.get(id)
|
||||
if (cached) {
|
||||
remember(id, cached.value)
|
||||
return cached.value
|
||||
}
|
||||
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)
|
||||
remember(id, value)
|
||||
},
|
||||
() => {
|
||||
if (pending.get(id)?.work === work) pending.delete(id)
|
||||
},
|
||||
)
|
||||
return work
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -343,8 +388,8 @@ 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)
|
||||
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) return 0
|
||||
return parseInt(result.stdout.trim(), 10) || 0
|
||||
}
|
||||
@@ -356,8 +401,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,9 +420,16 @@ 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)
|
||||
const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal })
|
||||
return result.code === 0 ? result.stdout : ""
|
||||
}
|
||||
|
||||
@@ -386,10 +444,17 @@ async function readAfter(dir: string, file: string, status: Status): Promise<str
|
||||
return fs.readFile(full, "utf-8").catch(() => "")
|
||||
}
|
||||
|
||||
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 : ""
|
||||
}
|
||||
@@ -418,15 +483,26 @@ 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 Promise.all([
|
||||
meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal),
|
||||
meta.status === "deleted" ? 0 : fileSize(dir, meta.file),
|
||||
])
|
||||
if (mime) {
|
||||
const image = await loadImage(
|
||||
meta.file,
|
||||
meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) },
|
||||
meta.status === "added"
|
||||
? undefined
|
||||
: { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) },
|
||||
meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) },
|
||||
)
|
||||
return { ...summarize(meta), summarized: false, image }
|
||||
@@ -444,9 +520,12 @@ 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 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(""),
|
||||
])
|
||||
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,33 @@ 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: () => {
|
||||
if (item.abort) signal?.removeEventListener("abort", item.abort)
|
||||
this.running++
|
||||
resolve()
|
||||
},
|
||||
abort: undefined as (() => void) | undefined,
|
||||
}
|
||||
item.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,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 +246,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.runFetch(source, epoch, false).finally(() => {
|
||||
busy = false
|
||||
})
|
||||
if (!keep && this.epoch === epoch && this.active === source) this.stopPolling()
|
||||
}, DIFF_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -352,6 +352,36 @@ 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("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 = {
|
||||
|
||||
@@ -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 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"
|
||||
@@ -309,6 +309,7 @@ const AgentManagerContent: Component = () => {
|
||||
let pendingSidebarWidth: number | undefined
|
||||
const [history, setHistory] = createSignal(false)
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
|
||||
const [diffMounted, setDiffMounted] = createSignal(false)
|
||||
const diffOpen = () => sidePanel() === SidePanel.Diff
|
||||
const prOpen = () => sidePanel() === SidePanel.PR
|
||||
const activePR = createMemo(() => {
|
||||
@@ -319,6 +320,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
|
||||
@@ -330,7 +332,7 @@ const AgentManagerContent: Component = () => {
|
||||
setReviewActive(false)
|
||||
setSidePanel(SidePanel.Terminal)
|
||||
}
|
||||
const reviewComposer = createReviewComposer()
|
||||
const composers = createReviewComposers(currentProjectId)
|
||||
const reviewState = createReviewState()
|
||||
const reviewOpenByContext = reviewState.open
|
||||
const setReviewOpenByContext = reviewState.setOpen
|
||||
@@ -524,7 +526,6 @@ const AgentManagerContent: Component = () => {
|
||||
setPendingDelete(null)
|
||||
}
|
||||
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
|
||||
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
|
||||
createEffect(
|
||||
on(
|
||||
selection,
|
||||
@@ -1752,9 +1753,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(() => (
|
||||
@@ -2603,7 +2607,9 @@ const AgentManagerContent: Component = () => {
|
||||
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` }}
|
||||
@@ -2620,42 +2626,49 @@ 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()[key]}
|
||||
comments={(ctx) =>
|
||||
readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx)
|
||||
}
|
||||
setComments={(ctx, comments) =>
|
||||
setReviewCommentsByContext((prev) =>
|
||||
setReviewComments(prev, currentProjectId() ?? "single", ctx, 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()}
|
||||
/>
|
||||
<Show when={sidePanel() === SidePanel.PR && activePR()}>
|
||||
<PRPanelHost
|
||||
pr={activePR()!.pr}
|
||||
@@ -2726,7 +2739,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()}
|
||||
@@ -2736,7 +2749,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()}
|
||||
|
||||
@@ -8,7 +8,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 +73,7 @@ const DIFF_NOTICE_KEYS: Record<string, string> = {
|
||||
interface DiffPanelProps {
|
||||
diffs: WorktreeFileDiff[]
|
||||
loading: boolean
|
||||
active?: boolean
|
||||
loadingFiles?: Set<string>
|
||||
sessionId?: string
|
||||
sessionKey?: string
|
||||
@@ -264,7 +264,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 +327,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)
|
||||
@@ -553,7 +554,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 +714,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,110 @@
|
||||
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"
|
||||
|
||||
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>
|
||||
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(() => {
|
||||
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
|
||||
return scoped
|
||||
}
|
||||
const next = [...scoped, { key, cacheKey, ctx, used: ++used }]
|
||||
if (next.length <= CACHE_SIZE) return next
|
||||
const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry))
|
||||
return next.filter((item) => item !== oldest)
|
||||
})
|
||||
})
|
||||
|
||||
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()[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.ctx)}
|
||||
onCommentsChange={(comments) => props.setComments(entry.ctx, 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>
|
||||
)
|
||||
}
|
||||
@@ -1939,6 +1939,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);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ export function createRevertFile(
|
||||
return files()[id] ?? new Set<string>()
|
||||
})
|
||||
|
||||
function revert(file: string) {
|
||||
const id = diffScopeId()
|
||||
const context = ctx()
|
||||
const revertingFor = (id: string) => files()[id] ?? new Set<string>()
|
||||
|
||||
function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) {
|
||||
if (!id || !context) return
|
||||
setFiles((prev) => {
|
||||
const set = new Set(prev[id] ?? [])
|
||||
@@ -42,10 +42,14 @@ export function createRevertFile(
|
||||
projectId: projectId?.(),
|
||||
sessionId: context,
|
||||
file,
|
||||
scope: scope(),
|
||||
scope: source,
|
||||
})
|
||||
}
|
||||
|
||||
function revert(file: string) {
|
||||
revertFor(diffScopeId(), ctx(), scope(), file)
|
||||
}
|
||||
|
||||
function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) {
|
||||
setFiles((prev) => {
|
||||
const set = new Set(prev[ev.sessionId] ?? [])
|
||||
@@ -62,5 +66,5 @@ export function createRevertFile(
|
||||
}
|
||||
}
|
||||
|
||||
return { reverting, revert, onResult }
|
||||
return { reverting, revertingFor, revert, revertFor, onResult }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return { get, clear }
|
||||
}
|
||||
@@ -34,10 +34,18 @@ export function createWorktreeDiffs(
|
||||
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 reset = () => {
|
||||
setDiffDatas({})
|
||||
setDiffLoadings({})
|
||||
setDiffNotices({})
|
||||
setDiffFileLoading({})
|
||||
}
|
||||
|
||||
const setDiffFilePending = (sessionId: string, file: string, value: boolean) => {
|
||||
setDiffFileLoading((prev) => {
|
||||
const session = prev[sessionId] ?? {}
|
||||
@@ -93,6 +101,13 @@ export function createWorktreeDiffs(
|
||||
return new Set(Object.keys(diffFileLoading()[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
|
||||
return diffLoadings()[id] === true && !(id in diffDatas())
|
||||
}
|
||||
|
||||
// Backend messages.
|
||||
|
||||
const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => {
|
||||
@@ -122,7 +137,18 @@ export function createWorktreeDiffs(
|
||||
}
|
||||
|
||||
const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => {
|
||||
setDiffLoading(ev.loading)
|
||||
// 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({ [ev.sessionId]: true })
|
||||
return
|
||||
}
|
||||
setDiffLoadings((prev) => {
|
||||
if (!prev[ev.sessionId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[ev.sessionId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => {
|
||||
@@ -132,11 +158,13 @@ export function createWorktreeDiffs(
|
||||
return {
|
||||
diffDatas,
|
||||
diffLoading,
|
||||
setDiffLoading,
|
||||
setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}),
|
||||
diffNotices,
|
||||
requestDiffFile,
|
||||
refreshStaleDiffs,
|
||||
diffFileLoadingFor,
|
||||
diffLoadingFor,
|
||||
reset,
|
||||
onWorktreeDiff,
|
||||
onWorktreeDiffFile,
|
||||
onWorktreeDiffLoading,
|
||||
|
||||
@@ -36,7 +36,7 @@ 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]) => {
|
||||
const files = new Set(open)
|
||||
for (const file of requested.keys()) {
|
||||
|
||||
Reference in New Issue
Block a user