import type { FileContent } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, Match, on, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { useI18n } from "@opencode-ai/ui/context/i18n" import { dataUrlFromMediaValue, hasMediaValue, isBinaryContent, mediaKindFromPath, normalizeMimeType, svgTextFromValue, } from "../pierre/media" export type FileMediaOptions = { mode?: "auto" | "off" path?: string current?: unknown before?: unknown after?: unknown deleted?: boolean readFile?: (path: string) => Promise onLoad?: () => void onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void } function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") { if (cfg.current !== undefined) return cfg.current if (mode === "image") return cfg.after ?? cfg.before return cfg.after ?? cfg.before } export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) { const i18n = useI18n() const [remote, setRemote] = createStore<{ key?: string loading?: boolean error?: boolean src?: string mime?: string }>({}) const cfg = () => props.media const kind = createMemo(() => { const media = cfg() if (!media || media.mode === "off") return return mediaKindFromPath(media.path) }) const isBinary = createMemo(() => { const media = cfg() if (!media || media.mode === "off") return false if (kind()) return false return isBinaryContent(media.current as any) }) const onLoad = () => props.media?.onLoad?.() const deleted = createMemo(() => { const media = cfg() const k = kind() if (!media || !k) return false if (media.deleted) return true if (k === "svg") return false if (media.current !== undefined) return false return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any) }) const direct = createMemo(() => { const media = cfg() const k = kind() if (!media || (k !== "image" && k !== "audio")) return return dataUrlFromMediaValue(mediaValue(media, k), k) }) const request = createMemo(() => { const media = cfg() const k = kind() if (!media || (k !== "image" && k !== "audio")) return if (media.current !== undefined) return if (deleted()) return if (direct()) return if (!media.path || !media.readFile) return return { key: `${k}:${media.path}`, kind: k, path: media.path, readFile: media.readFile, onError: media.onError, } }) createEffect(() => { const input = request() if (!input) { setRemote({ key: undefined, loading: false, error: false, src: undefined, mime: undefined }) return } let active = true // Keep the previous media visible while re-reading the same file (e.g. a vcs // diff refresh); only a key change resets to the loading placeholder. if (untrack(() => remote.key) === input.key) setRemote({ loading: true, error: false }) else setRemote({ key: input.key, loading: true, error: false, src: undefined, mime: undefined }) void input.readFile(input.path).then( (result) => { if (!active) return const src = dataUrlFromMediaValue(result as any, input.kind) if (!src) { input.onError?.({ kind: input.kind }) setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined }) return } setRemote({ key: input.key, loading: false, error: false, src, mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined, }) }, () => { if (!active) return input.onError?.({ kind: input.kind }) setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined }) }, ) onCleanup(() => { active = false }) }) const src = createMemo(() => { const input = request() if (!input || remote.key !== input.key || remote.error) return direct() return direct() ?? remote.src }) const status = createMemo(() => { if (direct()) return "ready" as const const input = request() if (!input) return "idle" as const if (remote.key !== input.key || remote.loading) return "loading" as const if (remote.error) return "error" as const if (src()) return "ready" as const return "idle" as const }) const audioMime = createMemo(() => { const input = request() if (!input || remote.key !== input.key) return return remote.mime }) const svgSource = createMemo(() => { const media = cfg() if (!media || kind() !== "svg") return return svgTextFromValue(media.current as any) }) const svgSrc = createMemo(() => { const media = cfg() if (!media || kind() !== "svg") return return dataUrlFromMediaValue(media.current as any, "svg") }) const svgInvalid = createMemo(() => { const media = cfg() if (!media || kind() !== "svg") return if (svgSource() !== undefined) return if (!hasMediaValue(media.current as any)) return return [media.path, media.current] as const }) createEffect( on( svgInvalid, (value) => { if (!value) return cfg()?.onError?.({ kind: "svg" }) }, { defer: true }, ), ) const kindLabel = (value: "image" | "audio") => i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio") return ( { const media = cfg() const k = kind() if (!media || (k !== "image" && k !== "audio")) return props.fallback() const label = kindLabel(k) if (deleted()) { return (
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
) } if (status() === "loading") { return (
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
) } if (status() === "error") { return (
{i18n.t("ui.fileMedia.state.error", { kind: label })}
) } return (
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
) })()} > {(value) => { const k = kind() if (k !== "image" && k !== "audio") return props.fallback() if (k === "image") { return (
{cfg()?.path}
) } return (
) }}
{(() => { if (svgSource() === undefined && svgSrc() == null) return props.fallback() return (
{props.fallback()} {(value) => (
{cfg()?.path}
)}
) })()}
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
{(() => { const path = cfg()?.path if (!path) return i18n.t("ui.fileMedia.binary.description.default") return i18n.t("ui.fileMedia.binary.description.path", { path }) })()}
{props.fallback()}
) }