fix(agent-manager): close diff cache review gaps

This commit is contained in:
marius-kilocode
2026-08-25 13:25:36 +02:00
parent ea74bda399
commit 6a03357046
11 changed files with 217 additions and 76 deletions
@@ -1,4 +1,5 @@
import * as fs from "fs/promises"
import { createHash } from "crypto"
import { binaryFile } from "../diff/shared/binary"
import { imageMime, loadImage, readImageFile } from "../diff/shared/image"
import { resolveInside } from "../diff/shared/path"
@@ -144,7 +145,46 @@ 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 contentStamp(dir: string, file: string, status: Status): Promise<string> {
if (status === "deleted") return "deleted"
const full = resolveInside(dir, file)
if (!full) return `missing:${file}`
const stat = await fs.lstat(full).catch(() => undefined)
if (!stat) return `missing:${file}`
const value = stat.isSymbolicLink()
? Buffer.from(await fs.readlink(full))
: stat.isFile()
? await fs.readFile(full).catch(() => undefined)
: undefined
if (!value) return `unreadable:${file}`
return createHash("sha256").update(value).digest("hex")
}
function detailStamp(value: WorktreeDiffEntry, meta: Meta): string {
if (meta.status === "deleted") return "deleted"
const data = value.image?.after?.data
if (data) return createHash("sha256").update(Buffer.from(data, "base64")).digest("hex")
return createHash("sha256")
.update(value.after ?? "")
.digest("hex")
}
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> {
@@ -265,21 +305,26 @@ 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 details = new Map<string, { value: WorktreeDiffEntry; bytes: number; stamp: string }>()
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 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 })
details.set(id, { value, bytes: size, stamp })
bytes += size
while (details.size > 128 || bytes > 64 * 1024 * 1024) {
const key = details.keys().next().value!
@@ -311,11 +356,14 @@ export function createLocalDiff(git: GitOps, log?: Log) {
if (!state) return diffFile(git, dir, base, file, log)
const meta = state.metas.get(file)
if (!meta) return null
const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.stamp}`
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) {
remember(id, cached.value)
return cached.value
if (cached.stamp === (await contentStamp(dir, file, meta.status))) {
remember(id, cached.value, cached.stamp)
return cached.value
}
forget(id)
}
const current = pending.get(id)
if (current && !current.signal?.aborted) return current.work
@@ -325,7 +373,8 @@ export function createLocalDiff(git: GitOps, log?: Log) {
(value) => {
if (pending.get(id)?.work !== work) return
pending.delete(id)
remember(id, value)
if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return
remember(id, value, detailStamp(value, meta))
},
() => {
if (pending.get(id)?.work === work) pending.delete(id)
@@ -390,7 +439,7 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string):
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
if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`)
return parseInt(result.stdout.trim(), 10) || 0
}
@@ -430,18 +479,21 @@ async function readBefore(
): Promise<string> {
if (status === "added") return ""
const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal })
return result.code === 0 ? result.stdout : ""
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(
@@ -456,7 +508,8 @@ async function unifiedPatch(
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 {
@@ -493,10 +546,8 @@ async function materialize(
): Promise<WorktreeDiffEntry> {
const mime = imageMime(meta.file)
if (meta.binary && !mime) return summarize(meta)
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),
])
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,
@@ -505,6 +556,7 @@ async function materialize(
: { 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
@@ -520,11 +572,8 @@ async function materialize(
return summarize(meta)
}
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 [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 {
@@ -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, false)
}
}
@@ -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)
}
/**
@@ -251,7 +253,7 @@ export class SourceController {
if (busy) return
busy = true
// Self-cancel when the tick reports the source is done
const keep = await this.runFetch(source, epoch, false).finally(() => {
const keep = await this.fetch(source, epoch, false).finally(() => {
busy = false
})
if (!keep && this.epoch === epoch && this.active === source) this.stopPolling()
@@ -264,4 +266,20 @@ export class SourceController {
this.interval = undefined
}
}
private fetch(source: DiffSource, epoch: number, initial: boolean): Promise<boolean> {
const current = this.fetches.get(source)
if (current) return current
const work = this.runFetch(source, epoch, initial)
this.fetches.set(source, work)
work.then(
() => {
if (this.fetches.get(source) === work) this.fetches.delete(source)
},
() => {
if (this.fetches.get(source) === work) this.fetches.delete(source)
},
)
return work
}
}
@@ -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)
})
})
@@ -167,7 +167,7 @@ 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 { createReviewComposers } from "./review-composers"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
@@ -333,6 +333,7 @@ const AgentManagerContent: Component = () => {
setSidePanel(SidePanel.Terminal)
}
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
@@ -549,16 +550,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,
@@ -719,6 +710,8 @@ const AgentManagerContent: Component = () => {
})
createEffect(() => {
const ids = new Set(worktrees().map((wt) => wt.id))
composers.prune(ids)
composers.prune(ids)
setReviewOpenByContext((prev) => {
const next = pruneReviewState(prev, currentProjectId() ?? "single", ids)
if (Object.keys(next).length === Object.keys(prev).length) return prev
@@ -1661,6 +1654,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()}
@@ -1726,20 +1730,15 @@ 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
@@ -2634,13 +2633,13 @@ const AgentManagerContent: Component = () => {
data={diffDatas}
loading={(key) => diffs.diffLoadingFor(() => key)}
loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)}
notice={(key) => diffNotices()[key]}
comments={(ctx) =>
readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", ctx)
notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]}
comments={(key) =>
readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key)
}
setComments={(ctx, comments) =>
setComments={(key, comments) =>
setReviewCommentsByContext((prev) =>
setReviewComments(prev, currentProjectId() ?? "single", ctx, comments),
setReviewComments(prev, currentProjectId() ?? "single", key, comments),
)
}
composer={composers.get}
@@ -2668,6 +2667,11 @@ const AgentManagerContent: Component = () => {
}}
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
@@ -159,6 +159,21 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
},
),
)
createEffect(
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
},
),
)
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
@@ -249,6 +264,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
on(
() => props.sessionKey,
() => {
if (props.active === false) return
setDraft(null)
draftMeta = null
setEditing(null)
@@ -393,8 +409,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
}
@@ -3,6 +3,7 @@ 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
@@ -18,6 +19,8 @@ interface Props {
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>
@@ -46,6 +49,15 @@ 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()
@@ -59,12 +71,19 @@ export const DiffPanelCache: Component<Props> = (props) => {
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) return next
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))
return next.filter((item) => item !== oldest)
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
})
})
@@ -77,7 +96,7 @@ export const DiffPanelCache: Component<Props> = (props) => {
return (
<div class="am-diff-panel-cache" classList={{ "am-diff-panel-cache-active": active() }} inert={!active()}>
<DiffPanel
diffs={props.data()[entry.key] ?? []}
diffs={props.data()[diffDataKey(props.project(), entry.key)] ?? []}
loading={props.loading(entry.key)}
active={active()}
loadingFiles={props.loadingFiles(entry.key)}
@@ -89,8 +108,8 @@ export const DiffPanelCache: Component<Props> = (props) => {
onDiffStyleChange={props.onDiffStyleChange}
markdownRender={props.markdownRender}
onMarkdownRenderChange={props.onMarkdownRenderChange}
comments={props.comments(entry.ctx)}
onCommentsChange={(comments) => props.setComments(entry.ctx, comments)}
comments={props.comments(entry.key)}
onCommentsChange={(comments) => props.setComments(entry.key, comments)}
composer={props.composer(entry.cacheKey)}
onSendClick={props.onSendClick}
onClose={props.onClose}
@@ -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,21 +21,23 @@ 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>()
})
const revertingFor = (id: string) => files()[id] ?? new Set<string>()
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",
@@ -51,12 +53,13 @@ export function createRevertFile(
}
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") {
@@ -20,5 +20,23 @@ export function createReviewComposers(project: Accessor<string | undefined>) {
}
}
return { get, clear }
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 }
}
@@ -13,6 +13,7 @@ interface DiffRequestOptions {
export function createDiffRequests(opts: DiffRequestOptions) {
const requested = new Map<string, string>()
let active = false
createEffect(
on(
@@ -38,6 +39,15 @@ export function createDiffRequests(opts: DiffRequestOptions) {
on(
() => [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)