import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useParams } from "@solidjs/router" import type { FileSearchHandle } from "@opencode-ai/ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" import { sampledChecksum } from "@opencode-ai/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@opencode-ai/ui/toast" import { useLayout } from "@/context/layout" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { getSessionHandoff } from "@/pages/session/handoff" function FileCommentMenu(props: { moreLabel: string editLabel: string deleteLabel: string onEdit: VoidFunction onDelete: VoidFunction }) { return (
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> {props.editLabel} {props.deleteLabel}
) } export function FileTabContent(props: { tab: string }) { const params = useParams() const layout = useLayout() const file = useFile() const comments = useComments() const language = useLanguage() const prompt = usePrompt() const fileComponent = useFileComponent() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined let restoreFrame: number | undefined let pending: { x: number; y: number } | undefined let codeScroll: HTMLElement[] = [] let find: FileSearchHandle | null = null const search = { register: (handle: FileSearchHandle | null) => { find = handle }, } const path = createMemo(() => file.pathFromTab(props.tab)) const state = createMemo(() => { const p = path() if (!p) return return file.get(p) }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => sampledChecksum(contents())) const selectedLines = createMemo(() => { const p = path() if (!p) return null if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null }) const selectionPreview = (source: string, selection: FileSelection) => { return previewSelectedLines(source, { start: selection.startLine, end: selection.endLine, }) } const addCommentToContext = (input: { file: string selection: SelectedLineRange comment: string preview?: string origin?: "review" | "file" }) => { const selection = selectionFromLines(input.selection) const preview = input.preview ?? (() => { if (input.file === path()) return selectionPreview(contents(), selection) const source = file.get(input.file)?.content?.content if (!source) return undefined return selectionPreview(source, selection) })() const saved = comments.add({ file: input.file, selection: input.selection, comment: input.comment, }) prompt.context.add({ type: "file", path: input.file, selection, comment: input.comment, commentID: saved.id, commentOrigin: input.origin, preview, }) } const updateCommentInContext = (input: { id: string file: string selection: SelectedLineRange comment: string }) => { comments.update(input.file, input.id, input.comment) const preview = input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined prompt.context.updateComment(input.file, input.id, { comment: input.comment, ...(preview ? { preview } : {}), }) } const removeCommentFromContext = (input: { id: string; file: string }) => { comments.remove(input.file, input.id) prompt.context.removeComment(input.file, input.id) } const fileComments = createMemo(() => { const p = path() if (!p) return [] return 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, selected: null as SelectedLineRange | null, }) const syncSelected = (range: SelectedLineRange | null) => { const p = path() if (!p) return file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) } const activeSelection = () => note.selected ?? selectedLines() const commentsUi = createLineCommentController({ comments: fileComments, label: language.t("ui.lineComment.submit"), draftKey: () => path() ?? props.tab, state: { opened: () => note.openedComment, setOpened: (id) => setNote("openedComment", id), selected: () => note.selected, setSelected: (range) => setNote("selected", range), commenting: () => note.commenting, setCommenting: (range) => setNote("commenting", range), syncSelected, hoverSelected: syncSelected, }, getHoverSelectedRange: activeSelection, cancelDraftOnCommentToggle: true, clearSelectionOnSelectionEndNull: true, onSubmit: ({ comment, selection }) => { const p = path() if (!p) return addCommentToContext({ file: p, selection, comment, origin: "file" }) }, onUpdate: ({ id, comment, selection }) => { const p = path() if (!p) return updateCommentInContext({ id, file: p, selection, comment }) }, onDelete: (comment) => { const p = path() if (!p) return removeCommentFromContext({ id: comment.id, file: p }) }, editSubmitLabel: language.t("common.save"), renderCommentActions: (_, controls) => ( ), onDraftPopoverFocusOut: (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)) { setNote("commenting", null) } }, 0) }, }) createEffect(() => { if (typeof window === "undefined") return const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return if (tabs().active() !== props.tab) return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (event.key.toLowerCase() !== "f") return event.preventDefault() event.stopPropagation() find?.focus() } window.addEventListener("keydown", onKeyDown, { capture: true }) onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) }) createEffect( on( path, () => { commentsUi.note.reset() }, { defer: true }, ), ) createEffect(() => { const focus = comments.focus() const p = path() if (!focus || !p) return if (focus.file !== p) return if (tabs().active() !== props.tab) return const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) requestAnimationFrame(() => 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 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 = 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 queueRestore = () => { if (restoreFrame !== undefined) return restoreFrame = requestAnimationFrame(() => { restoreFrame = undefined restoreScroll() }) } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { if (codeScroll.length === 0) syncCodeScroll() queueScrollUpdate({ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop, }) } const cancelCommenting = () => { const p = path() if (p) file.setSelectedLines(p, null) setNote("commenting", null) } let prev = { loaded: false, ready: false, active: false, } createEffect(() => { const loaded = !!state()?.loaded const ready = file.ready() const active = tabs().active() === props.tab const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) prev = { loaded, ready, active } if (!restore) return queueRestore() }) onCleanup(() => { for (const item of codeScroll) { item.removeEventListener("scroll", handleCodeScroll) } if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) }) const renderFile = (source: string) => (
{ queueRestore() }} annotations={commentsUi.annotations()} renderAnnotation={commentsUi.renderAnnotation} renderHoverUtility={commentsUi.renderHoverUtility} onLineSelected={(range: SelectedLineRange | null) => { commentsUi.onLineSelected(range) }} onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} onLineSelectionEnd={(range: SelectedLineRange | null) => { commentsUi.onLineSelectionEnd(range) }} search={search} overflow="scroll" class="select-text" media={{ mode: "auto", path: path(), current: state()?.content, onLoad: queueRestore, onError: (args: { kind: "image" | "audio" | "svg" }) => { if (args.kind !== "svg") return showToast({ variant: "error", title: language.t("toast.file.loadFailed.title"), }) }, }} />
) return ( { scroll = el restoreScroll() }} onScroll={handleScroll as any} > {renderFile(contents())}
{language.t("common.loading")}...
{(err) =>
{err()}
}
) }