Просмотр исходного кода

feat(app): render images in session review

Adam 1 месяц назад
Родитель
Сommit
496bbd70f4

+ 10 - 0
packages/app/src/pages/session.tsx

@@ -93,6 +93,15 @@ function SessionReviewTab(props: SessionReviewTabProps) {
   let frame: number | undefined
   let pending: { x: number; y: number } | undefined
 
+  const sdk = useSDK()
+
+  const readFile = (path: string) => {
+    return sdk.client.file
+      .read({ path })
+      .then((x) => x.data)
+      .catch(() => undefined)
+  }
+
   const restoreScroll = (retries = 0) => {
     const el = scroll
     if (!el) return
@@ -161,6 +170,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
       diffStyle={props.diffStyle}
       onDiffStyleChange={props.onDiffStyleChange}
       onViewFile={props.onViewFile}
+      readFile={readFile}
     />
   )
 }

+ 26 - 0
packages/ui/src/components/session-review.css

@@ -137,4 +137,30 @@
     align-items: center;
     justify-content: flex-end;
   }
+
+  [data-slot="session-review-file-container"] {
+    padding: 0;
+  }
+
+  [data-slot="session-review-image-container"] {
+    padding: 12px;
+    display: flex;
+    justify-content: center;
+    background: var(--background-stronger);
+  }
+
+  [data-slot="session-review-image"] {
+    max-width: 100%;
+    max-height: 60vh;
+    object-fit: contain;
+    border-radius: 8px;
+    border: 1px solid var(--border-weak-base);
+    background: var(--background-base);
+  }
+
+  [data-slot="session-review-image-placeholder"] {
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    color: var(--text-weak);
+  }
 }

+ 166 - 50
packages/ui/src/components/session-review.tsx

@@ -5,12 +5,14 @@ import { DiffChanges } from "./diff-changes"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
+import { useCodeComponent } from "../context/code"
 import { useDiffComponent } from "../context/diff"
 import { useI18n } from "../context/i18n"
+import { checksum } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
-import { type FileDiff } from "@opencode-ai/sdk/v2"
+import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { Dynamic } from "solid-js/web"
 
@@ -30,11 +32,52 @@ export interface SessionReviewProps {
   actions?: JSX.Element
   diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
   onViewFile?: (file: string) => void
+  readFile?: (path: string) => Promise<FileContent | undefined>
+}
+
+const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+
+function getExtension(file: string): string {
+  const idx = file.lastIndexOf(".")
+  if (idx === -1) return ""
+  return file.slice(idx + 1).toLowerCase()
+}
+
+function isImageFile(file: string): boolean {
+  return imageExtensions.has(getExtension(file))
+}
+
+function dataUrl(content: FileContent | undefined): string | undefined {
+  if (!content) return
+  if (content.encoding !== "base64") return
+  const mime = content.mimeType ?? ""
+  if (!mime.startsWith("image/")) return
+  return `data:${mime};base64,${content.content}`
+}
+
+function dataUrlFromValue(value: unknown): string | undefined {
+  if (typeof value === "string") {
+    if (value.startsWith("data:image/")) return value
+    return
+  }
+  if (!value || typeof value !== "object") return
+
+  const content = (value as { content?: unknown }).content
+  const encoding = (value as { encoding?: unknown }).encoding
+  const mimeType = (value as { mimeType?: unknown }).mimeType
+
+  if (typeof content !== "string") return
+  if (encoding !== "base64") return
+  if (typeof mimeType !== "string") return
+  if (!mimeType.startsWith("image/")) return
+
+  return `data:${mimeType};base64,${content}`
 }
 
 export const SessionReview = (props: SessionReviewProps) => {
   const i18n = useI18n()
   const diffComponent = useDiffComponent()
+  const codeComponent = useCodeComponent()
   const [store, setStore] = createStore({
     open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
   })
@@ -100,56 +143,129 @@ export const SessionReview = (props: SessionReviewProps) => {
       >
         <Accordion multiple value={open()} onChange={handleChange}>
           <For each={props.diffs}>
-            {(diff) => (
-              <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
-                <StickyAccordionHeader>
-                  <Accordion.Trigger>
-                    <div data-slot="session-review-trigger-content">
-                      <div data-slot="session-review-file-info">
-                        <FileIcon node={{ path: diff.file, type: "file" }} />
-                        <div data-slot="session-review-file-name-container">
-                          <Show when={diff.file.includes("/")}>
-                            <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
-                          </Show>
-                          <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
-                          <Show when={props.onViewFile}>
-                            <button
-                              data-slot="session-review-view-button"
-                              type="button"
-                              onClick={(e) => {
-                                e.stopPropagation()
-                                props.onViewFile?.(diff.file)
-                              }}
-                            >
-                              <Icon name="eye" size="small" />
-                            </button>
-                          </Show>
+            {(diff) => {
+              const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
+              const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+
+              const isAdded = () => beforeText().length === 0 && afterText().length > 0
+              const isDeleted = () => afterText().length === 0 && beforeText().length > 0
+              const isImage = () => isImageFile(diff.file)
+
+              const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
+              const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
+              const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
+
+              createEffect(() => {
+                if (!open().includes(diff.file)) return
+                if (!isImage()) return
+                if (imageSrc()) return
+                if (imageStatus() !== "idle") return
+
+                const reader = props.readFile
+                if (!reader) return
+
+                setImageStatus("loading")
+                reader(diff.file)
+                  .then((result) => {
+                    const src = dataUrl(result)
+                    if (!src) {
+                      setImageStatus("error")
+                      return
+                    }
+                    setImageSrc(src)
+                    setImageStatus("idle")
+                  })
+                  .catch(() => {
+                    setImageStatus("error")
+                  })
+              })
+
+              const fileForCode = () => {
+                const contents = afterText() || beforeText()
+                return {
+                  name: diff.file,
+                  contents,
+                  cacheKey: checksum(contents),
+                }
+              }
+
+              return (
+                <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
+                  <StickyAccordionHeader>
+                    <Accordion.Trigger>
+                      <div data-slot="session-review-trigger-content">
+                        <div data-slot="session-review-file-info">
+                          <FileIcon node={{ path: diff.file, type: "file" }} />
+                          <div data-slot="session-review-file-name-container">
+                            <Show when={diff.file.includes("/")}>
+                              <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
+                            </Show>
+                            <span data-slot="session-review-filename">{getFilename(diff.file)}</span>
+                            <Show when={props.onViewFile}>
+                              <button
+                                data-slot="session-review-view-button"
+                                type="button"
+                                onClick={(e) => {
+                                  e.stopPropagation()
+                                  props.onViewFile?.(diff.file)
+                                }}
+                              >
+                                <Icon name="eye" size="small" />
+                              </button>
+                            </Show>
+                          </div>
+                        </div>
+                        <div data-slot="session-review-trigger-actions">
+                          <DiffChanges changes={diff} />
+                          <Icon name="chevron-grabber-vertical" size="small" />
                         </div>
                       </div>
-                      <div data-slot="session-review-trigger-actions">
-                        <DiffChanges changes={diff} />
-                        <Icon name="chevron-grabber-vertical" size="small" />
-                      </div>
-                    </div>
-                  </Accordion.Trigger>
-                </StickyAccordionHeader>
-                <Accordion.Content data-slot="session-review-accordion-content">
-                  <Dynamic
-                    component={diffComponent}
-                    preloadedDiff={diff.preloaded}
-                    diffStyle={diffStyle()}
-                    before={{
-                      name: diff.file!,
-                      contents: typeof diff.before === "string" ? diff.before : "",
-                    }}
-                    after={{
-                      name: diff.file!,
-                      contents: typeof diff.after === "string" ? diff.after : "",
-                    }}
-                  />
-                </Accordion.Content>
-              </Accordion.Item>
-            )}
+                    </Accordion.Trigger>
+                  </StickyAccordionHeader>
+                  <Accordion.Content data-slot="session-review-accordion-content">
+                    <Switch>
+                      <Match when={isImage()}>
+                        <div data-slot="session-review-image-container">
+                          <Show
+                            when={imageSrc()}
+                            fallback={
+                              <div data-slot="session-review-image-placeholder">
+                                <Switch>
+                                  <Match when={imageStatus() === "loading"}>Loading image...</Match>
+                                  <Match when={true}>Image preview unavailable</Match>
+                                </Switch>
+                              </div>
+                            }
+                          >
+                            <img data-slot="session-review-image" src={imageSrc()!} alt={getFilename(diff.file)} />
+                          </Show>
+                        </div>
+                      </Match>
+                      <Match when={isAdded() || isDeleted()}>
+                        <div data-slot="session-review-file-container">
+                          <Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" />
+                        </div>
+                      </Match>
+                      <Match when={true}>
+                        <Dynamic
+                          component={diffComponent}
+                          preloadedDiff={diff.preloaded}
+                          diffStyle={diffStyle()}
+                          before={{
+                            name: diff.file!,
+                            contents: beforeText(),
+                          }}
+                          after={{
+                            name: diff.file!,
+                            contents: afterText(),
+                          }}
+                        />
+                      </Match>
+                    </Switch>
+                  </Accordion.Content>
+                </Accordion.Item>
+              )
+            }}
           </For>
         </Accordion>
       </div>