summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components/file-media.tsx
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-26 18:23:04 -0600
committerGitHub <[email protected]>2026-02-26 18:23:04 -0600
commitfc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch)
treecf23af294a00a10e55f230232585344c111f0bb9 /packages/ui/src/components/file-media.tsx
parent9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff)
downloadopencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz
opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]> Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/ui/src/components/file-media.tsx')
-rw-r--r--packages/ui/src/components/file-media.tsx265
1 files changed, 265 insertions, 0 deletions
diff --git a/packages/ui/src/components/file-media.tsx b/packages/ui/src/components/file-media.tsx
new file mode 100644
index 000000000..2fd54588a
--- /dev/null
+++ b/packages/ui/src/components/file-media.tsx
@@ -0,0 +1,265 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
+import { useI18n } from "../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
+ readFile?: (path: string) => Promise<FileContent | undefined>
+ 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 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 (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,
+ }
+ })
+
+ const [loaded] = createResource(request, async (input) => {
+ return input.readFile(input.path).then(
+ (result) => {
+ const src = dataUrlFromMediaValue(result as any, input.kind)
+ if (!src) {
+ input.onError?.({ kind: input.kind })
+ return { key: input.key, error: true as const }
+ }
+
+ return {
+ key: input.key,
+ src,
+ mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
+ }
+ },
+ () => {
+ input.onError?.({ kind: input.kind })
+ return { key: input.key, error: true as const }
+ },
+ )
+ })
+
+ const remote = createMemo(() => {
+ const input = request()
+ const value = loaded()
+ if (!input || !value || value.key !== input.key) return
+ return value
+ })
+
+ const src = createMemo(() => {
+ const value = remote()
+ return direct() ?? (value && "src" in value ? value.src : undefined)
+ })
+ const status = createMemo(() => {
+ if (direct()) return "ready" as const
+ if (!request()) return "idle" as const
+ if (loaded.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 value = remote()
+ return value && "mime" in value ? value.mime : undefined
+ })
+
+ 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 (
+ <Switch>
+ <Match when={kind() === "image" || kind() === "audio"}>
+ <Show
+ when={src()}
+ fallback={(() => {
+ const media = cfg()
+ const k = kind()
+ if (!media || (k !== "image" && k !== "audio")) return props.fallback()
+ const label = kindLabel(k)
+
+ if (deleted()) {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })}
+ </div>
+ )
+ }
+ if (status() === "loading") {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })}
+ </div>
+ )
+ }
+ if (status() === "error") {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.error", { kind: label })}
+ </div>
+ )
+ }
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
+ </div>
+ )
+ })()}
+ >
+ {(value) => {
+ const k = kind()
+ if (k !== "image" && k !== "audio") return props.fallback()
+ if (k === "image") {
+ return (
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
+ <img
+ src={value()}
+ alt={cfg()?.path}
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+ onLoad={onLoad}
+ />
+ </div>
+ )
+ }
+
+ return (
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
+ <audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
+ <source src={value()} type={audioMime()} />
+ </audio>
+ </div>
+ )
+ }}
+ </Show>
+ </Match>
+ <Match when={kind() === "svg"}>
+ {(() => {
+ if (svgSource() === undefined && svgSrc() == null) return props.fallback()
+
+ return (
+ <div class="flex flex-col gap-4 px-6 py-4">
+ <Show when={svgSource() !== undefined}>{props.fallback()}</Show>
+ <Show when={svgSrc()}>
+ {(value) => (
+ <div class="flex justify-center">
+ <img
+ src={value()}
+ alt={cfg()?.path}
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+ onLoad={onLoad}
+ />
+ </div>
+ )}
+ </Show>
+ </div>
+ )
+ })()}
+ </Match>
+ <Match when={isBinary()}>
+ <div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
+ <div class="text-14-semibold text-text-strong">
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
+ </div>
+ <div class="text-14-regular text-text-weak">
+ {(() => {
+ const path = cfg()?.path
+ if (!path) return i18n.t("ui.fileMedia.binary.description.default")
+ return i18n.t("ui.fileMedia.binary.description.path", { path })
+ })()}
+ </div>
+ </div>
+ </Match>
+ <Match when={true}>{props.fallback()}</Match>
+ </Switch>
+ )
+}