From cb481d9ac861813d4ff091ed33bcac9e882da1a1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 21 Jan 2026 06:17:55 -0600 Subject: wip(app): line selection --- packages/ui/src/components/diff-ssr.tsx | 45 ++- packages/ui/src/components/diff.tsx | 42 +++ packages/ui/src/components/session-review.css | 99 ++++++ packages/ui/src/components/session-review.tsx | 449 +++++++++++++++++--------- packages/ui/src/pierre/index.ts | 10 + 5 files changed, 493 insertions(+), 152 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index da99ba3b7..ac98a6d24 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,4 +1,4 @@ -import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" @@ -19,12 +19,50 @@ export function Diff(props: SSRDiffProps) { "classList", "annotations", "selectedLines", + "commentedLines", ]) const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff | undefined const cleanupFunctions: Array<() => void> = [] + const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + + const findSide = (element: HTMLElement): "additions" | "deletions" => { + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" + } + + const applyCommentedLines = (ranges: SelectedLineRange[]) => { + const root = getRoot() + if (!root) return + + const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) + for (const node of existing) { + if (!(node instanceof HTMLElement)) continue + node.removeAttribute("data-comment-selected") + } + + for (const range of ranges) { + const start = Math.max(1, Math.min(range.start, range.end)) + const end = Math.max(range.start, range.end) + + for (let line = start; line <= end; line++) { + const expectedSide = + line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide) + + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + if (expectedSide && findSide(node) !== expectedSide) continue + node.setAttribute("data-comment-selected", "") + } + } + } + } + onMount(() => { if (isServer || !props.preloadedDiff) return fileDiffInstance = new FileDiff( @@ -55,6 +93,11 @@ export function Diff(props: SSRDiffProps) { fileDiffInstance?.setSelectedLines(local.selectedLines ?? null) }) + createEffect(() => { + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => applyCommentedLines(ranges)) + }) + // 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 20dd5c440..825a7e076 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -63,6 +63,7 @@ export function Diff(props: DiffProps) { "classList", "annotations", "selectedLines", + "commentedLines", "onRendered", ]) @@ -82,6 +83,7 @@ export function Diff(props: DiffProps) { let instance: FileDiff | undefined const [current, setCurrent] = createSignal | undefined>(undefined) + const [rendered, setRendered] = createSignal(0) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -172,6 +174,39 @@ export function Diff(props: DiffProps) { observer.observe(container, { childList: true, subtree: true }) } + const applyCommentedLines = (ranges: SelectedLineRange[]) => { + const root = getRoot() + if (!root) return + + const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) + for (const node of existing) { + if (!(node instanceof HTMLElement)) continue + node.removeAttribute("data-comment-selected") + } + + for (const range of ranges) { + const start = Math.max(1, Math.min(range.start, range.end)) + const end = Math.max(range.start, range.end) + + for (let line = start; line <= end; line++) { + const expectedSide = + line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide) + + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + + if (expectedSide) { + const side = findSide(node) + if (side && side !== expectedSide) continue + } + + node.setAttribute("data-comment-selected", "") + } + } + } + } + const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() if (!active) return @@ -379,9 +414,16 @@ export function Diff(props: DiffProps) { containerWrapper: container, }) + setRendered((value) => value + 1) notifyRendered() }) + createEffect(() => { + rendered() + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => applyCommentedLines(ranges)) + }) + createEffect(() => { const selected = local.selectedLines ?? null setSelectedLines(selected) diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index a53289b9a..775d3d444 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -195,4 +195,103 @@ font-size: var(--font-size-small); color: var(--text-weak); } + + [data-slot="session-review-diff-wrapper"] { + position: relative; + } + + [data-slot="session-review-comment-anchor"] { + position: absolute; + right: 12px; + z-index: 30; + } + + [data-slot="session-review-comment-button"] { + width: 20px; + height: 20px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--icon-info-active) 60%, transparent); + color: var(--icon-info-active); + box-shadow: var(--shadow-xs-border); + cursor: pointer; + + &:hover { + background: var(--surface-raised-base-hover); + border-color: var(--icon-info-active); + } + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: var(--shadow-xs-border-focus); + } + } + + [data-slot="session-review-comment-hover"] { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 320px; + } + + [data-slot="session-review-comment-hover-label"], + [data-slot="session-review-comment-popover-label"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + } + + [data-slot="session-review-comment-hover-text"], + [data-slot="session-review-comment-popover-text"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + color: var(--text-base); + white-space: pre-wrap; + } + + [data-slot="session-review-comment-preview"] { + margin: 0; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent); + color: var(--text-base); + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + line-height: 1.4; + white-space: pre-wrap; + } + + [data-slot="session-review-comment-textarea"] { + width: 320px; + max-width: calc(100vw - 48px); + resize: vertical; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent); + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: 1.4; + + &:focus { + outline: none; + box-shadow: var(--shadow-xs-border-focus); + } + } + + [data-slot="session-review-comment-actions"] { + display: flex; + justify-content: flex-end; + gap: 8px; + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 814281723..7afebdced 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -1,30 +1,49 @@ import { Accordion } from "./accordion" import { Button } from "./button" +import { HoverCard } from "./hover-card" +import { Popover } from "./popover" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { StickyAccordionHeader } from "./sticky-accordion-header" -import { useCodeComponent } from "../context/code" 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, 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 { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" export type SessionReviewDiffStyle = "unified" | "split" +export type SessionReviewComment = { + id: string + file: string + selection: SelectedLineRange + comment: string +} + +export type SessionReviewLineComment = { + file: string + selection: SelectedLineRange + comment: string + preview?: string +} + +export type SessionReviewFocus = { file: string; id: string } + export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void onLineComment?: (comment: SessionReviewLineComment) => void + comments?: SessionReviewComment[] + focusedComment?: SessionReviewFocus | null + onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -105,29 +124,43 @@ type SessionReviewSelection = { range: SelectedLineRange } -type SessionReviewLineComment = { - file: string - selection: SelectedLineRange - comment: string - preview?: string +function findSide(element: HTMLElement): "additions" | "deletions" { + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" } -type CommentAnnotationMeta = { - file: string - selection: SelectedLineRange - label: string - preview?: string +function findMarker(root: ShadowRoot, range: SelectedLineRange) { + const line = Math.max(range.start, range.end) + const side = range.endSide ?? range.side + 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] +} + +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) => { const i18n = useI18n() const diffComponent = useDiffComponent() - const codeComponent = useCodeComponent() + const anchors = new Map() const [store, setStore] = createStore({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), }) + const [selection, setSelection] = createSignal(null) const [commenting, setCommenting] = createSignal(null) + const [opened, setOpened] = createSignal(null) const open = () => props.open ?? store.open const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") @@ -150,9 +183,6 @@ export const SessionReview = (props: SessionReviewProps) => { 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) => { @@ -167,88 +197,26 @@ export const SessionReview = (props: SessionReviewProps) => { return lines.slice(0, 2).join("\n") } - const renderAnnotation = (annotation: DiffLineAnnotation) => { - 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) - } + createEffect(() => { + const focus = props.focusedComment + if (!focus) return - 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) - }) + setOpened(focus) - actions.appendChild(cancel) - actions.appendChild(submit) - footer.appendChild(label) - footer.appendChild(actions) - card.appendChild(textarea) - card.appendChild(footer) - wrapper.appendChild(card) + const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) + if (comment) setSelection({ file: comment.file, range: comment.selection }) - requestAnimationFrame(() => textarea.focus()) + const current = open() + if (!current.includes(focus.file)) { + handleChange([...current, focus.file]) + } - return wrapper - } + requestAnimationFrame(() => { + anchors.get(focus.file)?.scrollIntoView({ block: "center" }) + }) + + requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) + }) return (
{ {(diff) => { + let wrapper: HTMLDivElement | undefined + let textarea: HTMLTextAreaElement | undefined + + const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file)) + const commentedLines = createMemo(() => comments().map((c) => c.selection)) + const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const afterText = () => (typeof diff.after === "string" ? diff.after : "") @@ -321,27 +295,70 @@ export const SessionReview = (props: SessionReviewProps) => { return current.range }) - const commentingLines = createMemo(() => { + const draftRange = createMemo(() => { const current = commenting() if (!current || current.file !== diff.file) return null return current.range }) - const annotations = createMemo[]>(() => { - 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), - }, - }, - ] + const [draft, setDraft] = createSignal("") + const [positions, setPositions] = createSignal>({}) + const [draftTop, setDraftTop] = createSignal(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 = {} + 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(() => { + comments() + scheduleAnchors() + }) + + createEffect(() => { + const range = draftRange() + if (!range) return + setDraft("") + scheduleAnchors() + requestAnimationFrame(() => textarea?.focus()) }) createEffect(() => { @@ -395,31 +412,15 @@ export const SessionReview = (props: SessionReviewProps) => { }) }) - const fileForCode = () => { - const contents = afterText() || beforeText() - return { - name: diff.file, - contents, - cacheKey: checksum(contents), - } - } - 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) => { @@ -434,6 +435,17 @@ export const SessionReview = (props: SessionReviewProps) => { setCommenting({ file: diff.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 + } + return ( @@ -526,32 +538,167 @@ export const SessionReview = (props: SessionReviewProps) => {
- -
- -
-
- { + wrapper = el + anchors.set(diff.file, el) + scheduleAnchors() }} - after={{ - name: diff.file!, - contents: afterText(), - }} - /> + > + { + props.onDiffRendered?.() + scheduleAnchors() + }} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + commentedLines={commentedLines()} + before={{ + name: diff.file!, + contents: beforeText(), + }} + after={{ + name: diff.file!, + contents: afterText(), + }} + /> + + + {(comment) => ( +
+ { + if (open) { + openComment(comment) + return + } + if (!isCommentOpen(comment)) return + setOpened(null) + }} + trigger={ + + setSelection({ file: comment.file, range: comment.selection }) + } + > + + + } + > +
+
+ {getFilename(comment.file)}:{selectionLabel(comment.selection)} +
+
{comment.comment}
+
+
+ } + > +
+
+ {getFilename(comment.file)}:{selectionLabel(comment.selection)} +
+
{comment.comment}
+ + {(preview) =>
{preview()}
} +
+
+
+
+ )} +
+ + + {(range) => ( + +
+ { + if (open) return + setCommenting(null) + }} + trigger={ + + } + > +
+
+ Commenting on {getFilename(diff.file)}:{selectionLabel(range())} +
+