diff options
| author | Adam <[email protected]> | 2026-02-26 18:23:04 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 18:23:04 -0600 |
| commit | fc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch) | |
| tree | cf23af294a00a10e55f230232585344c111f0bb9 /packages/ui/src/components/session-review.tsx | |
| parent | 9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff) | |
| download | opencode-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/session-review.tsx')
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 847 |
1 files changed, 499 insertions, 348 deletions
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 7f737032e..679935f61 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -1,23 +1,30 @@ import { Accordion } from "./accordion" import { Button } from "./button" +import { DropdownMenu } from "./dropdown-menu" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" -import { LineComment, LineCommentEditor } from "./line-comment" +import { IconButton } from "./icon-button" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" -import { useDiffComponent } from "../context/diff" +import { FileSearchBar } from "./file-search" +import type { FileSearchHandle } from "./file" +import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search" +import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" +import { mediaKindFromPath } from "../pierre/media" +import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" +import { createLineCommentController } from "./line-comment-annotations" const MAX_DIFF_CHANGED_LINES = 500 @@ -37,6 +44,22 @@ export type SessionReviewLineComment = { preview?: string } +export type SessionReviewCommentUpdate = SessionReviewLineComment & { + id: string +} + +export type SessionReviewCommentDelete = { + id: string + file: string +} + +export type SessionReviewCommentActions = { + moreLabel: string + editLabel: string + deleteLabel: string + saveLabel: string +} + export type SessionReviewFocus = { file: string; id: string } export interface SessionReviewProps { @@ -47,6 +70,9 @@ export interface SessionReviewProps { onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void onLineComment?: (comment: SessionReviewLineComment) => void + onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void + onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void + lineCommentActions?: SessionReviewCommentActions comments?: SessionReviewComment[] focusedComment?: SessionReviewFocus | null onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void @@ -64,66 +90,35 @@ export interface SessionReviewProps { readFile?: (path: string) => Promise<FileContent | undefined> } -const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"]) -const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]) - -function normalizeMimeType(type: string | undefined): string | undefined { - if (!type) return - - const mime = type.split(";", 1)[0]?.trim().toLowerCase() - if (!mime) return - - if (mime === "audio/x-aac") return "audio/aac" - if (mime === "audio/x-m4a") return "audio/mp4" - - return mime -} - -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 isAudioFile(file: string): boolean { - return audioExtensions.has(getExtension(file)) -} - -function dataUrl(content: FileContent | undefined): string | undefined { - if (!content) return - if (content.encoding !== "base64") return - const mime = normalizeMimeType(content.mimeType) - if (!mime) return - if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return - return `data:${mime};base64,${content.content}` -} - -function dataUrlFromValue(value: unknown): string | undefined { - if (typeof value === "string") { - if (value.startsWith("data:image/")) return value - if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;") - if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;") - if (value.startsWith("data:audio/")) 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 - const mime = normalizeMimeType(mimeType) - if (!mime) return - if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return - - return `data:${mime};base64,${content}` +function ReviewCommentMenu(props: { + labels: SessionReviewCommentActions + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( + <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + <DropdownMenu gutter={4} placement="bottom-end"> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + size="small" + class="size-6 rounded-md" + aria-label={props.labels.moreLabel} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content> + <DropdownMenu.Item onSelect={props.onEdit}> + <DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item onSelect={props.onDelete}> + <DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + ) } function diffId(file: string): string | undefined { @@ -137,62 +132,37 @@ type SessionReviewSelection = { range: SelectedLineRange } -function findSide(element: HTMLElement): "additions" | "deletions" | undefined { - const typed = element.closest("[data-line-type]") - if (typed instanceof HTMLElement) { - const type = typed.dataset.lineType - if (type === "change-deletion") return "deletions" - if (type === "change-addition" || type === "change-additions") return "additions" - } - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return - return code.hasAttribute("data-deletions") ? "deletions" : "additions" -} - -function findMarker(root: ShadowRoot, range: SelectedLineRange) { - const marker = (line: number, side?: "additions" | "deletions") => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - if (!side) return nodes[0] - const match = nodes.find((node) => findSide(node) === side) - return match ?? nodes[0] - } - - const a = marker(range.start, range.side) - const b = marker(range.end, range.endSide ?? range.side) - if (!a) return b - if (!b) return a - return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b -} - -function 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) -} - export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined + let searchInput: HTMLInputElement | undefined let focusToken = 0 + let revealToken = 0 + let highlightedFile: string | undefined const i18n = useI18n() - const diffComponent = useDiffComponent() + const fileComponent = useFileComponent() const anchors = new Map<string, HTMLElement>() - const [store, setStore] = createStore({ + const searchHandles = new Map<string, FileSearchHandle>() + const readyFiles = new Set<string>() + const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), + force: {}, }) const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null) const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null) const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null) + const [searchOpen, setSearchOpen] = createSignal(false) + const [searchQuery, setSearchQuery] = createSignal("") + const [searchActive, setSearchActive] = createSignal(0) + const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 }) const open = () => props.open ?? store.open const files = createMemo(() => props.diffs.map((d) => d.file)) const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const))) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 + const searchValue = createMemo(() => searchQuery().trim()) + const searchExpanded = createMemo(() => searchValue().length > 0) const handleChange = (open: string[]) => { props.onOpenChange?.(open) @@ -205,13 +175,259 @@ export const SessionReview = (props: SessionReviewProps) => { handleChange(next) } - const selectionLabel = (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 clearViewerSearch = () => { + for (const handle of searchHandles.values()) handle.clear() + highlightedFile = undefined + } + + const focusSearch = () => { + if (!hasDiffs()) return + setSearchOpen(true) + requestAnimationFrame(() => { + searchInput?.focus() + searchInput?.select() + }) + } + + const closeSearch = () => { + revealToken++ + setSearchOpen(false) + setSearchQuery("") + setSearchActive(0) + clearViewerSearch() } + const positionSearchBar = () => { + if (typeof window === "undefined") return + if (!scroll) return + + const rect = scroll.getBoundingClientRect() + const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height")) + const header = Number.isNaN(title) ? 0 : title + setSearchPos({ + top: Math.round(rect.top) + header - 4, + right: Math.round(window.innerWidth - rect.right) + 8, + }) + } + + const searchHits = createMemo(() => + buildSessionSearchHits({ + query: searchQuery(), + files: props.diffs.flatMap((diff) => { + if (mediaKindFromPath(diff.file)) return [] + + return [ + { + file: diff.file, + before: typeof diff.before === "string" ? diff.before : undefined, + after: typeof diff.after === "string" ? diff.after : undefined, + }, + ] + }), + }), + ) + + const waitForViewer = (file: string, token: number) => + new Promise<FileSearchHandle | undefined>((resolve) => { + let attempt = 0 + + const tick = () => { + if (token !== revealToken) { + resolve(undefined) + return + } + + const handle = searchHandles.get(file) + if (handle && readyFiles.has(file)) { + resolve(handle) + return + } + + if (attempt >= 180) { + resolve(undefined) + return + } + + attempt++ + requestAnimationFrame(tick) + } + + tick() + }) + + const waitForFrames = (count: number, token: number) => + new Promise<boolean>((resolve) => { + const tick = (left: number) => { + if (token !== revealToken) { + resolve(false) + return + } + + if (left <= 0) { + resolve(true) + return + } + + requestAnimationFrame(() => tick(left - 1)) + } + + tick(count) + }) + + const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => { + const diff = diffs().get(hit.file) + if (!diff) return + + if (!open().includes(hit.file)) { + handleChange([...open(), hit.file]) + } + + if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) { + setStore("force", hit.file, true) + } + + const handle = await waitForViewer(hit.file, token) + if (!handle || token !== revealToken) return + if (searchValue() !== query) return + if (!(await waitForFrames(2, token))) return + + if (highlightedFile && highlightedFile !== hit.file) { + searchHandles.get(highlightedFile)?.clear() + highlightedFile = undefined + } + + anchors.get(hit.file)?.scrollIntoView({ block: "nearest" }) + + let done = false + for (let i = 0; i < 4; i++) { + if (token !== revealToken) return + if (searchValue() !== query) return + + handle.setQuery(query) + if (handle.reveal(hit)) { + done = true + break + } + + const expanded = handle.expand(hit) + handle.refresh() + if (!(await waitForFrames(expanded ? 2 : 1, token))) return + } + + if (!done) return + + if (!(await waitForFrames(1, token))) return + handle.reveal(hit) + + highlightedFile = hit.file + } + + const navigateSearch = (dir: 1 | -1) => { + const total = searchHits().length + if (total <= 0) return + setSearchActive((value) => stepSessionSearchIndex(total, value, dir)) + } + + const inReview = (node: unknown, path?: unknown[]) => { + if (node === searchInput) return true + if (path?.some((item) => item === scroll || item === searchInput)) return true + if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) { + return true + } + if (!(node instanceof Node)) return false + if (searchInput?.contains(node)) return true + if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true + if (!scroll) return false + return scroll.contains(node) + } + + createEffect(() => { + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + + const mod = event.metaKey || event.ctrlKey + if (!mod) return + + const key = event.key.toLowerCase() + if (key !== "f" && key !== "g") return + + if (key === "f") { + if (!hasDiffs()) return + event.preventDefault() + event.stopPropagation() + focusSearch() + return + } + + const path = typeof event.composedPath === "function" ? event.composedPath() : undefined + if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return + if (!searchOpen()) return + event.preventDefault() + event.stopPropagation() + navigateSearch(event.shiftKey ? -1 : 1) + } + + window.addEventListener("keydown", onKeyDown, { capture: true }) + onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) + }) + + createEffect(() => { + diffStyle() + searchExpanded() + readyFiles.clear() + }) + + createEffect(() => { + if (!searchOpen()) return + if (!scroll) return + + const root = scroll + + requestAnimationFrame(positionSearchBar) + window.addEventListener("resize", positionSearchBar, { passive: true }) + const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar) + observer?.observe(root) + + onCleanup(() => { + window.removeEventListener("resize", positionSearchBar) + observer?.disconnect() + }) + }) + + createEffect(() => { + const total = searchHits().length + if (total === 0) { + if (searchActive() !== 0) setSearchActive(0) + return + } + + if (searchActive() >= total) setSearchActive(total - 1) + }) + + createEffect(() => { + diffStyle() + const query = searchValue() + const hits = searchHits() + const token = ++revealToken + if (!query || hits.length === 0) { + clearViewerSearch() + return + } + + const hit = hits[Math.min(searchActive(), hits.length - 1)] + if (!hit) return + void revealSearchHit(token, hit, query) + }) + + onCleanup(() => { + revealToken++ + clearViewerSearch() + readyFiles.clear() + searchHandles.clear() + }) + const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => { @@ -219,11 +435,7 @@ export const SessionReview = (props: SessionReviewProps) => { const contents = side === "deletions" ? diff.before : diff.after if (typeof contents !== "string" || contents.length === 0) return undefined - const start = Math.max(1, Math.min(range.start, range.end)) - const end = Math.max(range.start, range.end) - const lines = contents.split("\n").slice(start - 1, end) - if (lines.length === 0) return undefined - return lines.slice(0, 2).join("\n") + return previewSelectedLines(contents, range) } createEffect(() => { @@ -236,7 +448,7 @@ export const SessionReview = (props: SessionReviewProps) => { setOpened(focus) const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) - if (comment) setSelection({ file: comment.file, range: comment.selection }) + if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) }) const current = open() if (!current.includes(focus.file)) { @@ -249,11 +461,11 @@ export const SessionReview = (props: SessionReviewProps) => { const root = scroll if (!root) return - const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) - const ready = - anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0" + const wrapper = anchors.get(focus.file) + const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`) + const ready = anchor instanceof HTMLElement - const target = ready ? anchor : anchors.get(focus.file) + const target = ready ? anchor : wrapper if (!target) { if (attempt >= 120) return requestAnimationFrame(() => scrollTo(attempt + 1)) @@ -276,6 +488,58 @@ export const SessionReview = (props: SessionReviewProps) => { requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) }) + const handleReviewKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + + const mod = event.metaKey || event.ctrlKey + const key = event.key.toLowerCase() + const target = event.target + if (mod && key === "f") { + event.preventDefault() + event.stopPropagation() + focusSearch() + return + } + + if (mod && key === "g") { + if (!searchOpen()) return + event.preventDefault() + event.stopPropagation() + navigateSearch(event.shiftKey ? -1 : 1) + } + } + + const handleSearchInputKeyDown = (event: KeyboardEvent) => { + const mod = event.metaKey || event.ctrlKey + const key = event.key.toLowerCase() + + if (mod && key === "g") { + event.preventDefault() + event.stopPropagation() + navigateSearch(event.shiftKey ? -1 : 1) + return + } + + if (mod && key === "f") { + event.preventDefault() + event.stopPropagation() + focusSearch() + return + } + + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + closeSearch() + return + } + + if (event.key !== "Enter") return + event.preventDefault() + event.stopPropagation() + navigateSearch(event.shiftKey ? -1 : 1) + } + return ( <ScrollView data-component="session-review" @@ -284,6 +548,7 @@ export const SessionReview = (props: SessionReviewProps) => { props.scrollRef?.(el) }} onScroll={props.onScroll as any} + onKeyDown={handleReviewKeyDown} classList={{ ...(props.classList ?? {}), [props.classes?.root ?? ""]: !!props.classes?.root, @@ -321,6 +586,25 @@ export const SessionReview = (props: SessionReviewProps) => { {props.actions} </div> </div> + <Show when={searchOpen()}> + <FileSearchBar + pos={searchPos} + query={searchQuery} + index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)} + count={() => searchHits().length} + setInput={(el) => { + searchInput = el + }} + onInput={(value) => { + setSearchQuery(value) + setSearchActive(0) + }} + onKeyDown={(event) => handleSearchInputKeyDown(event)} + onClose={closeSearch} + onPrev={() => navigateSearch(-1)} + onNext={() => navigateSearch(1)} + /> + </Show> <div data-slot="session-review-container" class={props.classes?.container}> <Show when={hasDiffs()} fallback={props.empty}> <Accordion multiple value={open()} onChange={handleChange}> @@ -332,7 +616,7 @@ export const SessionReview = (props: SessionReviewProps) => { const item = () => diff()! const expanded = createMemo(() => open().includes(file)) - const [force, setForce] = createSignal(false) + const force = () => !!store.force[file] const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) const commentedLines = createMemo(() => comments().map((c) => c.selection)) @@ -340,28 +624,18 @@ export const SessionReview = (props: SessionReviewProps) => { const beforeText = () => (typeof item().before === "string" ? item().before : "") const afterText = () => (typeof item().after === "string" ? item().after : "") const changedLines = () => item().additions + item().deletions + const mediaKind = createMemo(() => mediaKindFromPath(file)) const tooLarge = createMemo(() => { if (!expanded()) return false if (force()) return false - if (isImageFile(file)) return false + if (mediaKind()) return false return changedLines() > MAX_DIFF_CHANGED_LINES }) const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0) const isDeleted = () => item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0) - const isImage = () => isImageFile(file) - const isAudio = () => isAudioFile(file) - - const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before)) - const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc()) - const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle") - - const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before)) - const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc()) - const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") - const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined) const selectedLines = createMemo(() => { const current = selection() @@ -375,164 +649,74 @@ export const SessionReview = (props: SessionReviewProps) => { return current.range }) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal<Record<string, number>>({}) - const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined) - - const getRoot = () => { - const el = wrapper - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - return host.shadowRoot ?? undefined - } - - const updateAnchors = () => { - const el = wrapper - if (!el) return - - const root = getRoot() - if (!root) return - - const next: Record<string, number> = {} - for (const item of comments()) { - const marker = findMarker(root, item.selection) - if (!marker) continue - next[item.id] = markerTop(el, marker) - } - setPositions(next) - - const range = draftRange() - if (!range) { - setDraftTop(undefined) - return - } - - const marker = findMarker(root, range) - if (!marker) { - setDraftTop(undefined) - return - } - - setDraftTop(markerTop(el, marker)) - } - - const scheduleAnchors = () => { - requestAnimationFrame(updateAnchors) - } - - createEffect(() => { - if (!isImage()) return - const src = diffImageSrc() - setImageSrc(src) - setImageStatus("idle") - }) - - createEffect(() => { - if (!isAudio()) return - const src = diffAudioSrc() - setAudioSrc(src) - setAudioStatus("idle") - setAudioMime(undefined) - }) - - createEffect(() => { - comments() - scheduleAnchors() - }) - - createEffect(() => { - const range = draftRange() - if (!range) return - setDraft("") - scheduleAnchors() - }) - - createEffect(() => { - if (!open().includes(file)) return - if (!isImage()) return - if (imageSrc()) return - if (imageStatus() !== "idle") return - if (isDeleted()) return - - const reader = props.readFile - if (!reader) return - - setImageStatus("loading") - reader(file) - .then((result) => { - const src = dataUrl(result) - if (!src) { - setImageStatus("error") - return - } - setImageSrc(src) - setImageStatus("idle") + const commentsUi = createLineCommentController<SessionReviewComment>({ + comments, + label: i18n.t("ui.lineComment.submit"), + draftKey: () => file, + state: { + opened: () => { + const current = opened() + if (!current || current.file !== file) return null + return current.id + }, + setOpened: (id) => setOpened(id ? { file, id } : null), + selected: selectedLines, + setSelected: (range) => setSelection(range ? { file, range } : null), + commenting: draftRange, + setCommenting: (range) => setCommenting(range ? { file, range } : null), + }, + getSide: selectionSide, + clearSelectionOnSelectionEndNull: false, + onSubmit: ({ comment, selection }) => { + props.onLineComment?.({ + file, + selection, + comment, + preview: selectionPreview(item(), selection), + }) + }, + onUpdate: ({ id, comment, selection }) => { + props.onLineCommentUpdate?.({ + id, + file, + selection, + comment, + preview: selectionPreview(item(), selection), }) - .catch(() => { - setImageStatus("error") + }, + onDelete: (comment) => { + props.onLineCommentDelete?.({ + id: comment.id, + file, }) + }, + editSubmitLabel: props.lineCommentActions?.saveLabel, + renderCommentActions: props.lineCommentActions + ? (comment, controls) => ( + <ReviewCommentMenu + labels={props.lineCommentActions!} + onEdit={controls.edit} + onDelete={controls.remove} + /> + ) + : undefined, }) - createEffect(() => { - if (!open().includes(file)) return - if (!isAudio()) return - if (audioSrc()) return - if (audioStatus() !== "idle") return - - const reader = props.readFile - if (!reader) return - - setAudioStatus("loading") - reader(file) - .then((result) => { - const src = dataUrl(result) - if (!src) { - setAudioStatus("error") - return - } - setAudioMime(normalizeMimeType(result?.mimeType)) - setAudioSrc(src) - setAudioStatus("idle") - }) - .catch(() => { - setAudioStatus("error") - }) + onCleanup(() => { + anchors.delete(file) + readyFiles.delete(file) + searchHandles.delete(file) + if (highlightedFile === file) highlightedFile = undefined }) const handleLineSelected = (range: SelectedLineRange | null) => { if (!props.onLineComment) return - - if (!range) { - setSelection(null) - return - } - - setSelection({ file, range }) + commentsUi.onLineSelected(range) } const handleLineSelectionEnd = (range: SelectedLineRange | null) => { if (!props.onLineComment) return - - if (!range) { - setCommenting(null) - return - } - - setSelection({ file, range }) - setCommenting({ file, range }) - } - - const openComment = (comment: SessionReviewComment) => { - setOpened({ file: comment.file, id: comment.id }) - setSelection({ file: comment.file, range: comment.selection }) - } - - const isCommentOpen = (comment: SessionReviewComment) => { - const current = opened() - if (!current) return false - return current.file === comment.file && current.id === comment.id + commentsUi.onLineSelectionEnd(range) } return ( @@ -585,7 +769,7 @@ export const SessionReview = (props: SessionReviewProps) => { {i18n.t("ui.sessionReview.change.removed")} </span> </Match> - <Match when={isImage()}> + <Match when={!!mediaKind()}> <span data-slot="session-review-change" data-type="modified"> {i18n.t("ui.sessionReview.change.modified")} </span> @@ -607,33 +791,11 @@ export const SessionReview = (props: SessionReviewProps) => { ref={(el) => { wrapper = el anchors.set(file, el) - scheduleAnchors() }} > <Show when={expanded()}> <Switch> - <Match when={isImage() && imageSrc()}> - <div data-slot="session-review-image-container"> - <img data-slot="session-review-image" src={imageSrc()} alt={file} /> - </div> - </Match> - <Match when={isImage() && isDeleted()}> - <div data-slot="session-review-image-container" data-removed> - <span data-slot="session-review-image-placeholder"> - {i18n.t("ui.sessionReview.change.removed")} - </span> - </div> - </Match> - <Match when={isImage() && !imageSrc()}> - <div data-slot="session-review-image-container"> - <span data-slot="session-review-image-placeholder"> - {imageStatus() === "loading" - ? i18n.t("ui.sessionReview.image.loading") - : i18n.t("ui.sessionReview.image.placeholder")} - </span> - </div> - </Match> - <Match when={!isImage() && tooLarge()}> + <Match when={tooLarge()}> <div data-slot="session-review-large-diff"> <div data-slot="session-review-large-diff-title"> {i18n.t("ui.sessionReview.largeDiff.title")} @@ -645,26 +807,52 @@ export const SessionReview = (props: SessionReviewProps) => { })} </div> <div data-slot="session-review-large-diff-actions"> - <Button size="normal" variant="secondary" onClick={() => setForce(true)}> + <Button + size="normal" + variant="secondary" + onClick={() => setStore("force", file, true)} + > {i18n.t("ui.sessionReview.largeDiff.renderAnyway")} </Button> </div> </div> </Match> - <Match when={!isImage()}> + <Match when={true}> <Dynamic - component={diffComponent} + component={fileComponent} + mode="diff" preloadedDiff={item().preloaded} diffStyle={diffStyle()} + expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20} onRendered={() => { + readyFiles.add(file) props.onDiffRendered?.() - scheduleAnchors() }} enableLineSelection={props.onLineComment != null} + enableHoverUtility={props.onLineComment != null} onLineSelected={handleLineSelected} onLineSelectionEnd={handleLineSelectionEnd} + onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined} selectedLines={selectedLines()} commentedLines={commentedLines()} + search={{ + shortcuts: "disabled", + showBar: false, + disableVirtualization: searchExpanded(), + register: (handle: FileSearchHandle | null) => { + if (!handle) { + searchHandles.delete(file) + readyFiles.delete(file) + if (highlightedFile === file) highlightedFile = undefined + return + } + + searchHandles.set(file, handle) + }, + }} before={{ name: file, contents: typeof item().before === "string" ? item().before : "", @@ -673,53 +861,16 @@ export const SessionReview = (props: SessionReviewProps) => { name: file, contents: typeof item().after === "string" ? item().after : "", }} + media={{ + mode: "auto", + path: file, + before: item().before, + after: item().after, + readFile: props.readFile, + }} /> </Match> </Switch> - - <For each={comments()}> - {(comment) => ( - <LineComment - id={comment.id} - top={positions()[comment.id]} - onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })} - onClick={() => { - if (isCommentOpen(comment)) { - setOpened(null) - return - } - - openComment(comment) - }} - open={isCommentOpen(comment)} - comment={comment.comment} - selection={selectionLabel(comment.selection)} - /> - )} - </For> - - <Show when={draftRange()}> - {(range) => ( - <Show when={draftTop() !== undefined}> - <LineCommentEditor - top={draftTop()} - value={draft()} - selection={selectionLabel(range())} - onInput={setDraft} - onCancel={() => setCommenting(null)} - onSubmit={(comment) => { - props.onLineComment?.({ - file, - selection: range(), - comment, - preview: selectionPreview(item(), range()), - }) - setCommenting(null) - }} - /> - </Show> - )} - </Show> </Show> </div> </Accordion.Content> |
