mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
fix(session-ui): retry local Markdown images and stop leaking gutter comment buttons (#61972)
This commit is contained in:
committed by
GitHub
Unverified
parent
54dbbddc0c
commit
c7ee32ba4e
@@ -0,0 +1,81 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator"
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import type { SelectedLineRange } from "@pierre/diffs"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { HoverCommentLine } from "../pierre/comment-hover"
|
||||
import { createLineCommentGutterRenderer } from "./line-comment-annotations"
|
||||
|
||||
beforeAll(() => GlobalRegistrator.register())
|
||||
|
||||
afterAll(() => GlobalRegistrator.unregister())
|
||||
|
||||
/** What a Pierre instance reports as hovered, and how often the button asked. */
|
||||
type Viewer = { hovered: HoverCommentLine | undefined; reads: number }
|
||||
|
||||
function setup() {
|
||||
const opened: SelectedLineRange[] = []
|
||||
const viewer: Viewer = { hovered: undefined, reads: 0 }
|
||||
|
||||
const getHoveredLine = () => {
|
||||
viewer.reads++
|
||||
|
||||
return viewer.hovered
|
||||
}
|
||||
|
||||
return createRoot((dispose) => ({
|
||||
opened,
|
||||
viewer,
|
||||
getHoveredLine,
|
||||
dispose,
|
||||
render: createLineCommentGutterRenderer({
|
||||
label: "Comment",
|
||||
getSelectedRange: () => null,
|
||||
onOpenDraft: (range) => opened.push(range),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
test("tracks the hovered line when the pointer moves before Pierre inserts the button", () => {
|
||||
const gutter = setup()
|
||||
const button = gutter.render(gutter.getHoveredLine)!
|
||||
document.dispatchEvent(new Event("pointermove"))
|
||||
document.dispatchEvent(new Event("scroll"))
|
||||
document.body.replaceChildren(button)
|
||||
|
||||
gutter.viewer.hovered = { lineNumber: 5, side: "additions" }
|
||||
document.dispatchEvent(new Event("pointermove"))
|
||||
gutter.viewer.hovered = undefined
|
||||
button.click()
|
||||
|
||||
expect(gutter.opened).toEqual([{ start: 5, end: 5, side: "additions" }])
|
||||
gutter.dispose()
|
||||
})
|
||||
|
||||
test("reuses one button across Pierre rerenders and detaches it on cleanup", () => {
|
||||
const gutter = setup()
|
||||
const button = gutter.render(gutter.getHoveredLine)!
|
||||
document.body.replaceChildren(button)
|
||||
|
||||
expect(gutter.render(gutter.getHoveredLine)).toBe(button)
|
||||
|
||||
// A reset viewer is a new Pierre instance with its own `getHoveredLine`.
|
||||
const next = { reads: 0 }
|
||||
|
||||
const replaced = gutter.render(() => {
|
||||
next.reads++
|
||||
|
||||
return undefined
|
||||
})!
|
||||
|
||||
expect(replaced).not.toBe(button)
|
||||
const reads = gutter.viewer.reads
|
||||
document.dispatchEvent(new Event("pointermove"))
|
||||
expect(gutter.viewer.reads).toBe(reads)
|
||||
|
||||
document.body.replaceChildren(replaced)
|
||||
document.dispatchEvent(new Event("pointermove"))
|
||||
expect(next.reads).toBe(1)
|
||||
gutter.dispose()
|
||||
document.dispatchEvent(new Event("pointermove"))
|
||||
expect(next.reads).toBe(1)
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, untrack, type Accessor, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { createHoverComment, type HoverComment, type HoverCommentLine } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
import {
|
||||
@@ -37,11 +37,6 @@ type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
/** A line-comment mode owned by the caller, such as one file's entry in a multi-file review. Omit it to keep the mode inside the controller. */
|
||||
export type LineCommentMachine = {
|
||||
mode: Accessor<LineCommentMode>
|
||||
@@ -538,8 +533,17 @@ export function createLineCommentGutterRenderer(props: {
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
}) {
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) =>
|
||||
createHoverCommentUtility({
|
||||
let active: { comment: HoverComment; getHoveredLine: () => HoverCommentLine | undefined } | undefined
|
||||
|
||||
onCleanup(() => active?.comment.dispose())
|
||||
|
||||
// Pierre calls this on every render and rerender but inserts the result only when it holds no button yet, so hand
|
||||
// back the same button for the same viewer instead of building one with fresh document listeners each time.
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) => {
|
||||
if (active?.getHoveredLine === getHoveredLine && active.comment.canReuse()) return active.comment.button
|
||||
active?.comment.dispose()
|
||||
|
||||
const comment = createHoverComment({
|
||||
label: props.label,
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
@@ -560,4 +564,9 @@ export function createLineCommentGutterRenderer(props: {
|
||||
props.onOpenDraft(range)
|
||||
},
|
||||
})
|
||||
|
||||
active = comment && { comment, getHoveredLine }
|
||||
|
||||
return comment?.button
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DISPOSED_MARKDOWN_IMAGES_STATE,
|
||||
IMAGE_READ_TIMEOUT_MS,
|
||||
INITIAL_MARKDOWN_IMAGES_STATE,
|
||||
NO_MARKDOWN_IMAGE_EFFECTS,
|
||||
selectMarkdownImageUrl,
|
||||
transitionMarkdownImages,
|
||||
type MarkdownImagesEffect,
|
||||
type MarkdownImagesEvent,
|
||||
type MarkdownImagesState,
|
||||
} from "./markdown-image-machine"
|
||||
|
||||
const synced = (...paths: string[]): MarkdownImagesEvent => ({ type: "paths.synced", paths: new Set(paths) })
|
||||
|
||||
const step = (state: MarkdownImagesState, event: MarkdownImagesEvent) => transitionMarkdownImages(state, event)
|
||||
|
||||
const read = (path: string, attempt: number): MarkdownImagesEffect => ({
|
||||
type: "read",
|
||||
path,
|
||||
attempt,
|
||||
timeoutMs: IMAGE_READ_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
test("retries a failed read after a backoff and loads, while streaming updates change nothing", () => {
|
||||
const loading = step(INITIAL_MARKDOWN_IMAGES_STATE, synced("chart.png"))
|
||||
expect(loading.effects).toEqual([read("chart.png", 1)])
|
||||
|
||||
const waiting = step(loading.state, { type: "read.failed", path: "chart.png", attempt: 1 })
|
||||
expect(waiting.effects).toEqual([
|
||||
{ type: "cancel", attempt: 1 },
|
||||
{ type: "wait", path: "chart.png", attempt: 2, delayMs: 1000 },
|
||||
])
|
||||
|
||||
const streamed = step(waiting.state, synced("chart.png"))
|
||||
expect(streamed.state).toBe(waiting.state)
|
||||
expect(streamed.effects).toBe(NO_MARKDOWN_IMAGE_EFFECTS)
|
||||
|
||||
const retried = step(waiting.state, { type: "retry.due", path: "chart.png", attempt: 2 })
|
||||
expect(retried.effects).toEqual([{ type: "cancel", attempt: 2 }, read("chart.png", 3)])
|
||||
|
||||
const loaded = step(retried.state, {
|
||||
type: "read.succeeded",
|
||||
path: "chart.png",
|
||||
attempt: 3,
|
||||
url: "blob:1",
|
||||
revoke: true,
|
||||
})
|
||||
|
||||
expect(loaded.effects).toEqual([{ type: "cancel", attempt: 3 }])
|
||||
expect(selectMarkdownImageUrl(loaded.state, "chart.png")).toBe("blob:1")
|
||||
})
|
||||
|
||||
test("times out a hung read and gives up after the last retry", () => {
|
||||
const timeOut = (state: MarkdownImagesState, attempt: number) =>
|
||||
step(state, { type: "read.timed-out", path: "chart.png", attempt })
|
||||
|
||||
const retry = (state: MarkdownImagesState, attempt: number) =>
|
||||
step(state, { type: "retry.due", path: "chart.png", attempt }).state
|
||||
|
||||
const wait = (attempt: number, delayMs: number): MarkdownImagesEffect => ({
|
||||
type: "wait",
|
||||
path: "chart.png",
|
||||
attempt,
|
||||
delayMs,
|
||||
})
|
||||
|
||||
const first = timeOut(step(INITIAL_MARKDOWN_IMAGES_STATE, synced("chart.png")).state, 1)
|
||||
expect(first.effects).toEqual([{ type: "cancel", attempt: 1 }, wait(2, 1000)])
|
||||
const second = timeOut(retry(first.state, 2), 3)
|
||||
expect(second.effects).toEqual([{ type: "cancel", attempt: 3 }, wait(4, 2000)])
|
||||
const third = timeOut(retry(second.state, 4), 5)
|
||||
expect(third.effects).toEqual([{ type: "cancel", attempt: 5 }, wait(6, 4000)])
|
||||
|
||||
const failed = timeOut(retry(third.state, 6), 7)
|
||||
expect(failed.effects).toEqual([{ type: "cancel", attempt: 7 }])
|
||||
expect(failed.state.status === "active" && failed.state.entries.get("chart.png")).toEqual({ status: "failed" })
|
||||
})
|
||||
|
||||
test("settles an empty read as failed without a retry", () => {
|
||||
const loading = step(INITIAL_MARKDOWN_IMAGES_STATE, synced("notes.txt"))
|
||||
const failed = step(loading.state, { type: "read.empty", path: "notes.txt", attempt: 1 })
|
||||
|
||||
expect(failed.effects).toEqual([{ type: "cancel", attempt: 1 }])
|
||||
expect(failed.state.status === "active" && failed.state.entries.get("notes.txt")).toEqual({ status: "failed" })
|
||||
})
|
||||
|
||||
test("ignores late results and revokes their blob URLs", () => {
|
||||
const loading = step(INITIAL_MARKDOWN_IMAGES_STATE, synced("chart.png")).state
|
||||
|
||||
const late = (state: MarkdownImagesState, revoke: boolean) =>
|
||||
step(state, { type: "read.succeeded", path: "chart.png", attempt: 1, url: "blob:stale", revoke })
|
||||
|
||||
const timedOut = step(loading, { type: "read.timed-out", path: "chart.png", attempt: 1 }).state
|
||||
const removed = step(loading, synced()).state
|
||||
const disposed = step(loading, { type: "disposed" }).state
|
||||
const moved = [timedOut, removed, disposed]
|
||||
moved.forEach((state) => {
|
||||
const result = late(state, true)
|
||||
expect(result.state).toBe(state)
|
||||
expect(result.effects).toEqual([{ type: "revoke-url", url: "blob:stale" }])
|
||||
expect(late(state, false).effects).toBe(NO_MARKDOWN_IMAGE_EFFECTS)
|
||||
})
|
||||
|
||||
const stale = step(timedOut, { type: "read.failed", path: "chart.png", attempt: 1 })
|
||||
expect(stale.state).toBe(timedOut)
|
||||
expect(step(timedOut, { type: "retry.due", path: "chart.png", attempt: 1 }).state).toBe(timedOut)
|
||||
})
|
||||
|
||||
test("cancels and revokes removed entries and everything on dispose", () => {
|
||||
const started = step(INITIAL_MARKDOWN_IMAGES_STATE, synced("a.png", "b.png", "c.png", "d.png")).state
|
||||
|
||||
const events: MarkdownImagesEvent[] = [
|
||||
{ type: "read.failed", path: "b.png", attempt: 2 },
|
||||
{ type: "read.succeeded", path: "c.png", attempt: 3, url: "blob:c", revoke: true },
|
||||
{ type: "read.succeeded", path: "d.png", attempt: 4, url: "data:d", revoke: false },
|
||||
]
|
||||
|
||||
const settled = events.reduce((state, event) => step(state, event).state, started)
|
||||
|
||||
const removed = step(settled, synced("a.png"))
|
||||
expect(removed.effects).toEqual([
|
||||
{ type: "cancel", attempt: 5 },
|
||||
{ type: "revoke-url", url: "blob:c" },
|
||||
])
|
||||
expect(removed.state.status === "active" && [...removed.state.entries.keys()]).toEqual(["a.png"])
|
||||
|
||||
const disposed = step(settled, { type: "disposed" })
|
||||
expect(disposed.state).toBe(DISPOSED_MARKDOWN_IMAGES_STATE)
|
||||
expect(disposed.effects).toEqual([
|
||||
{ type: "cancel", attempt: 1 },
|
||||
{ type: "cancel", attempt: 5 },
|
||||
{ type: "revoke-url", url: "blob:c" },
|
||||
])
|
||||
expect(step(disposed.state, synced("a.png")).effects).toBe(NO_MARKDOWN_IMAGE_EFFECTS)
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
/** A read that has not settled after this long is aborted and retried, so a stalled request cannot hang the image. */
|
||||
export const IMAGE_READ_TIMEOUT_MS = 15_000
|
||||
|
||||
/** The first retry waits this long; each later retry doubles it. */
|
||||
export const IMAGE_RETRY_BASE_MS = 1_000
|
||||
|
||||
export const IMAGE_MAX_RETRIES = 3
|
||||
|
||||
/**
|
||||
* One local Markdown image path while it is in the container.
|
||||
* - `loading`: a read is in flight.
|
||||
* - `waiting`: a read failed or timed out and the next one is backing off.
|
||||
* - `loaded`: resolved to an image `src`; `revoke` marks a `blob:` URL the machine must release (an SVG `data:` URL
|
||||
* needs no release).
|
||||
* - `failed`: the reader has nothing to show (`undefined`) or every retry failed; the image keeps its alt text.
|
||||
*/
|
||||
export type MarkdownImageEntry =
|
||||
| { readonly status: "loading"; readonly attempt: number; readonly failures: number }
|
||||
| { readonly status: "waiting"; readonly attempt: number; readonly failures: number }
|
||||
| { readonly status: "loaded"; readonly url: string; readonly revoke: boolean }
|
||||
| { readonly status: "failed" }
|
||||
|
||||
export type MarkdownImagesState =
|
||||
| {
|
||||
readonly status: "active"
|
||||
/** Last attempt id handed out. Each read and each backoff gets a new one, so late results are recognised. */
|
||||
readonly attempts: number
|
||||
readonly entries: ReadonlyMap<string, MarkdownImageEntry>
|
||||
}
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
export type MarkdownImagesEvent =
|
||||
| { readonly type: "paths.synced"; readonly paths: ReadonlySet<string> }
|
||||
| {
|
||||
readonly type: "read.succeeded"
|
||||
readonly path: string
|
||||
readonly attempt: number
|
||||
readonly url: string
|
||||
readonly revoke: boolean
|
||||
}
|
||||
| { readonly type: "read.empty"; readonly path: string; readonly attempt: number }
|
||||
| { readonly type: "read.failed"; readonly path: string; readonly attempt: number }
|
||||
| { readonly type: "read.timed-out"; readonly path: string; readonly attempt: number }
|
||||
| { readonly type: "retry.due"; readonly path: string; readonly attempt: number }
|
||||
| { readonly type: "disposed" }
|
||||
|
||||
export type MarkdownImagesEffect =
|
||||
| { readonly type: "read"; readonly path: string; readonly attempt: number; readonly timeoutMs: number }
|
||||
| { readonly type: "wait"; readonly path: string; readonly attempt: number; readonly delayMs: number }
|
||||
| { readonly type: "cancel"; readonly attempt: number }
|
||||
| { readonly type: "revoke-url"; readonly url: string }
|
||||
|
||||
export type MarkdownImagesTransition = {
|
||||
readonly state: MarkdownImagesState
|
||||
readonly effects: readonly MarkdownImagesEffect[]
|
||||
}
|
||||
|
||||
export const INITIAL_MARKDOWN_IMAGES_STATE: MarkdownImagesState = Object.freeze({
|
||||
status: "active",
|
||||
attempts: 0,
|
||||
entries: new Map(),
|
||||
})
|
||||
|
||||
export const DISPOSED_MARKDOWN_IMAGES_STATE: MarkdownImagesState = Object.freeze({ status: "disposed" })
|
||||
|
||||
export const NO_MARKDOWN_IMAGE_EFFECTS: readonly MarkdownImagesEffect[] = Object.freeze([])
|
||||
|
||||
export function transitionMarkdownImages(
|
||||
state: MarkdownImagesState,
|
||||
event: MarkdownImagesEvent,
|
||||
): MarkdownImagesTransition {
|
||||
if (state.status === "disposed") return ignore(state, event)
|
||||
|
||||
if (event.type === "disposed")
|
||||
return { state: DISPOSED_MARKDOWN_IMAGES_STATE, effects: [...state.entries.values()].flatMap(release) }
|
||||
|
||||
if (event.type === "paths.synced") return sync(state, event.paths)
|
||||
|
||||
if (event.type === "retry.due") {
|
||||
const entry = state.entries.get(event.path)
|
||||
|
||||
if (entry?.status !== "waiting" || entry.attempt !== event.attempt) return ignore(state, event)
|
||||
const attempt = state.attempts + 1
|
||||
|
||||
return {
|
||||
state: withEntry(state, event.path, { status: "loading", attempt, failures: entry.failures }, attempt),
|
||||
effects: [
|
||||
{ type: "cancel", attempt: entry.attempt },
|
||||
{ type: "read", path: event.path, attempt, timeoutMs: IMAGE_READ_TIMEOUT_MS },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const entry = state.entries.get(event.path)
|
||||
|
||||
if (entry?.status !== "loading" || entry.attempt !== event.attempt) return ignore(state, event)
|
||||
const cancel = { type: "cancel", attempt: entry.attempt } as const
|
||||
|
||||
if (event.type === "read.succeeded")
|
||||
return {
|
||||
state: withEntry(state, event.path, { status: "loaded", url: event.url, revoke: event.revoke }, state.attempts),
|
||||
effects: [cancel],
|
||||
}
|
||||
|
||||
if (event.type === "read.empty" || entry.failures >= IMAGE_MAX_RETRIES)
|
||||
return { state: withEntry(state, event.path, { status: "failed" }, state.attempts), effects: [cancel] }
|
||||
const attempt = state.attempts + 1
|
||||
const failures = entry.failures + 1
|
||||
|
||||
return {
|
||||
state: withEntry(state, event.path, { status: "waiting", attempt, failures }, attempt),
|
||||
effects: [cancel, { type: "wait", path: event.path, attempt, delayMs: IMAGE_RETRY_BASE_MS * 2 ** entry.failures }],
|
||||
}
|
||||
}
|
||||
|
||||
export function selectMarkdownImageUrl(state: MarkdownImagesState, path: string) {
|
||||
if (state.status === "disposed") return
|
||||
const entry = state.entries.get(path)
|
||||
|
||||
return entry?.status === "loaded" ? entry.url : undefined
|
||||
}
|
||||
|
||||
function sync(
|
||||
state: Extract<MarkdownImagesState, { status: "active" }>,
|
||||
paths: ReadonlySet<string>,
|
||||
): MarkdownImagesTransition {
|
||||
const removed = [...state.entries].filter(([path]) => !paths.has(path))
|
||||
const added = [...paths].filter((path) => path && !state.entries.has(path))
|
||||
|
||||
if (removed.length === 0 && added.length === 0) return { state, effects: NO_MARKDOWN_IMAGE_EFFECTS }
|
||||
const entries = new Map(state.entries)
|
||||
removed.forEach(([path]) => entries.delete(path))
|
||||
|
||||
const reads = added.map((path, index) => {
|
||||
const attempt = state.attempts + index + 1
|
||||
entries.set(path, { status: "loading", attempt, failures: 0 })
|
||||
|
||||
return { type: "read", path, attempt, timeoutMs: IMAGE_READ_TIMEOUT_MS } as const
|
||||
})
|
||||
|
||||
return {
|
||||
state: { status: "active", attempts: state.attempts + added.length, entries },
|
||||
effects: [...removed.flatMap(([, entry]) => release(entry)), ...reads],
|
||||
}
|
||||
}
|
||||
|
||||
function release(entry: MarkdownImageEntry): MarkdownImagesEffect[] {
|
||||
if (entry.status === "loading" || entry.status === "waiting") return [{ type: "cancel", attempt: entry.attempt }]
|
||||
|
||||
if (entry.status === "loaded" && entry.revoke) return [{ type: "revoke-url", url: entry.url }]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function withEntry(
|
||||
state: Extract<MarkdownImagesState, { status: "active" }>,
|
||||
path: string,
|
||||
entry: MarkdownImageEntry,
|
||||
attempts: number,
|
||||
): MarkdownImagesState {
|
||||
return { status: "active", attempts, entries: new Map(state.entries).set(path, entry) }
|
||||
}
|
||||
|
||||
// A reader that ignores its abort signal can still hand back a blob URL after the entry moved on; release it.
|
||||
function ignore(state: MarkdownImagesState, event: MarkdownImagesEvent): MarkdownImagesTransition {
|
||||
if (event.type === "read.succeeded" && event.revoke)
|
||||
return { state, effects: [{ type: "revoke-url", url: event.url }] }
|
||||
|
||||
return { state, effects: NO_MARKDOWN_IMAGE_EFFECTS }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { localImagePath, localLinkPath } from "./markdown-image"
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator"
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from "bun:test"
|
||||
import { createMarkdownImages, localImagePath, localLinkPath } from "./markdown-image"
|
||||
|
||||
test.each([
|
||||
["./out/report.html", "./out/report.html"],
|
||||
@@ -58,3 +59,50 @@ test.each([
|
||||
])("does not read non-local or invalid source %s", (source) => {
|
||||
expect(localImagePath(source)).toBeUndefined()
|
||||
})
|
||||
|
||||
describe("createMarkdownImages", () => {
|
||||
// Happy DOM's fetch cannot read `blob:` URLs; Bun's can, and fails once the URL is revoked.
|
||||
const nativeFetch = fetch
|
||||
|
||||
// Lets the read's promise chain settle; fake timers do not drive microtasks.
|
||||
const settle = (ticks = 10): Promise<void> =>
|
||||
ticks === 0 ? Promise.resolve() : Promise.resolve().then(() => settle(ticks - 1))
|
||||
|
||||
beforeAll(() => GlobalRegistrator.register())
|
||||
|
||||
afterAll(() => GlobalRegistrator.unregister())
|
||||
|
||||
afterEach(() => jest.useRealTimers())
|
||||
|
||||
test("retries a failed read and shows the image without another update", async () => {
|
||||
jest.useFakeTimers()
|
||||
const reads: string[] = []
|
||||
|
||||
const images = createMarkdownImages(async (path) => {
|
||||
reads.push(path)
|
||||
|
||||
if (reads.length === 1) throw new Error("not written yet")
|
||||
|
||||
return new Blob(["png"], { type: "image/png" })
|
||||
})
|
||||
|
||||
const root = document.createElement("div")
|
||||
root.innerHTML = '<img data-local-image="./out/chart.png" alt="chart">'
|
||||
document.body.replaceChildren(root)
|
||||
const image = root.querySelector("img")!
|
||||
|
||||
images.update(root)
|
||||
await settle()
|
||||
expect(image.getAttribute("src")).toBeNull()
|
||||
|
||||
jest.advanceTimersToNextTimer()
|
||||
await settle()
|
||||
const url = image.getAttribute("src")
|
||||
expect(reads).toEqual(["./out/chart.png", "./out/chart.png"])
|
||||
expect(url).toStartWith("blob:")
|
||||
expect(await nativeFetch(url!).then((response) => response.text())).toBe("png")
|
||||
|
||||
images.dispose()
|
||||
await expect(nativeFetch(url!)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { abort } from "@opencode/util/abort"
|
||||
import { isLineRangeHash, parsePathLineSuffix } from "@opencode/util/path"
|
||||
import type { ReadMarkdownImage } from "../context/markdown"
|
||||
import {
|
||||
INITIAL_MARKDOWN_IMAGES_STATE,
|
||||
selectMarkdownImageUrl,
|
||||
transitionMarkdownImages,
|
||||
type MarkdownImagesEffect,
|
||||
type MarkdownImagesEvent,
|
||||
} from "./markdown-image-machine"
|
||||
|
||||
export function localImagePath(source: string) {
|
||||
// Marked percent-encodes image sources, so `C:\tmp\chart.png` arrives as `C:%5Ctmp%5Cchart.png`.
|
||||
@@ -63,63 +70,87 @@ function decodePath(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type ImageEntry = { controller: AbortController; result: Promise<string | undefined>; url?: string }
|
||||
|
||||
export function createMarkdownImages(read: ReadMarkdownImage) {
|
||||
const entries = new Map<string, ImageEntry>()
|
||||
let state = INITIAL_MARKDOWN_IMAGES_STATE
|
||||
let root: HTMLElement | undefined
|
||||
const pending = new Map<number, { controller?: AbortController; timer: ReturnType<typeof setTimeout> }>()
|
||||
|
||||
const images = (container: HTMLElement) =>
|
||||
Array.from(container.querySelectorAll<HTMLImageElement>("img[data-local-image]"))
|
||||
|
||||
const syncDom = (container: HTMLElement) =>
|
||||
images(container).forEach((image) => {
|
||||
const url = selectMarkdownImageUrl(state, image.dataset.localImage ?? "")
|
||||
|
||||
if (url && image.getAttribute("src") !== url) image.src = url
|
||||
})
|
||||
|
||||
const dispatch = (event: MarkdownImagesEvent) => {
|
||||
const next = transitionMarkdownImages(state, event)
|
||||
const changed = next.state !== state
|
||||
state = next.state
|
||||
|
||||
if (changed && root) syncDom(root)
|
||||
next.effects.forEach(run)
|
||||
}
|
||||
|
||||
const run = (effect: MarkdownImagesEffect) => {
|
||||
if (effect.type === "revoke-url") return URL.revokeObjectURL(effect.url)
|
||||
|
||||
if (effect.type === "cancel") {
|
||||
const item = pending.get(effect.attempt)
|
||||
pending.delete(effect.attempt)
|
||||
|
||||
if (!item) return
|
||||
clearTimeout(item.timer)
|
||||
abort(item.controller)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "wait") {
|
||||
const retry = { type: "retry.due", path: effect.path, attempt: effect.attempt } as const
|
||||
pending.set(effect.attempt, { timer: setTimeout(() => dispatch(retry), effect.delayMs) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const result = { path: effect.path, attempt: effect.attempt }
|
||||
const timer = setTimeout(() => dispatch({ type: "read.timed-out", ...result }), effect.timeoutMs)
|
||||
pending.set(effect.attempt, { controller, timer })
|
||||
void read(effect.path, controller.signal)
|
||||
.then(async (blob) => {
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (!blob) return dispatch({ type: "read.empty", ...result })
|
||||
|
||||
// SVG documents must not inherit the app origin if opened outside the image element.
|
||||
if (blob.type === "image/svg+xml") {
|
||||
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}`
|
||||
|
||||
return dispatch({ type: "read.succeeded", ...result, url, revoke: false })
|
||||
}
|
||||
|
||||
dispatch({ type: "read.succeeded", ...result, url: URL.createObjectURL(blob), revoke: true })
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return
|
||||
dispatch({ type: "read.failed", ...result })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
update(root: HTMLElement) {
|
||||
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img[data-local-image]"))
|
||||
const paths = new Set(images.map((image) => image.dataset.localImage))
|
||||
entries.forEach((entry, path) => {
|
||||
if (paths.has(path)) return
|
||||
abort(entry.controller)
|
||||
|
||||
if (entry.url) URL.revokeObjectURL(entry.url)
|
||||
entries.delete(path)
|
||||
})
|
||||
images.forEach((image) => {
|
||||
const path = image.dataset.localImage
|
||||
|
||||
if (!path) return
|
||||
const existing = entries.get(path)
|
||||
|
||||
const entry: ImageEntry = existing ?? {
|
||||
controller: new AbortController(),
|
||||
result: Promise.resolve(undefined),
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
entries.set(path, entry)
|
||||
entry.result = read(path, entry.controller.signal)
|
||||
.then(async (blob) => {
|
||||
if (!blob || entry.controller.signal.aborted) return
|
||||
|
||||
// SVG documents must not inherit the app origin if opened outside the image element.
|
||||
if (blob.type === "image/svg+xml")
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}`
|
||||
entry.url = URL.createObjectURL(blob)
|
||||
|
||||
return entry.url
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
void entry.result.then((url) => {
|
||||
if (!url || entry.controller.signal.aborted || !root.contains(image) || image.dataset.localImage !== path)
|
||||
return
|
||||
image.src = url
|
||||
})
|
||||
})
|
||||
update(container: HTMLElement) {
|
||||
root = container
|
||||
const paths = new Set(images(container).flatMap((image) => image.dataset.localImage || []))
|
||||
dispatch({ type: "paths.synced", paths })
|
||||
// Newly inserted images of an already loaded path get their `src` before paint.
|
||||
syncDom(container)
|
||||
},
|
||||
dispose() {
|
||||
entries.forEach((entry) => {
|
||||
abort(entry.controller)
|
||||
|
||||
if (entry.url) URL.revokeObjectURL(entry.url)
|
||||
})
|
||||
entries.clear()
|
||||
root = undefined
|
||||
dispatch({ type: "disposed" })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DISPOSED_HOVER_COMMENT_STATE,
|
||||
INITIAL_HOVER_COMMENT_STATE,
|
||||
selectHoverActive,
|
||||
selectHoverLine,
|
||||
transitionHoverComment,
|
||||
type HoverCommentEvent,
|
||||
type HoverCommentSide,
|
||||
} from "./comment-hover-machine"
|
||||
|
||||
const hover = (lineNumber?: number, side?: HoverCommentSide): HoverCommentEvent => ({
|
||||
type: "hover.synced",
|
||||
lineNumber,
|
||||
side,
|
||||
})
|
||||
|
||||
test("keeps the last hovered line after the pointer leaves the code", () => {
|
||||
const hovered = transitionHoverComment(INITIAL_HOVER_COMMENT_STATE, hover(12, "additions"))
|
||||
const left = transitionHoverComment(hovered, hover())
|
||||
|
||||
expect(left).toBe(hovered)
|
||||
expect(selectHoverLine(left)).toEqual({ lineNumber: 12, side: "additions" })
|
||||
expect(selectHoverLine(transitionHoverComment(left, hover(12, "deletions")))).toEqual({
|
||||
lineNumber: 12,
|
||||
side: "deletions",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns the same state when the hovered line does not change", () => {
|
||||
const hovered = transitionHoverComment(INITIAL_HOVER_COMMENT_STATE, hover(7, "deletions"))
|
||||
|
||||
expect(transitionHoverComment(INITIAL_HOVER_COMMENT_STATE, hover())).toBe(INITIAL_HOVER_COMMENT_STATE)
|
||||
expect(transitionHoverComment(hovered, hover(7, "deletions"))).toBe(hovered)
|
||||
expect(transitionHoverComment(DISPOSED_HOVER_COMMENT_STATE, hover(7))).toBe(DISPOSED_HOVER_COMMENT_STATE)
|
||||
})
|
||||
|
||||
test("stops listening and forgets the line once disposed", () => {
|
||||
const disposed = transitionHoverComment(transitionHoverComment(INITIAL_HOVER_COMMENT_STATE, hover(3)), {
|
||||
type: "disposed",
|
||||
})
|
||||
|
||||
expect(disposed).toBe(DISPOSED_HOVER_COMMENT_STATE)
|
||||
expect(selectHoverActive(disposed)).toBe(false)
|
||||
expect(selectHoverLine(disposed)).toBeUndefined()
|
||||
expect(transitionHoverComment(disposed, { type: "disposed" })).toBe(disposed)
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
export type HoverCommentSide = "additions" | "deletions"
|
||||
|
||||
export type HoverCommentLine = {
|
||||
readonly lineNumber: number
|
||||
readonly side?: HoverCommentSide
|
||||
}
|
||||
|
||||
/**
|
||||
* One gutter comment `+` button. Runs on every pointer move and scroll, so it returns the same object when nothing
|
||||
* changes and reads its events from one reused object.
|
||||
* - `tracking`: listens to the document and caches the last hovered line, so a click still has one after the pointer
|
||||
* leaves the code. Leaving the document does not end it: Pierre inserts the button after creating it, and may detach
|
||||
* and reattach the viewer.
|
||||
* - `disposed`: its owner replaced or unmounted it; the document listeners are gone.
|
||||
*/
|
||||
export type HoverCommentState =
|
||||
| { readonly status: "tracking"; readonly line: HoverCommentLine | undefined }
|
||||
| { readonly status: "disposed" }
|
||||
|
||||
/** The line Pierre reports as hovered. Mutable so the driver can reuse one object for every pointer move. */
|
||||
export type HoverCommentSync = {
|
||||
type: "hover.synced"
|
||||
lineNumber: number | undefined
|
||||
side: HoverCommentSide | undefined
|
||||
}
|
||||
|
||||
export type HoverCommentEvent = Readonly<HoverCommentSync> | { readonly type: "disposed" }
|
||||
|
||||
export const INITIAL_HOVER_COMMENT_STATE: HoverCommentState = Object.freeze({ status: "tracking", line: undefined })
|
||||
|
||||
export const DISPOSED_HOVER_COMMENT_STATE: HoverCommentState = Object.freeze({ status: "disposed" })
|
||||
|
||||
export function transitionHoverComment(state: HoverCommentState, event: HoverCommentEvent): HoverCommentState {
|
||||
if (state.status === "disposed") return state
|
||||
|
||||
if (event.type === "disposed") return DISPOSED_HOVER_COMMENT_STATE
|
||||
const lineNumber = event.lineNumber
|
||||
|
||||
// No hovered line (the pointer left the code) keeps the cached one.
|
||||
if (lineNumber === undefined || (lineNumber === state.line?.lineNumber && event.side === state.line?.side))
|
||||
return state
|
||||
|
||||
return { status: "tracking", line: { lineNumber, side: event.side } }
|
||||
}
|
||||
|
||||
/** Whether the button still listens to the document, and so whether a render may hand it back again. */
|
||||
export function selectHoverActive(state: HoverCommentState) {
|
||||
return state.status !== "disposed"
|
||||
}
|
||||
|
||||
export function selectHoverLine(state: HoverCommentState) {
|
||||
return state.status === "disposed" ? undefined : state.line
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
import {
|
||||
INITIAL_HOVER_COMMENT_STATE,
|
||||
selectHoverActive,
|
||||
selectHoverLine,
|
||||
transitionHoverComment,
|
||||
type HoverCommentEvent,
|
||||
type HoverCommentLine,
|
||||
type HoverCommentSync,
|
||||
} from "./comment-hover-machine"
|
||||
|
||||
export type { HoverCommentLine }
|
||||
|
||||
/** A gutter `+` button and the hover tracking behind it. Dispose it when its viewer stops rendering it. */
|
||||
export type HoverComment = {
|
||||
button: HTMLButtonElement
|
||||
dispose: () => void
|
||||
/** Whether the next render may return this button instead of building another. */
|
||||
canReuse: () => boolean
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
export function createHoverComment(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
}): HoverComment | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
@@ -42,40 +57,35 @@ export function createHoverCommentUtility(props: {
|
||||
button.style.left = "30px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
let state = INITIAL_HOVER_COMMENT_STATE
|
||||
// Reused for every pointer move and scroll so tracking allocates nothing.
|
||||
const syncEvent: HoverCommentSync = { type: "hover.synced", lineNumber: undefined, side: undefined }
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
const dispatch = (event: HoverCommentEvent) => {
|
||||
const next = transitionHoverComment(state, event)
|
||||
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
if (next === state) return
|
||||
const active = selectHoverActive(state)
|
||||
state = next
|
||||
|
||||
// The hovered line changes when the pointer moves or when content scrolls under
|
||||
// a stationary pointer, so track both with passive listeners instead of polling
|
||||
// every animation frame. There is no teardown hook for this utility; like the
|
||||
// rAF loop this replaced, the listeners self-detach on the first event after
|
||||
// the button leaves the DOM.
|
||||
const onHoverInvalidated = () => {
|
||||
if (!button.isConnected) {
|
||||
document.removeEventListener("pointermove", onHoverInvalidated)
|
||||
document.removeEventListener("scroll", onHoverInvalidated, true)
|
||||
|
||||
return
|
||||
if (active && !selectHoverActive(state)) {
|
||||
document.removeEventListener("pointermove", sync)
|
||||
document.removeEventListener("scroll", sync, true)
|
||||
}
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
// The hovered line changes when the pointer moves or when content scrolls under a stationary pointer, so track both
|
||||
// with passive listeners instead of polling every animation frame.
|
||||
const sync = () => {
|
||||
if (!button.isConnected) return
|
||||
const hovered = props.getHoveredLine()
|
||||
syncEvent.lineNumber = hovered?.lineNumber
|
||||
syncEvent.side = hovered?.side
|
||||
dispatch(syncEvent)
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
|
||||
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
|
||||
document.addEventListener("pointermove", sync, { passive: true })
|
||||
document.addEventListener("scroll", sync, { passive: true, capture: true })
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
@@ -91,8 +101,15 @@ export function createHoverCommentUtility(props: {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
sync()
|
||||
const line = selectHoverLine(state)
|
||||
|
||||
if (line) props.onSelect(line)
|
||||
})
|
||||
|
||||
return button
|
||||
return {
|
||||
button,
|
||||
dispose: () => dispatch({ type: "disposed" }),
|
||||
canReuse: () => selectHoverActive(state),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user