mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13213 from Kilo-Org/thin-dumpling
feat(agent-manager): render PR comment diffs with Pierre
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Render Agent Manager pull request comment diffs with the Pierre-backed diff viewer and syntax highlighting.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalize, text } from "./session-diff"
|
||||
import { normalize, normalizeHunk, text } from "./session-diff"
|
||||
|
||||
describe("session diff", () => {
|
||||
test("keeps unified patch content", () => {
|
||||
@@ -61,4 +61,27 @@ describe("session diff", () => {
|
||||
expect(view.fileDiff.deletionLines.length).toBe(2)
|
||||
expect(view.fileDiff.additionLines.length).toBe(2)
|
||||
})
|
||||
|
||||
test("normalizes GitHub hunk-only patches for Pierre", () => {
|
||||
const view = normalizeHunk("src/foo.ts", "@@ -340,2 +340,2 @@\n one\n-two\n+three\n")
|
||||
|
||||
expect(view?.patch).toBe("--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -340,2 +340,2 @@\n one\n-two\n+three\n")
|
||||
expect(view?.fileDiff.hunks[0]?.deletionStart).toBe(340)
|
||||
expect(view?.fileDiff.hunks[0]?.additionStart).toBe(340)
|
||||
})
|
||||
|
||||
test("rejects an empty or malformed GitHub hunk", () => {
|
||||
expect(normalizeHunk("src/foo.ts", "")).toBeUndefined()
|
||||
expect(normalizeHunk("src/foo.ts", "not a diff")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("renders a real GitHub hunk with blank lines", () => {
|
||||
const view = normalizeHunk(
|
||||
"packages/kilo-ui/src/components/file.tsx",
|
||||
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n',
|
||||
)
|
||||
|
||||
expect(view?.fileDiff.hunks).toHaveLength(1)
|
||||
expect(view?.fileDiff.hunks[0]?.additionStart).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,13 @@ function name(diff: ReviewDiff) {
|
||||
return diff.file ?? ""
|
||||
}
|
||||
|
||||
function patchFor(file: string, value: string) {
|
||||
if (!value.trimStart().startsWith("@@")) return value
|
||||
if (!file) return value
|
||||
const body = value.endsWith("\n") ? value : `${value}\n`
|
||||
return `--- a/${file}\n+++ b/${file}\n${body}`
|
||||
}
|
||||
|
||||
function contents(diff: ReviewDiff): DiffText {
|
||||
if (typeof diff.patch === "string") {
|
||||
return { ...reconstruct(diff.patch), patch: diff.patch }
|
||||
@@ -93,6 +100,28 @@ export function normalize(diff: ReviewDiff): ViewDiff {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHunk(file: string, patch: string) {
|
||||
if (!file || !patch.trim()) return
|
||||
const value = patchFor(file, patch)
|
||||
let fileDiff: FileDiffMetadata | undefined
|
||||
try {
|
||||
fileDiff = processFile(value, { cacheKey: value })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!fileDiff?.hunks.length) return
|
||||
return {
|
||||
file,
|
||||
patch: value,
|
||||
before: fileDiff.deletionLines.join(""),
|
||||
after: fileDiff.additionLines.join(""),
|
||||
additions: fileDiff.additionLines.length,
|
||||
deletions: fileDiff.deletionLines.length,
|
||||
status: "modified" as const,
|
||||
fileDiff,
|
||||
}
|
||||
}
|
||||
|
||||
export function text(diff: ViewDiff, side: "deletions" | "additions") {
|
||||
const lines = side === "deletions" ? diff.fileDiff.deletionLines : diff.fileDiff.additionLines
|
||||
const out = lines.join("")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window({ url: "http://localhost" })
|
||||
class CSSStyleSheetStub {
|
||||
replaceSync() {}
|
||||
replace() {
|
||||
return Promise.resolve(this)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window,
|
||||
document: window.document,
|
||||
navigator: window.navigator,
|
||||
Node: window.Node,
|
||||
Element: window.Element,
|
||||
HTMLElement: window.HTMLElement,
|
||||
HTMLDivElement: window.HTMLDivElement,
|
||||
HTMLPreElement: window.HTMLPreElement,
|
||||
HTMLAnchorElement: window.HTMLAnchorElement,
|
||||
HTMLButtonElement: window.HTMLButtonElement,
|
||||
SVGElement: window.SVGElement,
|
||||
ShadowRoot: window.ShadowRoot,
|
||||
customElements: window.customElements,
|
||||
CSSStyleSheet: CSSStyleSheetStub,
|
||||
MutationObserver: window.MutationObserver,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
CustomEvent: window.CustomEvent,
|
||||
Event: window.Event,
|
||||
MouseEvent: window.MouseEvent,
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
||||
})
|
||||
|
||||
const { render } = await import("solid-js/web")
|
||||
const { I18nProvider } = await import("@kilocode/kilo-ui/context")
|
||||
const { MarkedProvider } = await import("@kilocode/kilo-ui/context/marked")
|
||||
const { VSCodeProvider } = await import("../../webview-ui/src/context/vscode")
|
||||
const { PRComments } = await import("../../webview-ui/agent-manager/pr/PRComments")
|
||||
|
||||
const root = document.createElement("div")
|
||||
const colors = document.createElement("style")
|
||||
colors.textContent = ":root { --syntax-keyword: rgb(72, 160, 199); --syntax-string: rgb(206, 145, 120); }"
|
||||
document.head.append(colors)
|
||||
document.body.append(root)
|
||||
|
||||
const dispose = render(
|
||||
() => (
|
||||
<VSCodeProvider>
|
||||
<I18nProvider
|
||||
value={
|
||||
{
|
||||
locale: () => "en",
|
||||
t: (key: string) => key,
|
||||
plural: (key: string) => key,
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<MarkedProvider>
|
||||
<PRComments
|
||||
worktreeId="wt-test"
|
||||
comments={{
|
||||
total: 1,
|
||||
unresolved: 1,
|
||||
comments: [
|
||||
{
|
||||
id: "PRRC_test",
|
||||
threadId: "PRRT_test",
|
||||
author: "kilo-code-bot",
|
||||
body: "comment body survives Pierre rendering",
|
||||
file: "packages/kilo-ui/src/components/file.tsx",
|
||||
line: 14,
|
||||
resolved: false,
|
||||
diffHunk:
|
||||
'@@ -1 +1,14 @@\n+import { File as BaseFile, type FileProps } from "@opencode-ai/ui/file"\n+import type { JSX } from "solid-js"\n+import { createDefaultOptions } from "../pierre"\n+\n export * from "@opencode-ai/ui/file"\n+\n+export function File<T>(props: FileProps<T>) {\n+ const View = BaseFile as unknown as (props: FileProps<T>) => JSX.Element\n+ if (props.mode === "text") return <View {...props} />\n+\n+ // Keep inline file diffs on the same Pierre defaults as the dedicated viewer.\n+ const options = { ...createDefaultOptions<T>(props.diffStyle), ...props } as FileProps<T>\n',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</MarkedProvider>
|
||||
</I18nProvider>
|
||||
</VSCodeProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
const host = root.querySelector("diffs-container")
|
||||
const shadow = host?.shadowRoot
|
||||
const keyword = shadow?.querySelector('[data-content] span[style*="--syntax-keyword"]')
|
||||
const string = shadow?.querySelector('[data-content] span[style*="--syntax-string"]')
|
||||
assert.match(root.textContent ?? "", /comment body survives Pierre rendering/)
|
||||
assert.equal(root.querySelectorAll('[data-component="diff"]').length, 1)
|
||||
assert.ok(keyword)
|
||||
assert.ok(string)
|
||||
assert.match(keyword!.getAttribute("style") ?? "", /--syntax-keyword/)
|
||||
assert.match(string!.getAttribute("style") ?? "", /--syntax-string/)
|
||||
assert.notEqual(keyword!.getAttribute("style"), string!.getAttribute("style"))
|
||||
dispose()
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { unlinkSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { build } from "esbuild"
|
||||
import { solidPlugin } from "esbuild-plugin-solid"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const WEBVIEW = path.join(ROOT, "webview-ui")
|
||||
const FIXTURE = path.join(ROOT, "tests/fixtures/pr-comments-render.tsx")
|
||||
|
||||
describe("PR comments", () => {
|
||||
it("keeps a comment mounted while Pierre renders its GitHub hunk", async () => {
|
||||
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
|
||||
const aliases: Record<string, string> = {
|
||||
"solid-js": path.join(solid, "dist/solid.js"),
|
||||
"solid-js/web": path.join(solid, "web/dist/web.js"),
|
||||
"solid-js/store": path.join(solid, "store/dist/store.js"),
|
||||
}
|
||||
const dedupe = {
|
||||
name: "solid-dedupe",
|
||||
setup(ctx: Parameters<NonNullable<Parameters<typeof build>[0]["plugins"]>[number]["setup"]>[0]) {
|
||||
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
|
||||
ctx.onResolve({ filter: /pierre\/worker$/ }, (args) => {
|
||||
if (args.path.includes("@pierre")) return
|
||||
return { path: path.join(WEBVIEW, "pierre-worker.ts") }
|
||||
})
|
||||
ctx.onResolve({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, () => ({
|
||||
path: "markdown-shiki-worker-url",
|
||||
namespace: "kilo-worker-url",
|
||||
}))
|
||||
ctx.onLoad({ filter: /.*/, namespace: "kilo-worker-url" }, () => ({
|
||||
contents: "export default undefined",
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const result = await build({
|
||||
entryPoints: [FIXTURE],
|
||||
bundle: true,
|
||||
conditions: ["browser"],
|
||||
external: ["happy-dom"],
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
platform: "node",
|
||||
plugins: [dedupe, solidPlugin()],
|
||||
target: "es2022",
|
||||
write: false,
|
||||
})
|
||||
const file = path.join(ROOT, `.pr-comments-render-${crypto.randomUUID()}.mjs`)
|
||||
await Bun.write(file, result.outputFiles[0]!.contents)
|
||||
const child = Bun.spawnSync(["bun", file], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe" })
|
||||
unlinkSync(file)
|
||||
|
||||
const output = child.stdout.toString() + child.stderr.toString()
|
||||
expect(child.exitCode, output).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -7,27 +7,7 @@ import type { PRStatus } from "../../src/types/messages"
|
||||
import type { PRComment } from "./pr-types"
|
||||
import { SectionHeading } from "./SectionHeading"
|
||||
import { CopyButton } from "./CopyButton"
|
||||
|
||||
function DiffHunk(props: { hunk: string }) {
|
||||
const lines = () => props.hunk.split("\n")
|
||||
return (
|
||||
<div class="am-pr-diff-hunk">
|
||||
<Index each={lines()}>
|
||||
{(line) => {
|
||||
const text = line()
|
||||
const cls = text.startsWith("+")
|
||||
? "am-pr-diff-line-add"
|
||||
: text.startsWith("-")
|
||||
? "am-pr-diff-line-del"
|
||||
: text.startsWith("@@")
|
||||
? "am-pr-diff-line-meta"
|
||||
: "am-pr-diff-line-ctx"
|
||||
return <div class={`am-pr-diff-line ${cls}`}>{text || " "}</div>
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { PRCommentDiff } from "../../diff-viewer/PRCommentDiff"
|
||||
|
||||
function CommentCard(props: { comment: PRComment; worktreeId: string }) {
|
||||
const vscode = useVSCode()
|
||||
@@ -83,7 +63,9 @@ function CommentCard(props: { comment: PRComment; worktreeId: string }) {
|
||||
|
||||
return (
|
||||
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": resolved() }}>
|
||||
<Show when={props.comment.diffHunk}>{(hunk) => <DiffHunk hunk={hunk()} />}</Show>
|
||||
<Show when={props.comment.diffHunk && props.comment.file}>
|
||||
<PRCommentDiff file={props.comment.file!} hunk={props.comment.diffHunk!} />
|
||||
</Show>
|
||||
<div class="am-pr-panel-comment-header am-pr-row">
|
||||
<span class="am-pr-panel-comment-author">{props.comment.author}</span>
|
||||
<Show when={props.comment.file}>
|
||||
|
||||
@@ -284,39 +284,12 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Diff hunk preview inside comment cards */
|
||||
/* Pierre diff hunk preview inside comment cards */
|
||||
.am-pr-diff-hunk {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.am-pr-diff-line {
|
||||
padding: 1px 6px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.am-pr-diff-line-add {
|
||||
background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent);
|
||||
color: var(--syntax-diff-add, #318430);
|
||||
}
|
||||
|
||||
.am-pr-diff-line-del {
|
||||
background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent);
|
||||
color: var(--syntax-diff-delete, #da3319);
|
||||
}
|
||||
|
||||
.am-pr-diff-line-meta {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-pr-diff-line-ctx {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* Resolve button */
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { Diff } from "@kilocode/kilo-ui/diff"
|
||||
import { normalizeHunk } from "@kilocode/kilo-ui/session-diff"
|
||||
|
||||
export function PRCommentDiff(props: { file: string; hunk: string }) {
|
||||
const view = createMemo(() => normalizeHunk(props.file, props.hunk))
|
||||
|
||||
return (
|
||||
<Show when={view()}>
|
||||
{(value) => (
|
||||
<div class="am-pr-diff-hunk">
|
||||
<Diff fileDiff={value().fileDiff} diffStyle="unified" virtualized={false} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user