diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 7328d9a65a..77fcca0680 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -1535,6 +1535,94 @@ font-size: 12.5px; } +/* A changed image in Files changed's list (image-diff.tsx), under its + header in place of lines, as its lead (diff-view/index.ts): its two sides beside each other, each on a + checkerboard that shows what is transparent, or the after laid over the + before, swiped or faded with the slider under them. */ +.image-diff { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--luke-space-8); + padding: var(--luke-space-12); + font-family: var(--luke-font-sans); +} + +.image-diff-modes { + display: flex; + justify-content: center; +} + +.image-diff-sides { + display: flex; + width: 100%; + flex-wrap: wrap; + justify-content: center; + gap: var(--luke-space-12); +} + +.image-diff-side { + display: flex; + min-width: 0; + flex: 1 1 200px; + flex-direction: column; + align-items: center; + gap: var(--luke-space-6); + margin: 0; +} + +.image-diff-side > figcaption { + color: var(--luke-color-text-muted); + font-size: 11.5px; +} + +.image-diff-picture, +.image-diff-stack { + max-width: 100%; + max-height: 480px; + border: 1px solid var(--luke-color-border); + background: + repeating-conic-gradient(var(--luke-color-surface) 0 25%, transparent 0 50%) 0 0 / 16px 16px; +} + +.image-diff-picture { + object-fit: contain; +} + +.image-diff-stacked { + display: flex; + max-width: 100%; + flex-direction: column; + align-items: center; + gap: var(--luke-space-8); +} + +/* The two sides stand in one cell of a grid, as large as the larger, the + after over the before: each fills the cell, scaled to fit, on a + checkerboard of its own, so the after at full strength or swiped all the + way hides the before however their sizes differ. */ +.image-diff-stack { + display: grid; +} + +.image-diff-stack > .image-diff-picture { + width: 100%; + height: 100%; + grid-area: 1 / 1; + border: 0; + background-color: var(--code-ground); +} + +.image-diff-slider { + width: min(320px, 100%); +} + +.image-diff-note { + margin: 0; + color: var(--luke-color-text-muted); + font-size: 12.5px; +} + /* The tree's search field and its filter, in one row over the tree. */ .file-tree-search { display: flex; diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts index d941a287e7..9e96c9c628 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts @@ -85,6 +85,15 @@ function changed( }; } +/** The commits the service names for a pull request's changed images, each where it named one. */ +interface ImageCommits { + readonly headSha?: string; + readonly baseSha?: string; +} + +/** The commits the service names for the pull request's changed images; none unless a test names them. */ +let commits: ImageCommits = {}; + /** The pull request's files, in the order GitHub lists them, which is not the tree's. */ let files: CodingAgentChangedFile[] = [changed("src/seats.ts")]; @@ -96,6 +105,7 @@ const control = codingAgentsControl({ Promise.resolve({ pullRequest: { url: "https://github.com/acme/relay/pull/7", + ...commits, truncated: false, files, reviewers: [], @@ -161,6 +171,7 @@ beforeEach(() => { revealed = []; treeOpens = []; files = [changed("src/seats.ts")]; + commits = {}; boxWidth = 900; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( this: HTMLElement, @@ -173,6 +184,8 @@ beforeEach(() => { }); afterEach(() => { + watched.clear(); + vi.unstubAllGlobals(); act(() => root?.unmount()); root = undefined; document.body.innerHTML = ""; @@ -681,3 +694,276 @@ test("Refresh changes reads the review threads again with the files, so the two assert.equal(threadReads, 1); }); + +/** What an intersection observer is told of an element coming into view, as far as the view reads it. */ +type InView = (entries: readonly { readonly isIntersecting: boolean }[]) => void; + +/** Every element an intersection observer watches, with the observer's callback, as the window would report one coming into view. */ +const watched = new Map(); + +/** An intersection observer that reports an element in view only once something calls `comeIntoView`, and only an element a document holds. */ +class StandInIntersectionObserver { + readonly #callback: InView; + constructor(callback: InView) { + this.#callback = callback; + } + observe(target: Element): void { + watched.set(target, this.#callback); + } + disconnect(): void { + for (const [target, callback] of watched) + if (callback === this.#callback) watched.delete(target); + } +} + +/** Reports every watched element a document holds as in view, as Pierre standing its file near the view would. */ +function comeIntoView(): void { + for (const [target, callback] of [...watched]) { + if (!target.isConnected) continue; + act(() => callback([{ isIntersecting: true }])); + } +} + +/** The images the service was asked for, by address, each answered with a picture of its own. */ +function answerImages(refusing: Set = new Set()): string[] { + const asked: string[] = []; + fakeAppBridge({ + act: (request: Act) => { + if (request.kind !== ACT_KIND.CODING_AGENTS_GITHUB_IMAGE) { + return Promise.resolve({ status: ACT_OUTCOME_STATUS.REFUSED, reason: "Not in this test." }); + } + asked.push(request.payload.src); + if (refusing.has(request.payload.src)) { + return Promise.resolve({ status: ACT_OUTCOME_STATUS.REFUSED, reason: "Unreachable." }); + } + return Promise.resolve({ + status: ACT_OUTCOME_STATUS.DONE, + value: { contentType: "image/png", base64: btoa(request.payload.src) }, + }); + }, + }); + handSettings(); + return asked; +} + +/** Stands what a file draws in the place of its lines where Pierre would, in the document near the view, and lets its pictures be read. */ +async function standNearView(path: string): Promise { + const file = lastDrawn().files.find((each) => each.path === path); + assert.equal(file?.lead, true, `${path} is drawn as its lead`); + const slot = lastDrawn().renderAnnotation(path, "lead"); + document.body.append(slot); + comeIntoView(); + await flush(); + return slot; +} + +const HEAD_SHA = "1".repeat(40); +const MERGE_BASE_SHA = "2".repeat(40); + +test("a changed image is read at the merge base and the head only once it comes near the view, and drawn before and after", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + const asked = answerImages(); + commits = { headSha: HEAD_SHA, baseSha: MERGE_BASE_SHA }; + files = [ + changed("assets/logo.png", { + previousPath: "assets/old logo.png", + status: CHANGED_FILE_STATUS.RENAMED, + omitted: PATCH_OMITTED.BINARY, + patch: null, + }), + ]; + const props = await drawTab(); + + assert.equal( + props.files[0]?.patch, + "diff --git a/assets/old logo.png b/assets/logo.png\n--- a/assets/old logo.png\n+++ b/assets/logo.png\n", + ); + assert.deepEqual(asked, [], "nothing is read before Pierre stands the file near the view"); + + const slot = await standNearView("assets/logo.png"); + assert.deepEqual(asked, [ + `https://raw.githubusercontent.com/acme/relay/${MERGE_BASE_SHA}/assets/old%20logo.png`, + `https://raw.githubusercontent.com/acme/relay/${HEAD_SHA}/assets/logo.png`, + ]); + const pictures = [...slot.querySelectorAll("img")]; + assert.deepEqual( + pictures.map((picture) => picture.alt), + ["Before", "After"], + ); + assert.equal(pictures[1]?.src, `data:image/png;base64,${btoa(asked[1] ?? "")}`); + + const swipe = [...slot.querySelectorAll('[role="radio"]')].find( + (each) => each.textContent === "Swipe", + ); + act(() => swipe?.click()); + assert.ok(slot.querySelector('input[aria-label="Swipe between the two"]')); + assert.equal(slot.querySelectorAll(".image-diff-stack img").length, 2); +}); + +test("a changed image whose merge base the service could not read draws its after alone, saying the before is unavailable", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + const asked = answerImages(); + commits = { headSha: "3".repeat(40) }; + files = [changed("logo.webp", { omitted: PATCH_OMITTED.BINARY, patch: null })]; + await drawTab(); + + const slot = await standNearView("logo.webp"); + assert.deepEqual(asked, [ + `https://raw.githubusercontent.com/acme/relay/${"3".repeat(40)}/logo.webp`, + ]); + assert.equal(slot.querySelectorAll("img").length, 1); + assert.match(slot.textContent ?? "", /Previous version unavailable/u); +}); + +test("an SVG is drawn as a picture until its source is asked for, and its lines then", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + answerImages(); + commits = { headSha: "4".repeat(40), baseSha: "5".repeat(40) }; + files = [changed("icon.svg")]; + await drawTab(); + assert.equal( + lastDrawn().files[0]?.patch, + "diff --git a/icon.svg b/icon.svg\n--- a/icon.svg\n+++ b/icon.svg\n", + ); + + act(() => + header("icon.svg") + .querySelector('button[aria-label="Show source"]') + ?.click(), + ); + await flush(); + assert.match(lastDrawn().files[0]?.patch ?? "", /@@ -1,2 \+1,3 @@/u); +}); + +test("an image of a service that names no commits is a binary file, as before", async () => { + files = [changed("logo.png", { omitted: PATCH_OMITTED.BINARY, patch: null })]; + await drawTab(); + + assert.equal( + lastDrawn().renderAnnotation("logo.png", "lead").querySelector(".diff-list-notice") + ?.textContent, + "Binary file not shown.", + ); +}); + +test("an image side whose read did not land is asked for again when the changes are read again", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + const after = `https://raw.githubusercontent.com/acme/relay/${"6".repeat(40)}/logo.png`; + const refusing = new Set([after]); + const asked = answerImages(refusing); + commits = { headSha: "6".repeat(40) }; + files = [ + changed("logo.png", { + status: CHANGED_FILE_STATUS.ADDED, + omitted: PATCH_OMITTED.BINARY, + patch: null, + }), + ]; + await drawTab(); + let slot = await standNearView("logo.png"); + assert.match(slot.textContent ?? "", /could not be shown/u); + + refusing.clear(); + const options = document.querySelector('button[aria-label="Changes actions"]'); + act(() => options?.click()); + const refresh = [...document.querySelectorAll('[role="menuitem"]')].find( + (each) => each.textContent?.includes("Refresh changes"), + ); + act(() => refresh?.click()); + await flush(); + slot = await standNearView("logo.png"); + + assert.deepEqual(asked, [after, after]); + assert.equal(slot.querySelector("img")?.alt, "Added"); +}); + +test("an SVG moved with its contents unchanged offers no source, having no lines to show", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + answerImages(); + commits = { headSha: "7".repeat(40), baseSha: "8".repeat(40) }; + files = [ + changed("logo.svg", { + previousPath: "icon.svg", + status: CHANGED_FILE_STATUS.RENAMED, + additions: 0, + deletions: 0, + patch: "", + }), + ]; + await drawTab(); + + assert.equal(lastDrawn().files[0]?.lead, true, "it is drawn as its pictures"); + assert.equal( + header("logo.svg").querySelector('button[aria-label="Show source"]') === null, + true, + "no Show source is offered", + ); +}); + +test("the comparison chosen for an image, and how far it is faded, outlast Refresh changes", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + answerImages(); + commits = { headSha: "9".repeat(40), baseSha: "a".repeat(40) }; + files = [changed("logo.png", { omitted: PATCH_OMITTED.BINARY, patch: null })]; + await drawTab(); + let slot = await standNearView("logo.png"); + const onion = [...slot.querySelectorAll('[role="radio"]')].find( + (each) => each.textContent === "Onion skin", + ); + act(() => onion?.click()); + const fade = () => + slot.querySelector('input[aria-label="Fade the after over the before"]') ?? + assert.fail("the fade's slider stands"); + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + act(() => { + setValue?.call(fade(), "80"); + fade().dispatchEvent(new Event("input", { bubbles: true })); + }); + + const options = document.querySelector('button[aria-label="Changes actions"]'); + act(() => options?.click()); + const refresh = [...document.querySelectorAll('[role="menuitem"]')].find( + (each) => each.textContent?.includes("Refresh changes"), + ); + act(() => refresh?.click()); + await flush(); + slot = await standNearView("logo.png"); + + assert.equal(fade().value, "80"); +}); + +test("after Refresh changes an image far from the view reads nothing until it comes near again", async () => { + vi.stubGlobal("IntersectionObserver", StandInIntersectionObserver); + const asked = answerImages(); + commits = { headSha: "b".repeat(40) }; + files = [ + changed("logo.png", { + status: CHANGED_FILE_STATUS.ADDED, + omitted: PATCH_OMITTED.BINARY, + patch: null, + }), + ]; + await drawTab(); + const slot = await standNearView("logo.png"); + assert.equal(asked.length, 1); + // Pierre takes the file's place away as it scrolls far from the view. + slot.remove(); + + commits = { headSha: "c".repeat(40) }; + const options = document.querySelector('button[aria-label="Changes actions"]'); + act(() => options?.click()); + const refresh = [...document.querySelectorAll('[role="menuitem"]')].find( + (each) => each.textContent?.includes("Refresh changes"), + ); + act(() => refresh?.click()); + await flush(); + comeIntoView(); + await flush(); + assert.equal(asked.length, 1, "nothing is read while the file is far from the view"); + + await standNearView("logo.png"); + assert.deepEqual( + asked.at(-1), + `https://raw.githubusercontent.com/acme/relay/${"c".repeat(40)}/logo.png`, + ); +}); diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx index 63725ba0e5..2a3e05059b 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx @@ -9,6 +9,7 @@ import { type CodingAgentPullRequest, type ReviewThread, } from "@luke/features/coding-agent-wire"; +import { imageTypeOfPath } from "@luke/features/github-image"; import type { CatalogModel } from "@luke/features/models-wire"; import { DIFF_LAYOUT, INLINE_DIFF } from "@luke/settings/diff-view"; import { @@ -26,9 +27,11 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, ClipboardCopyIcon, + CodeIcon, Columns2Icon, FileDiffIcon, HighlighterIcon, + ImageIcon, RotateCwIcon, } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -46,6 +49,7 @@ import { changeMarks, fileNotice, filePatch, + headerPatch, linesChanged, listPatch, STATUS_LETTER, @@ -60,6 +64,7 @@ import { LinesMenu, useSaveText } from "./file-menu"; import { FileTreePane } from "./file-tree-pane"; import { FileViewBar, usePaneRoom } from "./file-view"; import { useFileViewSettings } from "./file-view-settings"; +import { ImageDiff, type ImageSides, imageDrawable } from "./image-diff"; import { PullRequestMark } from "./pull-request-mark"; import { OpenedBy, PullRequestPeople, ReviewersLine } from "./pull-request-people"; import type { CodingAgentsControl, HeldPeople } from "./use-coding-agents"; @@ -304,36 +309,53 @@ function AboveThreads({ * header, and its notes: each review thread under its line, and those its * diff draws no row for above the first hunk (`threadsOn`), on the side * each counts on, as GitHub's split diff has them. A file with no hunks to - * draw stands as what is said in their place alone (`lead`), across its - * width: why it has none, and every one of its review threads. + * draw, or a changed image drawn as its pictures (`image-diff.tsx`), stands + * as what is drawn in the place of its lines alone (`lead`), across its + * width: why it has none, or the pictures, and every one of its review + * threads. */ function entryOf( file: CodingAgentChangedFile, list: ChangesList, threads: ChangesThreads | undefined, + images: ImageSides | undefined, ): DiffListEntry { - const { placed, above } = threadsOn(threads?.list ?? [], file); - const notice = fileNotice(file); + const path = file.path; + const image = imageTypeOfPath(path) === undefined || images === undefined ? undefined : images; + // An SVG with lines to draw is text as well as a picture: its lines are drawn while its source is asked for. + const source = fileNotice(file) === undefined && list.asSource(path); + const pictured = image !== undefined && !source && imageDrawable(file, image) ? image : undefined; + // Note that a pictured file draws no lines, so its threads stand above it, as on a binary file. + const drawnAs = pictured === undefined ? file : { ...file, patch: null }; + const { placed, above } = threadsOn(threads?.list ?? [], drawnAs); const notes: DiffNote[] = placed.map((each) => ({ key: each.key, side: each.side, line: each.line, content: threads?.draw(each.thread, THREAD_PLACE.ON_LINE), })); - if (notice !== undefined) { + // A pictured image, or a file with no hunks, stands as what is drawn in the place of its lines. + const notice = fileNotice(file); + const lead = + pictured !== undefined ? ( + + ) : notice === undefined ? undefined : ( +

{notice}

+ ); + if (lead !== undefined) { notes.unshift({ key: LEAD_KEY, side: DIFF_SELECTION_SIDE.ADDITIONS, line: FILE_NOTE_LINE, content: ( <> -

{notice}

+ {lead} {above.length === 0 ? null : } ), }); } - for (const side of notice === undefined ? Object.values(DIFF_SELECTION_SIDE) : []) { + for (const side of lead === undefined ? Object.values(DIFF_SELECTION_SIDE) : []) { const unplaced = above.filter((each) => each.side === side); if (unplaced.length === 0) continue; notes.unshift({ @@ -343,16 +365,20 @@ function entryOf( content: , }); } - const path = file.path; const collapsed = list.collapsed(path); return { path, - patch: listPatch(file), + patch: pictured === undefined ? listPatch(file) : headerPatch(file), collapsed, - lead: notice !== undefined, + lead: lead !== undefined, header: ( list.toggleSource(path)} /> + ) + } collapsed={collapsed} standing={list.standing === path} onToggle={() => list.toggle(path)} @@ -363,6 +389,52 @@ function entryOf( }; } +/** An SVG's turn between its picture and its source's lines, at the end of its header. */ +function SourceToggle({ + source, + onToggle, +}: { + source: boolean; + onToggle: () => void; +}): React.JSX.Element { + const label = source ? LIST_WORDS.SHOW_IMAGE : LIST_WORDS.SHOW_SOURCE; + return ( + + + + ); +} + +/** + * Where a pull request's changed images are read: its repository, named by + * its page on GitHub, and the commits the service named; none where the + * page names no repository. + */ +function imagesOf( + agentId: string, + pullRequest: { readonly url: string; readonly headSha?: string; readonly baseSha?: string }, + read: number, +): ImageSides | undefined { + const [owner, name] = URL.canParse(pullRequest.url) + ? new URL(pullRequest.url).pathname.split("/").filter((segment) => segment !== "") + : []; + if (owner === undefined || name === undefined) return undefined; + return { + agentId, + repository: `${owner}/${name}`, + headSha: pullRequest.headSha, + baseSha: pullRequest.baseSha, + read, + }; +} + /** Collapse all or Expand all, for the files the filter keeps, in the bar. */ function FoldAllButton({ list }: { list: ChangesList }): React.JSX.Element { const label = list.allCollapsed ? LIST_WORDS.EXPAND_ALL : LIST_WORDS.COLLAPSE_ALL; @@ -389,12 +461,15 @@ function FoldAllButton({ list }: { list: ChangesList }): React.JSX.Element { function FilesList({ list, threads, + images, filterShown, roomy, boxRef, }: { list: ChangesList; threads: ChangesThreads | undefined; + /** Where the pull request's changed images are read, drawn as pictures; none draws them as binary files. */ + images: ImageSides | undefined; /** Whether the tree, whose search is the filter, is drawn, so the filter is in sight. */ filterShown: boolean; roomy: boolean; @@ -418,7 +493,7 @@ function FilesList({

{LIST_WORDS.NONE_KEPT}

) : ( entryOf(file, list, threads))} + entries={list.kept.map((file) => entryOf(file, list, threads, images))} look={look} door={list.door} onTopFile={list.onTopFile} @@ -518,6 +593,12 @@ export function AgentChangesTab({ const status = control.agents?.find((each) => each.id === agentId)?.status; const ended = !agentStillWriting(status ?? CODING_AGENT_STATUS.RUNNING); const { answer, out, ask } = useAgentRead(agentId, shown, ended, control.readChanges, UNANSWERED); + // How many answers have landed, which a changed image is drawn anew on. + const reads = useRef<{ readonly of: unknown; readonly count: number }>({ + of: undefined, + count: 0, + }); + if (reads.current.of !== answer) reads.current = { of: answer, count: reads.current.count + 1 }; const room = usePaneRoom(tree); const [diffBox, setDiffBox] = useState(null); const roomy = useSplitRoom(diffBox); @@ -619,6 +700,7 @@ export function AgentChangesTab({ , path: string): ReadonlySet { + const next = new Set(paths); + if (!next.delete(path)) next.add(path); + return next; +} + /** The folds turned over, with `paths` folded as `collapsed` says. */ function foldedAs( overrides: ReadonlySet, @@ -126,6 +143,9 @@ export interface ChangesList { readonly stand: (path: string) => void; readonly onKeyDown: (event: React.KeyboardEvent) => void; readonly door: RefObject; + /** Whether an image that is text too, an SVG, is drawn as its source's lines rather than as a picture, and the door that turns it. */ + readonly asSource: (path: string) => boolean; + readonly toggleSource: (path: string) => void; } /** Files changed's list over `files`, every fold at its default and no file stood on until the developer acts. */ @@ -134,6 +154,7 @@ export function useChangesList(files: readonly CodingAgentChangedFile[]): Change const [overrides, setOverrides] = useState>(new Set()); const [standing, setStanding] = useState(undefined); const [top, setTop] = useState(undefined); + const [sources, setSources] = useState>(new Set()); const door = useRef(null); const ordered = useMemo(() => treeOrder(files), [files]); const kept = useMemo(() => filterFiles(ordered, query), [ordered, query]); @@ -148,13 +169,9 @@ export function useChangesList(files: readonly CodingAgentChangedFile[]): Change const keptStanding = standing !== undefined && paths.includes(standing) ? standing : undefined; const current = keptStanding ?? (top !== undefined && paths.includes(top) ? top : paths[0]); - const toggle = useCallback((path: string) => { - setOverrides((was) => { - const next = new Set(was); - if (!next.delete(path)) next.add(path); - return next; - }); - }, []); + const toggle = useCallback((path: string) => setOverrides((was) => turned(was, path)), []); + const asSource = useCallback((path: string) => sources.has(path), [sources]); + const toggleSource = useCallback((path: string) => setSources((was) => turned(was, path)), []); const toggleAll = useCallback( () => setOverrides((was) => foldedAs(was, kept, !allCollapsed)), [kept, allCollapsed], @@ -209,24 +226,30 @@ export function useChangesList(files: readonly CodingAgentChangedFile[]): Change stand: setStanding, onKeyDown, door, + asSource, + toggleSource, }; } /** * A file's header in the list, one row: its fold, the letter of what * became of it, its path, from the path it moved from where it moved, and - * the lines it adds and removes. Pierre keeps it at the top of the list + * the lines it adds and removes, and any control of its own at the end of + * its row. Pierre keeps it at the top of the list * while the file's lines scroll under it, at the height it reserves for it, * so nothing more stands in it. */ export function FileHead({ file, + controls, collapsed, standing, onToggle, onStand, }: { file: CodingAgentChangedFile; + /** The file's own controls, at the end of its row; none for none. */ + controls?: ReactNode; collapsed: boolean; /** Whether the keys stand on the file, which outlines it. */ standing: boolean; @@ -267,6 +290,7 @@ export function FileHead({ {file.path} + {controls} ); diff --git a/apps/desktop/src/renderer/tasks/changes-model.ts b/apps/desktop/src/renderer/tasks/changes-model.ts index 84df86a4b4..9f14f24c12 100644 --- a/apps/desktop/src/renderer/tasks/changes-model.ts +++ b/apps/desktop/src/renderer/tasks/changes-model.ts @@ -466,22 +466,26 @@ export function filePatch(file: CodingAgentChangedFile): string | undefined { return [`diff --git a/${from} b/${file.path}`, `--- ${before}`, `+++ ${after}`, body].join("\n"); } +/** A file's header as git writes one, with no hunks under it: what the list draws for a file whose place shows no lines. */ +export function headerPatch(file: CodingAgentChangedFile): string { + const from = file.previousPath ?? file.path; + return [`diff --git a/${from} b/${file.path}`, `--- a/${from}`, `+++ b/${file.path}`, ""].join( + "\n", + ); +} + /** * What a file's place in the list draws: its patch as a whole unified diff, - * or, where it has no hunks to draw, the same header with none under it, so + * or, where it has no hunks to draw, its header alone (`headerPatch`), so * the file still stands in the list under its header, with why it has none * said there (`fileNotice`). */ export function listPatch(file: CodingAgentChangedFile): string { - const patch = filePatch(file); - if (patch !== undefined) return patch; - const from = file.previousPath ?? file.path; - return [`diff --git a/${from} b/${file.path}`, `--- a/${from}`, `+++ b/${file.path}`, ""].join( - "\n", - ); + return filePatch(file) ?? headerPatch(file); } /** The files whose path holds `query`, whatever its case, as the tree's search narrows its rows; every file for a query of nothing but spaces. */ + export function filterFiles( files: readonly CodingAgentChangedFile[], query: string, diff --git a/apps/desktop/src/renderer/tasks/image-diff.tsx b/apps/desktop/src/renderer/tasks/image-diff.tsx new file mode 100644 index 0000000000..cbaee29a38 --- /dev/null +++ b/apps/desktop/src/renderer/tasks/image-diff.tsx @@ -0,0 +1,337 @@ +import { CHANGED_FILE_STATUS, type CodingAgentChangedFile } from "@luke/features/coding-agent-wire"; +import { ACT_KIND } from "@luke/host/acts"; +import { PICTURE_STATE, type PictureState, usePicture } from "@luke/ui/picture-cache"; +import { useCallback, useEffect, useState } from "react"; +import { useAct } from "../act"; +import { Segmented } from "../segmented"; + +/** + * image-diff.tsx -- a changed image as Files changed draws it: the picture before and after, side by side, swiped, or one faded over the other, in place of "binary file". + * + * Each side is the file of the agent's own repository at a commit, read by + * the service through the repository's contents on the App's token, as the + * Pull request tab reads any picture its pull request names + * (`@luke/features/github-image`'s raw-file address, the act + * `CODING_AGENTS_GITHUB_IMAGE`): the before at the merge base GitHub counts + * the changes from, the after at the head. So the window asks GitHub nothing + * itself, holds no token, and draws each from a `data:` URL of its own + * making, which the panel's policy admits; a side the service will not + * answer, an image past its bound or no image at all, says so. A side is + * read only once its file is open and Pierre has drawn it near the view, + * and kept for the window's life (`@luke/ui/picture-cache`), so a list of a + * hundred images reads only the ones looked at; a side whose read did not + * land is asked for again when the changes are read again. An image added has no + * before and one deleted no after; a before whose merge base the service + * could not read is not drawn at all rather than drawn wrong, and the after + * stands alone, saying so. + */ + +/** How an image's two sides are set against each other: beside each other, the after swiped over the before, or faded over it. */ +const IMAGE_COMPARE = { + SIDE_BY_SIDE: "side-by-side", + SWIPE: "swipe", + ONION_SKIN: "onion-skin", +} as const; + +type ImageCompare = (typeof IMAGE_COMPARE)[keyof typeof IMAGE_COMPARE]; + +const COMPARE_OPTIONS = [ + { value: IMAGE_COMPARE.SIDE_BY_SIDE, label: "2-up" }, + { value: IMAGE_COMPARE.SWIPE, label: "Swipe" }, + { value: IMAGE_COMPARE.ONION_SKIN, label: "Onion skin" }, +] as const satisfies readonly { readonly value: ImageCompare; readonly label: string }[]; + +/** What a changed image's place says. */ +const IMAGE_WORDS = { + BEFORE: "Before", + AFTER: "After", + ADDED: "Added", + DELETED: "Deleted", + READING: "Reading the image…", + MISSING: "This image could not be shown.", + NO_BEFORE: "Previous version unavailable", + COMPARE: "Compare images", + SWIPE: "Swipe between the two", + FADE: "Fade the after over the before", +} as const; + +/** How far into the before the swipe or the fade starts, in percent. */ +const HALFWAY = 50; + +/** Where an image's sides are read: the agent whose repository they are in, the repository as `owner/name`, and the commits each side stands at. */ +export interface ImageSides { + readonly agentId: string; + readonly repository: string; + readonly headSha: string | undefined; + readonly baseSha: string | undefined; + /** How many times the changes were read, so a read again asks again for a side whose last read did not land. */ + readonly read: number; +} + +/** The raw file of the repository at a commit, the address the service reads a repository's picture by. */ +function rawAddress(repository: string, sha: string, path: string): string { + const segments = [...repository.split("/"), sha, ...path.split("/")]; + return `https://raw.githubusercontent.com/${segments.map(encodeURIComponent).join("/")}`; +} + +/** Reads a picture of the agent's repository through the service. */ +function useImageRead() { + const { act } = useAct(); + return useCallback( + (agentId: string, address: string) => + act(ACT_KIND.CODING_AGENTS_GITHUB_IMAGE, { agentId, src: address }), + [act], + ); +} + +/** Whether `element` has come near the view, which it stays once it has; an element no document holds never does. */ +function useNearView(element: HTMLElement | null): boolean { + const [near, setNear] = useState(false); + useEffect(() => { + if (element === null || near) return undefined; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) setNear(true); + }, + { rootMargin: "400px 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element, near]); + return near; +} + +/** One side as it stands: its picture, the words saying it is read, or that it cannot be shown. */ +function SidePicture({ state, label }: { state: PictureState; label: string }) { + if (state.kind === PICTURE_STATE.READY) { + return {label}; + } + const words = state.kind === PICTURE_STATE.READING ? IMAGE_WORDS.READING : IMAGE_WORDS.MISSING; + return

{words}

; +} + +/** One side alone, under the word for what it is, and a note under it where it stands for two. */ +function OneSide({ + agentId, + address, + label, + note, +}: { + agentId: string; + address: string; + label: string; + note?: string | undefined; +}): React.JSX.Element { + const state = usePicture(agentId, address, useImageRead()); + return ( +
+
+
{label}
+ + {note === undefined ? null :

{note}

} +
+
+ ); +} + +/** How an image's two sides are set against each other, and how far the after is swiped or faded in, in percent. */ +interface Comparison { + readonly compare: ImageCompare; + readonly amount: number; +} + +const FIRST_COMPARISON: Comparison = { compare: IMAGE_COMPARE.SIDE_BY_SIDE, amount: HALFWAY }; + +/** The after over the before, swiped or faded at `amount` percent, with the slider that moves it. */ +function Stacked({ + before, + after, + compare, + amount, + onAmount, +}: { + before: string; + after: string; + compare: typeof IMAGE_COMPARE.SWIPE | typeof IMAGE_COMPARE.ONION_SKIN; + amount: number; + onAmount: (amount: number) => void; +}): React.JSX.Element { + const swipe = compare === IMAGE_COMPARE.SWIPE; + // Note that the after is cut or faded through the CSSOM (`style`), which the panel's policy admits where it refuses a style written as markup. + const style = swipe ? { clipPath: `inset(0 0 0 ${amount}%)` } : { opacity: amount / 100 }; + return ( +
+
+ {IMAGE_WORDS.BEFORE} + {IMAGE_WORDS.AFTER} +
+ onAmount(Number(event.target.value))} + /> +
+ ); +} + +/** Both sides, as the comparison chosen sets them, once both are read; each side as it stands until then. */ +function BothSides({ + agentId, + before, + after, + comparison, + onComparison, +}: { + agentId: string; + before: string; + after: string; + comparison: Comparison; + onComparison: (comparison: Comparison) => void; +}): React.JSX.Element { + const read = useImageRead(); + const beforeState = usePicture(agentId, before, read); + const afterState = usePicture(agentId, after, read); + const { compare, amount } = comparison; + const ready = + beforeState.kind === PICTURE_STATE.READY && afterState.kind === PICTURE_STATE.READY + ? { before: beforeState.url, after: afterState.url } + : undefined; + return ( + <> +
+ onComparison({ compare: next, amount })} + /> +
+ {ready === undefined || compare === IMAGE_COMPARE.SIDE_BY_SIDE ? ( +
+
+
{IMAGE_WORDS.BEFORE}
+ +
+
+
{IMAGE_WORDS.AFTER}
+ +
+
+ ) : ( + onComparison({ compare, amount: next })} + /> + )} + + ); +} + +/** What of a changed image is drawn: both sides, one, or neither, by what became of it and which commits the service named. */ +function sidesOf(file: CodingAgentChangedFile, sides: ImageSides) { + const { repository, headSha, baseSha } = sides; + const before = + file.status === CHANGED_FILE_STATUS.ADDED || baseSha === undefined + ? undefined + : rawAddress(repository, baseSha, file.previousPath ?? file.path); + const after = + file.status === CHANGED_FILE_STATUS.DELETED || headSha === undefined + ? undefined + : rawAddress(repository, headSha, file.path); + return { before, after }; +} + +/** Whether a changed image can be drawn at all: the commit of a side it has is known. */ +export function imageDrawable(file: CodingAgentChangedFile, sides: ImageSides): boolean { + const { before, after } = sidesOf(file, sides); + return file.status === CHANGED_FILE_STATUS.DELETED ? before !== undefined : after !== undefined; +} + +/** + * A changed image's sides as one read of the changes names them, read once + * they come near the view: both for one changed, the one it has for one + * added or deleted. + */ +function ImageSidesNear({ + file, + sides, + comparison, + onComparison, +}: { + file: CodingAgentChangedFile; + sides: ImageSides; + comparison: Comparison; + onComparison: (comparison: Comparison) => void; +}): React.JSX.Element { + const [box, setBox] = useState(null); + const near = useNearView(box); + const { before, after } = sidesOf(file, sides); + const { agentId } = sides; + const drawn = (() => { + if (before !== undefined && after !== undefined) { + return ( + + ); + } + if (after !== undefined) { + const added = file.status === CHANGED_FILE_STATUS.ADDED; + return ( + + ); + } + return before === undefined ? null : ( + + ); + })(); + return ( +
+ {near ? drawn :

{IMAGE_WORDS.READING}

} +
+ ); +} + +/** + * A changed image, read and drawn once it comes near the view. Note that + * its sides are drawn anew for each read of the changes, and wait to come + * near the view again, so a side whose read did not land is asked for + * again and one far from the view is asked for nothing, while the + * comparison chosen and how far it is swiped stand here, above them, and + * outlast the read. + */ +export function ImageDiff({ + file, + sides, +}: { + file: CodingAgentChangedFile; + sides: ImageSides; +}): React.JSX.Element { + const [comparison, setComparison] = useState(FIRST_COMPARISON); + return ( + + ); +} diff --git a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentChangesAnswerViewSchema.json b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentChangesAnswerViewSchema.json index 3e1ab6ddc3..dea8704df5 100644 --- a/packages/features/fixtures/json-schema/coding-agent-view-codingAgentChangesAnswerViewSchema.json +++ b/packages/features/fixtures/json-schema/coding-agent-view-codingAgentChangesAnswerViewSchema.json @@ -11,6 +11,12 @@ "url": { "type": "string" }, + "headSha": { + "type": "string" + }, + "baseSha": { + "type": "string" + }, "files": { "type": "array", "items": { diff --git a/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentChangesAnswerSchema.json b/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentChangesAnswerSchema.json index ef853d7311..52752eef37 100644 --- a/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentChangesAnswerSchema.json +++ b/packages/features/fixtures/json-schema/coding-agent-wire-codingAgentChangesAnswerSchema.json @@ -9,6 +9,12 @@ "url": { "type": "string" }, + "headSha": { + "type": "string" + }, + "baseSha": { + "type": "string" + }, "files": { "type": "array", "items": { diff --git a/packages/features/src/coding-agent-wire.ts b/packages/features/src/coding-agent-wire.ts index 90a16df1f2..fcb5cf17b8 100644 --- a/packages/features/src/coding-agent-wire.ts +++ b/packages/features/src/coding-agent-wire.ts @@ -826,6 +826,9 @@ export const CHANGES_BOUNDS = { * and removes; and its patch, every hunk of a unified diff from the first * `@@` header on, or null with why where it carries none. */ +/** A commit as GitHub names it: forty hex digits. */ +const commitShaSchema = EffectSchema.String.check(EffectSchema.isPattern(/^[0-9a-f]{40}$/u)); + const changedFileSchema = EffectSchema.Struct({ path: EffectSchema.String, previousPath: EffectSchema.NullOr(EffectSchema.String), @@ -894,12 +897,18 @@ const reviewCommentSchema = EffectSchema.Struct({ * reviewers and where each stands, who authored its commits, and its * reviews that said something ride beside the files, each absent from a * service before them, and who the agent writes as absent too for an agent - * whose first session never settled it. + * whose first session never settled it. The commits a changed image is read + * at ride beside them too (`headSha`, the pull request's head, and + * `baseSha`, where its head and base branch part, which GitHub's diff is + * counted from), each absent where it could not be read, and `baseSha` + * absent where no image the files change stood before them. */ export const codingAgentChangesAnswerSchema = EffectSchema.Struct({ pullRequest: EffectSchema.NullOr( EffectSchema.Struct({ url: EffectSchema.String, + headSha: EffectSchema.optionalKey(commitShaSchema), + baseSha: EffectSchema.optionalKey(commitShaSchema), files: EffectSchema.Array(changedFileSchema).check( EffectSchema.isMaxLength(CHANGES_BOUNDS.MAX_FILES), ), diff --git a/packages/features/src/github-image.ts b/packages/features/src/github-image.ts index c716cd3e0c..22b990454b 100644 --- a/packages/features/src/github-image.ts +++ b/packages/features/src/github-image.ts @@ -86,6 +86,12 @@ const FILE_TYPES: ReadonlyMap = new Map([ ["svg", GITHUB_IMAGE_TYPE.SVG], ]); +/** The type a file of a repository is read as, by its path's extension; none for a path whose extension names no image the window draws. */ +export function imageTypeOfPath(path: string): GitHubImageType | undefined { + const extension = /\.([^./]+)$/u.exec(path)?.[1]?.toLowerCase() ?? ""; + return FILE_TYPES.get(extension); +} + /** The segment `github.com///raw//` names a repository's file under. */ const RAW_SEGMENT = "raw"; @@ -131,8 +137,7 @@ function fileOf(segments: readonly string[]): GitHubFileImage | undefined { const decoded = segments.map(decodedSegment).filter((segment) => segment !== undefined); if (decoded.length !== segments.length || decoded.length < 4) return undefined; const [owner = "", name = "", ref = "", ...path] = decoded; - const extension = /\.([^.]+)$/u.exec(path.at(-1) ?? "")?.[1]?.toLowerCase() ?? ""; - const contentType = FILE_TYPES.get(extension); + const contentType = imageTypeOfPath(path.at(-1) ?? ""); if (contentType === undefined) return undefined; return { source: GITHUB_IMAGE_SOURCE.FILE, diff --git a/packages/service/src/agents/coding-agents-app.ts b/packages/service/src/agents/coding-agents-app.ts index 52e1641d5f..2de315b86a 100644 --- a/packages/service/src/agents/coding-agents-app.ts +++ b/packages/service/src/agents/coding-agents-app.ts @@ -92,7 +92,9 @@ import { takeOutOfMergeQueue, } from "../github/pull-request-writes.js"; import { + imageCommits, type PullRequestReadFailure, + pullRequestByNumber, pullRequestChecks, pullRequestConversation, pullRequestDeployments, @@ -906,7 +908,8 @@ const pullRequestEndpoint = /* @__PURE__ */ Effect.fn("web/agentPullRequestEndpo /** * GET reads the agent's changes: the files of the pull request its kept - * published answer names, and who it names (`github-people.ts`), read from + * published answer names, who it names (`github-people.ts`), and the + * commits its changed images are read at (`imageCommits`), read from * GitHub afresh on a token minted for the agent's repository on its task's * workspace installation, with who its row says it writes as, or none * where it names no pull request yet. @@ -925,14 +928,19 @@ const changesEndpoint = /* @__PURE__ */ Effect.fn("web/agentChangesEndpoint")(fu if (pullRequest === null) return hostedJsonResponse(HOSTED_HTTP_STATUS.OK, { pullRequest }); const listed = yield* Effect.gen(function* () { const { token } = yield* agentPullRequestReach(userId, agent); - const [files, people] = yield* Effect.all( + // Note that the pull request itself is read beside its files for its head and base, which its changed images are read at; a read that does not land names neither. + const [files, people, whole] = yield* Effect.all( [ pullRequestFiles(token, agent.repository, pullRequest), changesPeople(token, agent, pullRequest.number), + Effect.orElseSucceed(pullRequestByNumber(token, agent.repository, pullRequest.number), () => + Option.none(), + ), ], { concurrency: "unbounded" }, ); - return { ...files, ...people }; + const commits = yield* imageCommits(token, agent.repository, whole, files.files); + return { ...commits, ...files, ...people }; }).pipe( Effect.catch((failure) => { switch (failure._tag) { diff --git a/packages/service/src/github/image-commits.test.ts b/packages/service/src/github/image-commits.test.ts new file mode 100644 index 0000000000..1db34136af --- /dev/null +++ b/packages/service/src/github/image-commits.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { it } from "@effect/vitest"; +import { CHANGED_FILE_STATUS, type CodingAgentChangedFile } from "@luke/features/coding-agent-wire"; +import { Effect, Option, Redacted } from "effect"; +import { githubReaching, type SentToGitHub } from "../testing/github-app-fake.js"; +import { imageCommits } from "./pull-requests.js"; + +/** + * image-commits.test.ts -- the commits a pull request's changed images are + * read at, off a scripted GitHub: its head always, and where an image stood + * before the pull request, the merge base one compare of its base and head + * answers, asked for a single commit and file; no compare at all where no + * image stood before, and no merge base where the compare does not land, + * never the base branch's head in its place. + */ + +const REPOSITORY = "acme/relay"; +const TOKEN = Redacted.make("ghs_fixture_installation_token"); +const HEAD = "a".repeat(40); +const BASE = "b".repeat(40); +const MERGE_BASE = "c".repeat(40); +const COMPARE = `/repos/acme/relay/compare/${BASE}...${HEAD}`; + +/** A changed file, modified unless told otherwise. */ +function changed( + path: string, + status: CodingAgentChangedFile["status"] = CHANGED_FILE_STATUS.MODIFIED, +): CodingAgentChangedFile { + return { + path, + previousPath: null, + status, + additions: 0, + deletions: 0, + patch: null, + omitted: null, + }; +} + +/** The image commits for `files`, GitHub's compare answering as `compare` says, and every request it was sent. */ +const commitsOf = (files: readonly CodingAgentChangedFile[], compare: () => Response) => + Effect.gen(function* () { + const sent: SentToGitHub[] = []; + const github = githubReaching([], undefined, undefined, (request) => { + sent.push(request); + return new URL(request.url).pathname === COMPARE ? compare() : undefined; + }); + const commits = yield* imageCommits( + TOKEN, + REPOSITORY, + Option.some({ headSha: HEAD, baseSha: BASE }), + files, + ).pipe(Effect.provide(github.layer)); + return { commits, sent }; + }); + +const mergeBase = () => Response.json({ merge_base_commit: { sha: MERGE_BASE }, files: [] }); + +it.effect( + "an image changed in place is read at the head and at the merge base one small compare answers", + () => + Effect.gen(function* () { + const { commits, sent } = yield* commitsOf([changed("docs/logo.png")], mergeBase); + + assert.deepEqual(commits, { headSha: HEAD, baseSha: MERGE_BASE }); + assert.equal(sent.length, 1); + assert.equal(new URL(sent[0]?.url ?? "").searchParams.get("per_page"), "1"); + }), +); + +it.effect( + "a removed image needs the merge base too, and an added one or a text file asks no compare", + () => + Effect.gen(function* () { + const removed = yield* commitsOf( + [changed("logo.svg", CHANGED_FILE_STATUS.DELETED)], + mergeBase, + ); + assert.equal(removed.commits.baseSha, MERGE_BASE); + + const added = yield* commitsOf( + [changed("logo.png", CHANGED_FILE_STATUS.ADDED), changed("src/seats.ts")], + mergeBase, + ); + assert.deepEqual(added.commits, { headSha: HEAD }); + assert.equal(added.sent.length, 0); + }), +); + +it.effect("a compare that does not land names no merge base, never the base branch's head", () => + Effect.gen(function* () { + const { commits } = yield* commitsOf([changed("docs/logo.png")], () => + Response.json({ message: "Server Error" }, { status: 500 }), + ); + + assert.deepEqual(commits, { headSha: HEAD }); + }), +); diff --git a/packages/service/src/github/pull-requests.ts b/packages/service/src/github/pull-requests.ts index 715b4ea2f2..38a4269a4f 100644 --- a/packages/service/src/github/pull-requests.ts +++ b/packages/service/src/github/pull-requests.ts @@ -66,7 +66,7 @@ import { REVIEW_STATE, type ReviewState, } from "@luke/features/coding-agent-wire"; -import { githubImageOf } from "@luke/features/github-image"; +import { githubImageOf, imageTypeOfPath } from "@luke/features/github-image"; import { Effect, Option, type Redacted, Schema } from "effect"; import type * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -100,8 +100,9 @@ export interface GitHubPullRequest { readonly openedAt?: number; readonly mergedAt?: number; readonly closedAt?: number; - /** The branch it merges into, where GitHub named it. */ + /** The branch it merges into, and that branch's head as GitHub last counted it, where GitHub named them. */ readonly baseRef?: string; + readonly baseSha?: string; /** Its labels, each by its name and its colour as GitHub's six hex digits. */ readonly labels?: readonly GitHubLabel[]; /** How many comments it holds, on its conversation and its diff together, where GitHub counted them. */ @@ -192,7 +193,7 @@ const PullRequestSchema = Schema.Struct({ created_at: Schema.optional(Schema.String), merged_at: Schema.optional(Schema.NullOr(Schema.String)), closed_at: Schema.optional(Schema.NullOr(Schema.String)), - base: Schema.optional(Schema.Struct({ ref: Schema.String })), + base: Schema.optional(Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) })), labels: Schema.optional( Schema.Array(Schema.Struct({ name: Schema.String, color: Schema.String })), ), @@ -377,6 +378,7 @@ function pullRequestOf(record: typeof PullRequestSchema.Type): GitHubPullRequest ...(mergedAt === undefined ? undefined : { mergedAt }), ...(closedAt === undefined ? undefined : { closedAt }), ...(record.base === undefined ? undefined : { baseRef: record.base.ref }), + ...(record.base?.sha === undefined ? undefined : { baseSha: record.base.sha }), ...(record.labels === undefined ? undefined : { labels: record.labels }), ...(comments === undefined ? undefined : { comments }), ...(record.commits === undefined ? undefined : { commits: record.commits }), @@ -579,6 +581,61 @@ export const pullRequestFiles = /* @__PURE__ */ Effect.fn("web/pullRequestFiles" return { files: kept, truncated: pullRequest.changedFiles > kept.length }; }); +/** Two commits compared, as far as where they part. */ +const CompareSchema = Schema.Struct({ merge_base_commit: Schema.Struct({ sha: Schema.String }) }); + +/** The commits a pull request's changed images are read at: its head, and where it parts from its base branch, each where it was read. */ +export interface ImageCommits { + readonly headSha?: string; + readonly baseSha?: string; +} + +/** Whether a changed file is an image whose before stands at the base: one changed in place, moved, or removed. */ +function imageWithBefore(file: CodingAgentChangedFile): boolean { + return ( + file.status !== CHANGED_FILE_STATUS.ADDED && + imageTypeOfPath(file.previousPath ?? file.path) !== undefined + ); +} + +/** + * The commits the files' changed images are read at: the pull request's + * head, and, only where an image stood before them, where its head and its + * base branch part, the merge base GitHub counts the files' changes from, + * read off one compare of the two asking for a single commit and file, + * since GitHub pages the rest. Note that the base branch's head is never + * answered in the merge base's place, because a base branch that changed + * the same image since would draw a before the pull request never had; a + * compare that does not land answers no merge base, and the window draws + * the after alone. + */ +export const imageCommits = /* @__PURE__ */ Effect.fn("web/imageCommits")(function* ( + token: Redacted.Redacted, + fullName: string, + pullRequest: Option.Option>, + files: readonly CodingAgentChangedFile[], +): Effect.fn.Return { + if (Option.isNone(pullRequest)) return {}; + const { headSha, baseSha } = pullRequest.value; + if (baseSha === undefined || !files.some(imageWithBefore)) return { headSha }; + const request = githubRead( + `${repositoryPath(fullName)}/compare/${segment(baseSha)}...${segment(headSha)}`, + token, + ).pipe(HttpClientRequest.setUrlParams({ per_page: "1" })); + const compared = yield* readOptional(request, CompareSchema).pipe( + Effect.catch((failure) => + Effect.as( + Effect.logWarning(`a pull request's merge base could not be read: ${failure._tag}`), + Option.none(), + ), + ), + ); + return Option.match(compared, { + onNone: () => ({ headSha }), + onSome: (compare) => ({ headSha, baseSha: compare.merge_base_commit.sha }), + }); +}); + /** A GitHub account as the people a pull request names hold it: GitHub's id, its login, whether it is an App's bot, and its avatar where GitHub's avatar host serves it. */ export interface GitHubAccount { readonly id: number;