|
@@ -5,12 +5,14 @@ import { DiffChanges } from "./diff-changes"
|
|
|
import { FileIcon } from "./file-icon"
|
|
import { FileIcon } from "./file-icon"
|
|
|
import { Icon } from "./icon"
|
|
import { Icon } from "./icon"
|
|
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|
|
|
|
+import { useCodeComponent } from "../context/code"
|
|
|
import { useDiffComponent } from "../context/diff"
|
|
import { useDiffComponent } from "../context/diff"
|
|
|
import { useI18n } from "../context/i18n"
|
|
import { useI18n } from "../context/i18n"
|
|
|
|
|
+import { checksum } from "@opencode-ai/util/encode"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
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 { 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 { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
|
|
import { Dynamic } from "solid-js/web"
|
|
import { Dynamic } from "solid-js/web"
|
|
|
|
|
|
|
@@ -30,11 +32,52 @@ export interface SessionReviewProps {
|
|
|
actions?: JSX.Element
|
|
actions?: JSX.Element
|
|
|
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
|
|
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
|
|
|
onViewFile?: (file: string) => void
|
|
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) => {
|
|
export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const i18n = useI18n()
|
|
const i18n = useI18n()
|
|
|
const diffComponent = useDiffComponent()
|
|
const diffComponent = useDiffComponent()
|
|
|
|
|
+ const codeComponent = useCodeComponent()
|
|
|
const [store, setStore] = createStore({
|
|
const [store, setStore] = createStore({
|
|
|
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
|
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}>
|
|
<Accordion multiple value={open()} onChange={handleChange}>
|
|
|
<For each={props.diffs}>
|
|
<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>
|
|
</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>
|
|
</For>
|
|
|
</Accordion>
|
|
</Accordion>
|
|
|
</div>
|
|
</div>
|