mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): preview image attachments delivered by path (#57249)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
1da2be4192
commit
fa2888b12e
@@ -257,6 +257,7 @@ for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
|
||||
if (draft === "mixed-attachments") {
|
||||
await expect(component.getByAltText("layout.png")).toBeVisible()
|
||||
await expect(component.getByAltText("screenshot.png")).toBeVisible()
|
||||
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SessionPreview } from "@/session/story-model"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { resolveSessionComposerSelection } from "@/session/composer/selection"
|
||||
|
||||
const pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
|
||||
|
||||
const selectedModel = {
|
||||
id: STORY_MODEL.id,
|
||||
providerID: STORY_MODEL.providerID,
|
||||
@@ -147,6 +149,7 @@ function ComposerStory(props: {
|
||||
commands: () => commands,
|
||||
context: () => context,
|
||||
searchContextFiles: () => [],
|
||||
readImage: async () => pixel,
|
||||
view: {
|
||||
placeholder: () => "Ask anything, / for commands, @ for context...",
|
||||
agent: {
|
||||
@@ -211,8 +214,7 @@ function ComposerStory(props: {
|
||||
: `Submitted: ${value}`,
|
||||
)
|
||||
},
|
||||
onStop: () =>
|
||||
setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"),
|
||||
onStop: () => setStory("activity", props.continueOnStop ? "POST /interrupt · resume: true" : "Stop requested"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -278,7 +280,14 @@ export const MixedAttachments = {
|
||||
id: "image-story",
|
||||
filename: "layout.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "image-story", url: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" },
|
||||
blob: { id: "image-story", url: pixel },
|
||||
},
|
||||
{
|
||||
type: "path",
|
||||
id: "path-image-story",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
path: "C:/shots/screenshot.png",
|
||||
},
|
||||
]}
|
||||
comments={[
|
||||
|
||||
@@ -18,7 +18,6 @@ import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -224,6 +223,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
preview={props.controller.previewAttachment}
|
||||
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
|
||||
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
|
||||
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
|
||||
@@ -608,6 +608,7 @@ export function ComposerAttachments(props: {
|
||||
activeCommentID?: string
|
||||
removeLabel: string
|
||||
onAttachmentClick?: (attachment: ComposerAttachment, origin: HTMLElement) => void
|
||||
preview: (attachment: ComposerAttachment) => Promise<string | undefined>
|
||||
onAttachmentRemove: (attachment: ComposerAttachment) => void
|
||||
onUploadCancel?: (upload: Upload) => void
|
||||
onCommentClick?: (comment: ComposerComment) => void
|
||||
@@ -724,52 +725,53 @@ export function ComposerAttachments(props: {
|
||||
)}
|
||||
</For>
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Show
|
||||
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
{(attachment) => {
|
||||
const image = attachment.mime.startsWith("image/")
|
||||
// Restored drafts and history carry image ids only, and path attachments carry no bytes; both load when shown.
|
||||
const [url] = createResource(() => image && attachment, props.preview)
|
||||
// Read state rather than the resource so loading never suspends; the route boundary would detach the screen and drop editor focus.
|
||||
const src = () => (url.state === "ready" ? url() : undefined)
|
||||
const missing = () => url.state === "errored" || (url.state === "ready" && !url())
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Keep loading local; the route boundary would detach the screen and drop editor focus. */}
|
||||
<Suspense fallback={<div class="w-[58px] h-[46px]" />}>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={(event) => props.onAttachmentClick?.(attachment, event.currentTarget)}
|
||||
/>
|
||||
</Suspense>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onAttachmentRemove(attachment)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div class="relative group shrink-0">
|
||||
<Tooltip
|
||||
value={attachment.type === "path" ? attachment.path : attachment.filename}
|
||||
placement="top"
|
||||
contentClass="break-all"
|
||||
>
|
||||
<Show
|
||||
when={image && !missing()}
|
||||
fallback={
|
||||
<AttachmentCard title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
<Show when={src()} fallback={<div class="w-[58px] h-[46px]" />}>
|
||||
{(value) => (
|
||||
<img
|
||||
src={value()}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={(event) => props.onAttachmentClick?.(attachment, event.currentTarget)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onAttachmentRemove(attachment)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<For each={props.uploads ?? []}>
|
||||
{(upload) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
|
||||
import type { Upload } from "../attachments/uploads"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
@@ -65,6 +66,8 @@ export function createComposerEditor(input: {
|
||||
context: Accessor<ComposerSuggestion[]>
|
||||
searchContextFiles: (query: string) => ComposerSuggestion[] | Promise<ComposerSuggestion[]>
|
||||
openAttachment?: (attachment: ComposerAttachment, origin?: HTMLElement) => void
|
||||
/** An object URL for the image file at this path, which a path attachment references without its bytes. */
|
||||
readImage?: (path: string) => Promise<string | undefined>
|
||||
openContext?: (key: string) => void
|
||||
onContextRemove?: (item: ComposerComment) => void
|
||||
onQuoteComment?: (item: Extract<ComposerComment, { type: "quote" }>, comment: string) => void
|
||||
@@ -425,6 +428,14 @@ export function createComposerEditor(input: {
|
||||
openAttachment(attachment: ComposerAttachment, origin?: HTMLElement) {
|
||||
input.openAttachment?.(attachment, origin)
|
||||
},
|
||||
/** The image an attachment shows, whether its bytes are in the draft store or in a file at its path. */
|
||||
previewAttachment(attachment: ComposerAttachment): Promise<string | undefined> {
|
||||
if (!attachment.mime.startsWith("image/")) return Promise.resolve(undefined)
|
||||
|
||||
if (attachment.type === "image") return resolveBlobUrl(attachment.blob)
|
||||
|
||||
return input.readImage?.(attachment.path) ?? Promise.resolve(undefined)
|
||||
},
|
||||
removeAttachment(id: string) {
|
||||
draft.removeAttachment(id)
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ImagePreview } from "@opencode/ui/image-preview"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import type { ReferenceInfo } from "@opencode/client/promise"
|
||||
import type { Links, MountedSession } from "@opencode/gui-extensions/sdk"
|
||||
import { createComponent, createEffect, createMemo, on } from "solid-js"
|
||||
import { createComponent, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { Predicate } from "effect"
|
||||
import type { ComposerSuggestion } from "./types"
|
||||
import { createComposerEditor, createComposerEditorState, type ComposerEditorModel } from "./editor/interaction"
|
||||
@@ -15,8 +15,9 @@ import { useExtensionHost } from "@/runtime/extension/host"
|
||||
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { readLocalImage } from "@/runtime/server/image"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
@@ -56,8 +57,16 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const serverSDK = useServerSDK()
|
||||
const prompt = adapter.state
|
||||
let editor: HTMLDivElement | undefined
|
||||
// A path attachment's image is read through the server, which can see both local and uploaded paths.
|
||||
const pathImages = new Map<string, Promise<string | undefined>>()
|
||||
const pathImageReads = new AbortController()
|
||||
onCleanup(() => {
|
||||
pathImageReads.abort()
|
||||
pathImages.forEach((url) => void url.then((value) => value && URL.revokeObjectURL(value)))
|
||||
})
|
||||
|
||||
const interaction = createComposerEditorState(prompt.mode.current())
|
||||
createEffect(
|
||||
@@ -333,12 +342,24 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onContextRemove(item) {
|
||||
if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment, origin) => {
|
||||
if (attachment.type !== "image") return
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
openAttachment(attachment, origin) {
|
||||
void controller.previewAttachment(attachment).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename, origin }))
|
||||
})
|
||||
},
|
||||
readImage(path) {
|
||||
const existing = pathImages.get(path)
|
||||
|
||||
if (existing) return existing
|
||||
const url = readLocalImage(serverSDK.api, sdk().directory, path, pathImageReads.signal)
|
||||
.then((blob) => blob && URL.createObjectURL(blob))
|
||||
.catch(() => undefined)
|
||||
pathImages.set(path, url)
|
||||
// A failed read retries the next time the attachment shows.
|
||||
void url.then((value) => value || pathImages.delete(path))
|
||||
|
||||
return url
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("readLocalImage", () => {
|
||||
["d:/screen.png", "d:/", "screen.png"],
|
||||
["D:/charts/screen.png", "D:/charts/", "screen.png"],
|
||||
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
|
||||
["C:\\Users\\me\\screen #1.png", "C:/Users/me/", "screen%20%231.png"],
|
||||
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
|
||||
["/screen.png", "/", "screen.png"],
|
||||
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function readLocalImage(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Blob | undefined> {
|
||||
const drive = /^[a-z]:\//i.test(path)
|
||||
const drive = /^[a-z]:[\\/]/i.test(path)
|
||||
|
||||
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
|
||||
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
|
||||
|
||||
Reference in New Issue
Block a user