import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { checksum } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { Mark } from "@opencode-ai/ui/logo" import { Tabs } from "@opencode-ai/ui/tabs" import { useLayout } from "@/context/layout" import { useFile, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" export function FileTabContent(props: { tab: string activeTab: () => string tabs: () => ReturnType["tabs"]> view: () => ReturnType["view"]> handoffFiles: () => Record | undefined file: ReturnType comments: ReturnType language: ReturnType codeComponent: NonNullable addCommentToContext: (input: { file: string selection: SelectedLineRange comment: string preview?: string origin?: "review" | "file" }) => void }) { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined let codeScroll: HTMLElement[] = [] const path = createMemo(() => props.file.pathFromTab(props.tab)) const state = createMemo(() => { const p = path() if (!p) return return props.file.get(p) }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => checksum(contents())) const isImage = createMemo(() => { const c = state()?.content return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" }) const isSvg = createMemo(() => { const c = state()?.content return c?.mimeType === "image/svg+xml" }) const isBinary = createMemo(() => state()?.content?.type === "binary") const svgContent = createMemo(() => { if (!isSvg()) return const c = state()?.content if (!c) return if (c.encoding !== "base64") return c.content return decode64(c.content) }) const svgDecodeFailed = createMemo(() => { if (!isSvg()) return false const c = state()?.content if (!c) return false if (c.encoding !== "base64") return false return svgContent() === undefined }) const svgToast = { shown: false } createEffect(() => { if (!svgDecodeFailed()) return if (svgToast.shown) return svgToast.shown = true showToast({ variant: "error", title: props.language.t("toast.file.loadFailed.title"), description: "Invalid base64 content.", }) }) const svgPreviewUrl = createMemo(() => { if (!isSvg()) return const c = state()?.content if (!c) return if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` }) const imageDataUrl = createMemo(() => { if (!isImage()) return const c = state()?.content return `data:${c?.mimeType};base64,${c?.content}` }) const selectedLines = createMemo(() => { const p = path() if (!p) return null if (props.file.ready()) return props.file.selectedLines(p) ?? null return props.handoffFiles()?.[p] ?? null }) let wrap: HTMLDivElement | undefined const fileComments = createMemo(() => { const p = path() if (!p) return [] return props.comments.list(p) }) const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) const [note, setNote] = createStore({ openedComment: null as string | null, commenting: null as SelectedLineRange | null, draft: "", positions: {} as Record, draftTop: undefined as number | undefined, }) const openedComment = () => note.openedComment const setOpenedComment = ( value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment), ) => setNote("openedComment", value) const commenting = () => note.commenting const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) => setNote("commenting", value) const draft = () => note.draft const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) => setNote("draft", value) const positions = () => note.positions const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) => setNote("positions", value) const draftTop = () => note.draftTop const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) => setNote("draftTop", value) const commentLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) const end = Math.max(range.start, range.end) if (start === end) return `line ${start}` return `lines ${start}-${end}` } const getRoot = () => { const el = wrap if (!el) return const host = el.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return const root = host.shadowRoot if (!root) return return root } const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { const line = Math.max(range.start, range.end) const node = root.querySelector(`[data-line="${line}"]`) if (!(node instanceof HTMLElement)) return return node } const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { const wrapperRect = wrapper.getBoundingClientRect() const rect = marker.getBoundingClientRect() return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) } const updateComments = () => { const el = wrap const root = getRoot() if (!el || !root) { setPositions({}) setDraftTop(undefined) return } const next: Record = {} for (const comment of fileComments()) { const marker = findMarker(root, comment.selection) if (!marker) continue next[comment.id] = markerTop(el, marker) } setPositions(next) const range = commenting() if (!range) { setDraftTop(undefined) return } const marker = findMarker(root, range) if (!marker) { setDraftTop(undefined) return } setDraftTop(markerTop(el, marker)) } const scheduleComments = () => { requestAnimationFrame(updateComments) } createEffect(() => { fileComments() scheduleComments() }) createEffect(() => { const range = commenting() scheduleComments() if (!range) return setDraft("") }) createEffect(() => { const focus = props.comments.focus() const p = path() if (!focus || !p) return if (focus.file !== p) return if (props.activeTab() !== props.tab) return const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return setOpenedComment(target.id) setCommenting(null) props.file.setSelectedLines(p, target.selection) requestAnimationFrame(() => props.comments.clearFocus()) }) const getCodeScroll = () => { const el = scroll if (!el) return [] const host = el.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return [] const root = host.shadowRoot if (!root) return [] return Array.from(root.querySelectorAll("[data-code]")).filter( (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, ) } const queueScrollUpdate = (next: { x: number; y: number }) => { pending = next if (scrollFrame !== undefined) return scrollFrame = requestAnimationFrame(() => { scrollFrame = undefined const out = pending pending = undefined if (!out) return props.view().setScroll(props.tab, out) }) } const handleCodeScroll = (event: Event) => { const el = scroll if (!el) return const target = event.currentTarget if (!(target instanceof HTMLElement)) return queueScrollUpdate({ x: target.scrollLeft, y: el.scrollTop, }) } const syncCodeScroll = () => { const next = getCodeScroll() if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return for (const item of codeScroll) { item.removeEventListener("scroll", handleCodeScroll) } codeScroll = next for (const item of codeScroll) { item.addEventListener("scroll", handleCodeScroll) } } const restoreScroll = () => { const el = scroll if (!el) return const s = props.view()?.scroll(props.tab) if (!s) return syncCodeScroll() if (codeScroll.length > 0) { for (const item of codeScroll) { if (item.scrollLeft !== s.x) item.scrollLeft = s.x } } if (el.scrollTop !== s.y) el.scrollTop = s.y if (codeScroll.length > 0) return if (el.scrollLeft !== s.x) el.scrollLeft = s.x } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { if (codeScroll.length === 0) syncCodeScroll() queueScrollUpdate({ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop, }) } createEffect( on( () => state()?.loaded, (loaded) => { if (!loaded) return requestAnimationFrame(restoreScroll) }, { defer: true }, ), ) createEffect( on( () => props.file.ready(), (ready) => { if (!ready) return requestAnimationFrame(restoreScroll) }, { defer: true }, ), ) createEffect( on( () => props.tabs().active() === props.tab, (active) => { if (!active) return if (!state()?.loaded) return requestAnimationFrame(restoreScroll) }, ), ) onCleanup(() => { for (const item of codeScroll) { item.removeEventListener("scroll", handleCodeScroll) } if (scrollFrame === undefined) return cancelAnimationFrame(scrollFrame) }) const renderCode = (source: string, wrapperClass: string) => (
{ wrap = el scheduleComments() }} class={`relative overflow-hidden ${wrapperClass}`} > { requestAnimationFrame(restoreScroll) requestAnimationFrame(scheduleComments) }} onLineSelected={(range: SelectedLineRange | null) => { const p = path() if (!p) return props.file.setSelectedLines(p, range) if (!range) setCommenting(null) }} onLineSelectionEnd={(range: SelectedLineRange | null) => { if (!range) { setCommenting(null) return } setOpenedComment(null) setCommenting(range) }} overflow="scroll" class="select-text" /> {(comment) => ( { const p = path() if (!p) return props.file.setSelectedLines(p, comment.selection) }} onClick={() => { const p = path() if (!p) return setCommenting(null) setOpenedComment((current) => (current === comment.id ? null : comment.id)) props.file.setSelectedLines(p, comment.selection) }} /> )} {(range) => ( setDraft(value)} onCancel={() => setCommenting(null)} onSubmit={(value) => { const p = path() if (!p) return props.addCommentToContext({ file: p, selection: range(), comment: value, origin: "file", }) setCommenting(null) }} onPopoverFocusOut={(e: FocusEvent) => { const current = e.currentTarget as HTMLDivElement const target = e.relatedTarget if (target instanceof Node && current.contains(target)) return setTimeout(() => { if (!document.activeElement || !current.contains(document.activeElement)) { setCommenting(null) } }, 0) }} /> )}
) return ( { scroll = el restoreScroll() }} onScroll={handleScroll} >
{path()} requestAnimationFrame(restoreScroll)} />
{renderCode(svgContent() ?? "", "")}
{path()}
{path()?.split("/").pop()}
{props.language.t("session.files.binaryContent")}
{renderCode(contents(), "pb-40")}
{props.language.t("common.loading")}...
{(err) =>
{err()}
}
) }