mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(app): preview timeline images in attachment overlay (#49111)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Markdown } from "../src/components/markdown"
|
||||
import { preloadMarkdown } from "../src/components/markdown-cache"
|
||||
import { MarkdownProvider } from "../src/context/markdown"
|
||||
@@ -35,7 +36,7 @@ export async function mountMarkdown(options: {
|
||||
const [streaming, setStreaming] = createSignal(options.streaming ?? false)
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
return (
|
||||
<>
|
||||
<DialogProvider>
|
||||
<textarea aria-label="Markdown text" value={text()} onInput={(event) => setText(event.currentTarget.value)} />
|
||||
<input
|
||||
aria-label="Streaming"
|
||||
@@ -58,7 +59,7 @@ export async function mountMarkdown(options: {
|
||||
/>
|
||||
</Show>
|
||||
</MarkdownProvider>
|
||||
</>
|
||||
</DialogProvider>
|
||||
)
|
||||
}, host)
|
||||
}
|
||||
|
||||
@@ -545,9 +545,9 @@ for (const streaming of [false, true]) {
|
||||
{ fixture, streaming },
|
||||
)
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
const image = harness.getByRole("img", { name: "Chart", exact: true })
|
||||
const image = harness.getByRole("button", { name: "Chart", exact: true })
|
||||
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
|
||||
await expect(harness.getByRole("img", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
|
||||
await expect(harness.getByRole("button", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
|
||||
const url = await image.getAttribute("src")
|
||||
@@ -557,7 +557,9 @@ for (const streaming of [false, true]) {
|
||||
await harness.getByLabel("Markdown text").fill("")
|
||||
await expect
|
||||
.poll(() =>
|
||||
harness.getByRole("img", { name: "Replacement" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
harness
|
||||
.getByRole("button", { name: "Replacement" })
|
||||
.evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBe(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -571,9 +573,9 @@ for (const streaming of [false, true]) {
|
||||
url,
|
||||
),
|
||||
).toBe(true)
|
||||
const next = await harness.getByRole("img", { name: "Replacement" }).getAttribute("src")
|
||||
const next = await harness.getByRole("button", { name: "Replacement" }).getAttribute("src")
|
||||
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
|
||||
await expect(harness.getByRole("img")).toHaveCount(0)
|
||||
await expect(harness.locator("img")).toHaveCount(0)
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(url) =>
|
||||
@@ -602,9 +604,14 @@ story("keeps remote images browser-owned and rejects unsafe image sources", asyn
|
||||
}, fixture)
|
||||
const harness = page.getByTestId("markdown-fixture")
|
||||
await expect
|
||||
.poll(() => harness.getByRole("img", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth))
|
||||
.poll(() =>
|
||||
harness.getByRole("button", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBe(1)
|
||||
await expect(harness.getByRole("img", { name: "Remote" })).toHaveAttribute("src", "https://images.example/chart.png")
|
||||
await expect(harness.getByRole("button", { name: "Remote" })).toHaveAttribute(
|
||||
"src",
|
||||
"https://images.example/chart.png",
|
||||
)
|
||||
await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -628,7 +635,7 @@ story("keeps scripts and external resources inactive inside local SVG images", a
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({ images: true, text: "" })
|
||||
}, fixture)
|
||||
const image = page.getByTestId("markdown-fixture").getByRole("img", { name: "SVG" })
|
||||
const image = page.getByTestId("markdown-fixture").getByRole("button", { name: "SVG" })
|
||||
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32)
|
||||
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
|
||||
await expect(page).not.toHaveTitle("image-script-ran")
|
||||
@@ -662,12 +669,12 @@ story("loads file URLs and leaves unreadable images as alt text", async ({ page
|
||||
await expect
|
||||
.poll(() =>
|
||||
harness
|
||||
.getByRole("img", { name: "Available", exact: true })
|
||||
.getByRole("button", { name: "Available", exact: true })
|
||||
.evaluate((image: HTMLImageElement) => image.naturalWidth),
|
||||
)
|
||||
.toBe(1)
|
||||
await expect.poll(() => [...requested].sort()).toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png"])
|
||||
await expect(harness.getByRole("img", { name: "Unavailable" })).not.toHaveAttribute("src")
|
||||
await expect(harness.getByRole("button", { name: "Unavailable" })).not.toHaveAttribute("src")
|
||||
await harness.getByLabel("Markdown text").fill("Still usable")
|
||||
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { readLocalImage } from "../../app/src/runtime/server/image"
|
||||
import { MarkdownProvider } from "../src/context/markdown"
|
||||
@@ -25,36 +26,38 @@ export function mountReadImage(options: { path: string; grouped: boolean; runnin
|
||||
...(state.appended ? [storyTool("read_next", "read", "completed", { path: "src/next.ts" })] : []),
|
||||
])
|
||||
return (
|
||||
<section style={{ "max-width": "720px", padding: "24px" }}>
|
||||
<button onClick={() => setState("running", false)}>Finish read</button>
|
||||
<button onClick={() => setState("appended", true)}>Append read</button>
|
||||
<button onClick={() => setState("visible", false)}>Unmount tools</button>
|
||||
<MarkdownProvider readImage={(path, signal) => readLocalImage(api, "C:/project", path, signal)}>
|
||||
<CurrentSessionProviders document={storyDocument(tools())}>
|
||||
<Show when={state.visible}>
|
||||
<Show
|
||||
when={options.grouped}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id="read_image"
|
||||
tool="read"
|
||||
input={{ path: options.path }}
|
||||
metadata={{}}
|
||||
status={status()}
|
||||
<DialogProvider>
|
||||
<section style={{ "max-width": "720px", padding: "24px" }}>
|
||||
<button onClick={() => setState("running", false)}>Finish read</button>
|
||||
<button onClick={() => setState("appended", true)}>Append read</button>
|
||||
<button onClick={() => setState("visible", false)}>Unmount tools</button>
|
||||
<MarkdownProvider readImage={(path, signal) => readLocalImage(api, "C:/project", path, signal)}>
|
||||
<CurrentSessionProviders document={storyDocument(tools())}>
|
||||
<Show when={state.visible}>
|
||||
<Show
|
||||
when={options.grouped}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id="read_image"
|
||||
tool="read"
|
||||
input={{ path: options.path }}
|
||||
metadata={{}}
|
||||
status={status()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentContextToolGroup
|
||||
parts={tools()}
|
||||
busy={state.running}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CurrentContextToolGroup
|
||||
parts={tools()}
|
||||
busy={state.running}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</MarkdownProvider>
|
||||
</section>
|
||||
</CurrentSessionProviders>
|
||||
</MarkdownProvider>
|
||||
</section>
|
||||
</DialogProvider>
|
||||
)
|
||||
}, host)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ for (const grouped of [true, false]) {
|
||||
)
|
||||
const root = page.getByTestId("read-image-fixture")
|
||||
const trigger = root.getByRole("button", { name: "Read chart%20 one.PNG", exact: true })
|
||||
const image = root.getByRole("img", { name: "chart%20 one.PNG", exact: true })
|
||||
const image = root.getByRole("button", { name: "chart%20 one.PNG", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
@@ -115,7 +115,7 @@ for (const width of [840, 390]) {
|
||||
)
|
||||
const root = page.getByTestId("read-image-fixture")
|
||||
await root.getByRole("button", { name: "Read chart.svg", exact: true }).click()
|
||||
const image = root.getByRole("img", { name: "chart.svg", exact: true })
|
||||
const image = root.getByRole("button", { name: "chart.svg", exact: true })
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 1200)
|
||||
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
|
||||
expect(
|
||||
@@ -140,7 +140,7 @@ story("keeps an unavailable image read collapsible", async ({ page }) => {
|
||||
const response = page.waitForResponse("**/api/fs/read/**")
|
||||
await trigger.click()
|
||||
expect((await response).status()).toBe(404)
|
||||
await expect(root.getByRole("img", { name: "missing.png", exact: true })).not.toHaveAttribute("src")
|
||||
await expect(root.getByRole("button", { name: "missing.png", exact: true })).not.toHaveAttribute("src")
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { ImagePreview } from "@opencode/ui/image-preview"
|
||||
|
||||
export function createImagePreview() {
|
||||
const dialog = useDialog()
|
||||
const i18n = useI18n()
|
||||
const open = (event: MouseEvent | KeyboardEvent) => {
|
||||
if (!(event.currentTarget instanceof HTMLImageElement)) return
|
||||
const src = event.currentTarget.currentSrc || event.currentTarget.getAttribute("src")
|
||||
if (!src) return
|
||||
const alt = event.currentTarget.alt || i18n.t("ui.imagePreview.alt")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dialog.show(() => <ImagePreview src={src} alt={alt} />)
|
||||
}
|
||||
|
||||
return (root: HTMLElement) => {
|
||||
root.querySelectorAll<HTMLImageElement>("img").forEach((image) => {
|
||||
image.setAttribute("role", "button")
|
||||
image.setAttribute("aria-haspopup", "dialog")
|
||||
image.setAttribute("aria-label", image.alt || i18n.t("ui.imagePreview.alt"))
|
||||
image.tabIndex = 0
|
||||
image.onclick = open
|
||||
image.onkeydown = (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
open(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,7 @@
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
cursor: zoom-in;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -39,6 +39,7 @@ import { renderMermaidSvg } from "./markdown-mermaid"
|
||||
import { createMarkdownRenderer } from "./markdown-solid"
|
||||
import { useMarkdown, type ReadMarkdownImage } from "../context/markdown"
|
||||
import { createMarkdownImages } from "./markdown-image"
|
||||
import { createImagePreview } from "./image-preview"
|
||||
|
||||
type RenderedBlock =
|
||||
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
||||
@@ -394,6 +395,7 @@ export function Markdown(
|
||||
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
|
||||
const i18n = useI18n()
|
||||
const markdown = useMarkdown()
|
||||
const previewImages = createImagePreview()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const lifetime = new AbortController()
|
||||
@@ -557,6 +559,7 @@ export function Markdown(
|
||||
child.remove()
|
||||
}
|
||||
images?.update(container)
|
||||
previewImages(container)
|
||||
container
|
||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
|
||||
@@ -369,6 +369,7 @@
|
||||
min-width: 0;
|
||||
|
||||
img {
|
||||
cursor: zoom-in;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ToolErrorCard } from "../components/tool-error-card"
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Markdown } from "../components/markdown"
|
||||
import { createMarkdownImages } from "../components/markdown-image"
|
||||
import { createImagePreview } from "../components/image-preview"
|
||||
import { useMarkdown } from "../context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
@@ -1366,8 +1367,10 @@ ToolRegistry.register({
|
||||
|
||||
function ReadImage(props: { path: string; onContentRendered?: () => void }) {
|
||||
const markdown = useMarkdown()
|
||||
const previewImages = createImagePreview()
|
||||
let root!: HTMLDivElement
|
||||
createEffect(() => {
|
||||
previewImages(root)
|
||||
if (!markdown?.readImage) return
|
||||
const images = createMarkdownImages(markdown.readImage)
|
||||
images.update(root)
|
||||
|
||||
Reference in New Issue
Block a user