diff options
| author | Adam <[email protected]> | 2026-01-21 05:27:52 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | 0ce0cacb282c47943348a2af21ea00e721bcb9d9 (patch) | |
| tree | e1c17ec3dc03ce1fd86f348059a6401e700eb60d /packages/ui/src/components | |
| parent | 640d1f1ecc7a2b46fb2bafed760c7348c70579a8 (diff) | |
| download | opencode-0ce0cacb282c47943348a2af21ea00e721bcb9d9.tar.gz opencode-0ce0cacb282c47943348a2af21ea00e721bcb9d9.zip | |
wip(app): line selection
Diffstat (limited to 'packages/ui/src/components')
| -rw-r--r-- | packages/ui/src/components/diff-ssr.tsx | 21 | ||||
| -rw-r--r-- | packages/ui/src/components/diff.tsx | 285 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 197 |
3 files changed, 496 insertions, 7 deletions
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 56a12c100..da99ba3b7 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,6 +1,6 @@ import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" -import { onCleanup, onMount, Show, splitProps } from "solid-js" +import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" import { useWorkerPool } from "../context/worker-pool" @@ -12,7 +12,14 @@ export type SSRDiffProps<T = {}> = DiffProps<T> & { export function Diff<T>(props: SSRDiffProps<T>) { let container!: HTMLDivElement let fileDiffRef!: HTMLElement - const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) + const [local, others] = splitProps(props, [ + "before", + "after", + "class", + "classList", + "annotations", + "selectedLines", + ]) const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff<T> | undefined @@ -38,6 +45,16 @@ export function Diff<T>(props: SSRDiffProps<T>) { containerWrapper: container, }) + fileDiffInstance.setSelectedLines(local.selectedLines ?? null) + + createEffect(() => { + fileDiffInstance?.setLineAnnotations(local.annotations ?? []) + }) + + createEffect(() => { + fileDiffInstance?.setSelectedLines(local.selectedLines ?? null) + }) + // Hydrate annotation slots with interactive SolidJS components // if (props.annotations.length > 0 && props.renderAnnotation != null) { // for (const annotation of props.annotations) { diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 46b6709b6..20dd5c440 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,16 +1,70 @@ import { checksum } from "@opencode-ai/util/encode" -import { FileDiff } from "@pierre/diffs" +import { FileDiff, type SelectedLineRange } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" -import { createEffect, createMemo, onCleanup, splitProps } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" +type SelectionSide = "additions" | "deletions" + +function findElement(node: Node | null): HTMLElement | undefined { + if (!node) return + if (node instanceof HTMLElement) return node + return node.parentElement ?? undefined +} + +function findLineNumber(node: Node | null): number | undefined { + const element = findElement(node) + if (!element) return + + const line = element.closest("[data-line], [data-alt-line]") + if (!(line instanceof HTMLElement)) return + + const value = (() => { + const primary = parseInt(line.dataset.line ?? "", 10) + if (!Number.isNaN(primary)) return primary + + const alt = parseInt(line.dataset.altLine ?? "", 10) + if (!Number.isNaN(alt)) return alt + })() + + return value +} + +function findSide(node: Node | null): SelectionSide | undefined { + const element = findElement(node) + if (!element) return + + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return + + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" +} + export function Diff<T>(props: DiffProps<T>) { let container!: HTMLDivElement let observer: MutationObserver | undefined let renderToken = 0 - - const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations", "onRendered"]) + let selectionFrame: number | undefined + let dragFrame: number | undefined + let dragStart: number | undefined + let dragEnd: number | undefined + let dragSide: SelectionSide | undefined + let dragEndSide: SelectionSide | undefined + let dragMoved = false + let lastSelection: SelectedLineRange | null = null + let pendingSelectionEnd = false + + const [local, others] = splitProps(props, [ + "before", + "after", + "class", + "classList", + "annotations", + "selectedLines", + "onRendered", + ]) const mobile = createMediaQuery("(max-width: 640px)") @@ -27,6 +81,7 @@ export function Diff<T>(props: DiffProps<T>) { }) let instance: FileDiff<T> | undefined + const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -117,6 +172,186 @@ export function Diff<T>(props: DiffProps<T>) { observer.observe(container, { childList: true, subtree: true }) } + const setSelectedLines = (range: SelectedLineRange | null) => { + const active = current() + if (!active) return + lastSelection = range + active.setSelectedLines(range) + } + + const updateSelection = () => { + const root = getRoot() + if (!root) return + + const selection = + (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() + if (!selection || selection.isCollapsed) return + + const domRange = + ( + selection as unknown as { + getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] + } + ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? + (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) + + const startNode = domRange?.startContainer ?? selection.anchorNode + const endNode = domRange?.endContainer ?? selection.focusNode + if (!startNode || !endNode) return + + if (!root.contains(startNode) || !root.contains(endNode)) return + + const start = findLineNumber(startNode) + const end = findLineNumber(endNode) + if (start === undefined || end === undefined) return + + const startSide = findSide(startNode) + const endSide = findSide(endNode) + const side = startSide ?? endSide + + const selected: SelectedLineRange = { + start, + end, + } + + if (side) selected.side = side + if (endSide && side && endSide !== side) selected.endSide = endSide + + setSelectedLines(selected) + } + + const scheduleSelectionUpdate = () => { + if (selectionFrame !== undefined) return + + selectionFrame = requestAnimationFrame(() => { + selectionFrame = undefined + updateSelection() + + if (!pendingSelectionEnd) return + pendingSelectionEnd = false + props.onLineSelectionEnd?.(lastSelection) + }) + } + + const updateDragSelection = () => { + if (dragStart === undefined || dragEnd === undefined) return + + const selected: SelectedLineRange = { + start: dragStart, + end: dragEnd, + } + + if (dragSide) selected.side = dragSide + if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide + + setSelectedLines(selected) + } + + const scheduleDragUpdate = () => { + if (dragFrame !== undefined) return + + dragFrame = requestAnimationFrame(() => { + dragFrame = undefined + updateDragSelection() + }) + } + + const lineFromMouseEvent = (event: MouseEvent) => { + const path = event.composedPath() + + let numberColumn = false + let line: number | undefined + let side: SelectionSide | undefined + + for (const item of path) { + if (!(item instanceof HTMLElement)) continue + + numberColumn = numberColumn || item.dataset.columnNumber != null + + if (side === undefined && item.dataset.code != null) { + side = item.hasAttribute("data-deletions") ? "deletions" : "additions" + } + + if (line === undefined) { + const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN + if (!Number.isNaN(primary)) { + line = primary + } else { + const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN + if (!Number.isNaN(alt)) line = alt + } + } + + if (numberColumn && line !== undefined && side !== undefined) break + } + + return { line, numberColumn, side } + } + + const handleMouseDown = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (event.button !== 0) return + + const { line, numberColumn, side } = lineFromMouseEvent(event) + if (numberColumn) return + if (line === undefined) return + + dragStart = line + dragEnd = line + dragSide = side + dragEndSide = side + dragMoved = false + } + + const handleMouseMove = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (dragStart === undefined) return + + if ((event.buttons & 1) === 0) { + dragStart = undefined + dragEnd = undefined + dragSide = undefined + dragEndSide = undefined + dragMoved = false + return + } + + const { line, side } = lineFromMouseEvent(event) + if (line === undefined) return + + dragEnd = line + dragEndSide = side + dragMoved = true + scheduleDragUpdate() + } + + const handleMouseUp = () => { + if (props.enableLineSelection !== true) return + if (dragStart === undefined) return + + if (dragMoved) { + pendingSelectionEnd = true + scheduleDragUpdate() + scheduleSelectionUpdate() + } + + dragStart = undefined + dragEnd = undefined + dragSide = undefined + dragEndSide = undefined + dragMoved = false + } + + const handleSelectionChange = () => { + if (props.enableLineSelection !== true) return + if (dragStart === undefined) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed) return + + scheduleSelectionUpdate() + } + createEffect(() => { const opts = options() const workerPool = getWorkerPool(props.diffStyle) @@ -126,6 +361,7 @@ export function Diff<T>(props: DiffProps<T>) { instance?.cleanUp() instance = new FileDiff<T>(opts, workerPool) + setCurrent(instance) container.innerHTML = "" instance.render({ @@ -146,9 +382,50 @@ export function Diff<T>(props: DiffProps<T>) { notifyRendered() }) + createEffect(() => { + const selected = local.selectedLines ?? null + setSelectedLines(selected) + }) + + createEffect(() => { + if (props.enableLineSelection !== true) return + + container.addEventListener("mousedown", handleMouseDown) + container.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + document.addEventListener("selectionchange", handleSelectionChange) + + onCleanup(() => { + container.removeEventListener("mousedown", handleMouseDown) + container.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + document.removeEventListener("selectionchange", handleSelectionChange) + }) + }) + onCleanup(() => { observer?.disconnect() + + if (selectionFrame !== undefined) { + cancelAnimationFrame(selectionFrame) + selectionFrame = undefined + } + + if (dragFrame !== undefined) { + cancelAnimationFrame(dragFrame) + dragFrame = undefined + } + + dragStart = undefined + dragEnd = undefined + dragSide = undefined + dragEndSide = undefined + dragMoved = false + lastSelection = null + pendingSelectionEnd = false + instance?.cleanUp() + setCurrent(undefined) }) return <div data-component="diff" style={styleVariables} ref={container} /> diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index c47d11d08..814281723 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -10,10 +10,11 @@ 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 { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, 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 DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" export type SessionReviewDiffStyle = "unified" | "split" @@ -23,6 +24,7 @@ export interface SessionReviewProps { diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void + onLineComment?: (comment: SessionReviewLineComment) => void open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -98,6 +100,25 @@ function dataUrlFromValue(value: unknown): string | undefined { return `data:${mime};base64,${content}` } +type SessionReviewSelection = { + file: string + range: SelectedLineRange +} + +type SessionReviewLineComment = { + file: string + selection: SelectedLineRange + comment: string + preview?: string +} + +type CommentAnnotationMeta = { + file: string + selection: SelectedLineRange + label: string + preview?: string +} + export const SessionReview = (props: SessionReviewProps) => { const i18n = useI18n() const diffComponent = useDiffComponent() @@ -105,6 +126,8 @@ export const SessionReview = (props: SessionReviewProps) => { const [store, setStore] = createStore({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), }) + const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null) + const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null) const open = () => props.open ?? store.open const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") @@ -120,6 +143,113 @@ 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 isRangeEqual = (a: SelectedLineRange, b: SelectedLineRange) => + a.start === b.start && a.end === b.end && a.side === b.side && a.endSide === b.endSide + + const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" + + const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => { + const side = selectionSide(range) + 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") + } + + const renderAnnotation = (annotation: DiffLineAnnotation<CommentAnnotationMeta>) => { + if (!props.onLineComment) return undefined + const meta = annotation.metadata + if (!meta) return undefined + + const wrapper = document.createElement("div") + wrapper.className = "relative" + + const card = document.createElement("div") + card.className = + "min-w-[240px] max-w-[320px] flex flex-col gap-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha p-2 shadow-md" + + const textarea = document.createElement("textarea") + textarea.rows = 3 + textarea.placeholder = "Add a comment" + textarea.className = + "w-full resize-none rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong placeholder:text-text-subtle" + + const footer = document.createElement("div") + footer.className = "flex items-center justify-between gap-2 text-11-regular text-text-weak" + + const label = document.createElement("span") + label.textContent = `Commenting on ${meta.label}` + + const actions = document.createElement("div") + actions.className = "flex items-center gap-2" + + const cancel = document.createElement("button") + cancel.type = "button" + cancel.textContent = "Cancel" + cancel.className = "text-11-regular text-text-weak hover:text-text-strong" + + const submit = document.createElement("button") + submit.type = "button" + submit.textContent = "Comment" + submit.className = + "rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong hover:bg-surface-raised-base-hover" + + const updateState = () => { + const active = textarea.value.trim().length > 0 + submit.disabled = !active + submit.classList.toggle("opacity-50", !active) + submit.classList.toggle("cursor-not-allowed", !active) + } + + updateState() + textarea.addEventListener("input", updateState) + textarea.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return + if (event.shiftKey) return + event.preventDefault() + submit.click() + }) + cancel.addEventListener("click", () => { + setSelection(null) + setCommenting(null) + }) + submit.addEventListener("click", () => { + const value = textarea.value.trim() + if (!value) return + props.onLineComment?.({ + file: meta.file, + selection: meta.selection, + comment: value, + preview: meta.preview, + }) + setSelection(null) + setCommenting(null) + }) + + actions.appendChild(cancel) + actions.appendChild(submit) + footer.appendChild(label) + footer.appendChild(actions) + card.appendChild(textarea) + card.appendChild(footer) + wrapper.appendChild(card) + + requestAnimationFrame(() => textarea.focus()) + + return wrapper + } + return ( <div data-component="session-review" @@ -185,6 +315,35 @@ export const SessionReview = (props: SessionReviewProps) => { const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined) + const selectedLines = createMemo(() => { + const current = selection() + if (!current || current.file !== diff.file) return null + return current.range + }) + + const commentingLines = createMemo(() => { + const current = commenting() + if (!current || current.file !== diff.file) return null + return current.range + }) + + const annotations = createMemo<DiffLineAnnotation<CommentAnnotationMeta>[]>(() => { + const range = commentingLines() + if (!range) return [] + return [ + { + lineNumber: Math.max(range.start, range.end), + side: selectionSide(range), + metadata: { + file: diff.file, + selection: range, + label: selectionLabel(range), + preview: selectionPreview(diff, range), + }, + }, + ] + }) + createEffect(() => { if (!open().includes(diff.file)) return if (!isImage()) return @@ -245,6 +404,36 @@ export const SessionReview = (props: SessionReviewProps) => { } } + const handleLineSelected = (range: SelectedLineRange | null) => { + if (!props.onLineComment) return + + if (!range) { + setSelection(null) + setCommenting(null) + return + } + + setSelection({ file: diff.file, range }) + + const current = commenting() + if (!current) return + if (current.file !== diff.file) return + if (isRangeEqual(current.range, range)) return + setCommenting(null) + } + + const handleLineSelectionEnd = (range: SelectedLineRange | null) => { + if (!props.onLineComment) return + + if (!range) { + setCommenting(null) + return + } + + setSelection({ file: diff.file, range }) + setCommenting({ file: diff.file, range }) + } + return ( <Accordion.Item value={diff.file} data-slot="session-review-accordion-item"> <StickyAccordionHeader> @@ -348,6 +537,12 @@ export const SessionReview = (props: SessionReviewProps) => { preloadedDiff={diff.preloaded} diffStyle={diffStyle()} onRendered={props.onDiffRendered} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + annotations={annotations()} + renderAnnotation={renderAnnotation} before={{ name: diff.file!, contents: beforeText(), |
