Compare commits

...

2 Commits

Author SHA1 Message Date
Adam
73c619ee46 wip(app): better diff/code comments 2026-02-22 06:19:09 -06:00
Adam
6ef6d538b2 wip(app): better diff/code comments 2026-02-21 19:29:27 -06:00
15 changed files with 1077 additions and 553 deletions

View File

@@ -44,6 +44,17 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: selection.start,
end: selection.end,
}
if (selection.side) next.side = selection.side
if (selection.endSide) next.endSide = selection.endSide
return next
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +81,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
selection: cloneSelection(input.selection),
}
batch(() => {

View File

@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
if (range.start <= range.end) return { ...range }
const startSide = range.side
const endSide = range.endSide ?? startSide

View File

@@ -1,12 +1,17 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createHoverCommentUtility } from "@opencode-ai/ui/pierre/comment-hover"
import { cloneSelectedLineRange, lineInSelectedRange } from "@opencode-ai/ui/pierre/selection-bridge"
import {
createLineCommentAnnotationRenderer,
type LineCommentAnnotationMeta,
} from "@opencode-ai/ui/line-comment-annotations"
import { sampledChecksum } 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 { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -97,11 +102,11 @@ export function FileTabContent(props: { tab: string }) {
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? 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) => {
@@ -145,127 +150,148 @@ export function FileTabContent(props: { tab: string }) {
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentLayout = createMemo(() => {
return fileComments()
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
.join("|")
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
type Annotation = LineCommentAnnotationMeta<ReturnType<typeof fileComments>[number]>
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
draft: "",
positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
})
const activeSelection = () => note.selected ?? selectedLines()
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
setNote("commenting", range ? cloneSelectedLineRange(range) : null)
}
const getRoot = () => {
const el = wrap
if (!el) return
createEffect(
on(
path,
() => {
setNote("selected", null)
setNote("openedComment", null)
setNote("commenting", null)
},
{ defer: true },
),
)
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
const annotations = createMemo(() => {
const list = fileComments().map((comment) => ({
lineNumber: annotationLine(comment.selection),
metadata: {
kind: "comment",
key: `comment:${comment.id}`,
comment,
} satisfies Annotation,
}))
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) {
setNote("positions", {})
setNote("draftTop", undefined)
return
if (note.commenting) {
return [
...list,
{
lineNumber: annotationLine(note.commenting),
metadata: {
kind: "draft",
key: `draft:${path() ?? props.tab}`,
range: note.commenting,
} satisfies Annotation,
},
]
}
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const range = activeSelection()
if (!range || note.openedComment) return list
return list
})
const large = contents().length > 500_000
const annotationRenderer = createLineCommentAnnotationRenderer<ReturnType<typeof fileComments>[number]>({
renderComment: (comment) => ({
id: comment.id,
open: note.openedComment === comment.id,
comment: comment.comment,
selection: formatCommentLabel(comment.selection),
onMouseEnter: () => {
const p = path()
if (!p) return
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
},
onClick: () => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
},
}),
renderDraft: (range) => ({
value: note.draft,
selection: formatCommentLabel(range),
onInput: (value) => setNote("draft", value),
onCancel: () => setCommenting(null),
onSubmit: (value) => {
const p = path()
if (!p) return
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
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
if (removed.length > 0 || changed.length > 0) {
setNote(
"positions",
produce((draft) => {
for (const id of removed) {
delete draft[id]
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null)
}
}, 0)
},
}),
})
for (const [id, top] of changed) {
draft[id] = top
}
}),
)
}
const renderAnnotation = annotationRenderer.render
const range = note.commenting
if (!range) {
setNote("draftTop", undefined)
return
}
const marker = findMarker(root, range)
if (marker) {
setNote("draftTop", markerTop(el, marker))
return
}
setNote("draftTop", large ? estimateTop(range) : undefined)
const openDraft = (range: SelectedLineRange) => {
const p = path()
if (!p) return
const next = cloneSelectedLineRange(range)
setNote("openedComment", null)
setNote("selected", next)
file.setSelectedLines(p, cloneSelectedLineRange(next))
setCommenting(next)
}
const scheduleComments = () => {
requestAnimationFrame(updateComments)
}
const renderHoverUtility = (getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" }) =>
createHoverCommentUtility({
label: language.t("ui.lineComment.submit"),
getHoveredLine,
onSelect: (hovered) => {
const selected = note.openedComment ? null : activeSelection()
const range =
selected && lineInSelectedRange(selected, hovered.lineNumber, hovered.side)
? cloneSelectedLineRange(selected)
: { start: hovered.lineNumber, end: hovered.lineNumber }
openDraft(range)
},
})
createEffect(() => {
commentLayout()
scheduleComments()
annotationRenderer.reconcile(annotations())
})
onCleanup(() => {
annotationRenderer.cleanup()
})
createEffect(() => {
@@ -279,8 +305,9 @@ export function FileTabContent(props: { tab: string }) {
if (!target) return
setNote("openedComment", target.id)
setNote("selected", cloneSelectedLineRange(target.selection))
setCommenting(null)
file.setSelectedLines(p, target.selection)
file.setSelectedLines(p, cloneSelectedLineRange(target.selection))
requestAnimationFrame(() => comments.clearFocus())
})
@@ -414,13 +441,7 @@ export function FileTabContent(props: { tab: string }) {
})
const renderCode = (source: string, wrapperClass: string) => (
<div
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
<div class={`relative overflow-hidden ${wrapperClass}`}>
<Dynamic
component={codeComponent}
file={{
@@ -429,83 +450,39 @@ export function FileTabContent(props: { tab: string }) {
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}}
annotations={annotations()}
renderAnnotation={renderAnnotation}
renderHoverUtility={renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
setNote("selected", range ? cloneSelectedLineRange(range) : null)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) return
openDraft(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
const next = range ? cloneSelectedLineRange(range) : null
setNote("selected", next)
const p = path()
if (p) file.setSelectedLines(p, next ? cloneSelectedLineRange(next) : null)
if (!next) {
setCommenting(null)
return
}
setNote("openedComment", null)
setCommenting(range)
setCommenting(null)
}}
overflow="scroll"
class="select-text"
/>
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={note.positions[comment.id]}
open={note.openedComment === comment.id}
comment={comment.comment}
selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
<Show when={note.commenting}>
{(range) => (
<Show when={note.draftTop !== undefined}>
<LineCommentEditor
top={note.draftTop}
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={() => setCommenting(null)}
onSubmit={(value) => {
const p = path()
if (!p) return
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)
}}
/>
</Show>
)}
</Show>
</div>
)

View File

@@ -12,6 +12,8 @@ import {
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedFileLines } from "../pierre/commented-lines"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
@@ -29,6 +31,7 @@ export type CodeProps<T = {}> = FileOptions<T> & {
annotations?: LineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
class?: string
@@ -155,6 +158,7 @@ export function Code<T>(props: CodeProps<T>) {
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const bridge = createLineNumberSelectionBridge()
const [local, others] = splitProps(props, [
"file",
@@ -163,6 +167,9 @@ export function Code<T>(props: CodeProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
])
@@ -198,6 +205,16 @@ export function Code<T>(props: CodeProps<T>) {
const options = createMemo(() => ({
...createDefaultOptions<T>("unified"),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelected?.(range)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelectionEnd?.(range)
if (!bridge.consume(range)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(range))
},
}))
const getRoot = () => {
@@ -556,41 +573,6 @@ export function Code<T>(props: CodeProps<T>) {
})
})
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")
}
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
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 nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(line)) continue
if (line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
const text = () => {
const value = local.file.contents as unknown
if (typeof value === "string") return value
@@ -718,7 +700,7 @@ export function Code<T>(props: CodeProps<T>) {
observer.observe(container, { childList: true, subtree: true })
}
const updateSelection = () => {
const updateSelection = (preserveTextSelection = false) => {
const root = getRoot()
if (!root) return
@@ -757,6 +739,9 @@ export function Code<T>(props: CodeProps<T>) {
if (endSide && side && endSide !== side) selected.endSide = endSide
setSelectedLines(selected)
if (!preserveTextSelection || !domRange) return
restoreShadowTextSelection(root, domRange.cloneRange())
}
const setSelectedLines = (range: SelectedLineRange | null) => {
@@ -769,11 +754,12 @@ export function Code<T>(props: CodeProps<T>) {
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
const finishing = pendingSelectionEnd
updateSelection(finishing)
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
props.onLineSelectionEnd?.(lastSelection)
local.onLineSelectionEnd?.(lastSelection)
})
}
@@ -822,9 +808,13 @@ export function Code<T>(props: CodeProps<T>) {
if (event.button !== 0) return
const { line, numberColumn } = lineFromMouseEvent(event)
if (numberColumn) return
if (numberColumn) {
bridge.begin(true, line)
return
}
if (line === undefined) return
bridge.begin(false, line)
dragStart = line
dragEnd = line
dragMoved = false
@@ -832,16 +822,21 @@ export function Code<T>(props: CodeProps<T>) {
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
bridge.finish()
return
}
const { line } = lineFromMouseEvent(event)
const { line } = next
if (line === undefined) return
dragEnd = line
@@ -851,13 +846,18 @@ export function Code<T>(props: CodeProps<T>) {
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") {
return
}
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
const line = dragStart
setSelectedLines({ start: line, end: line })
props.onLineSelectionEnd?.(lastSelection)
local.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragMoved = false
@@ -920,7 +920,7 @@ export function Code<T>(props: CodeProps<T>) {
const value = text()
instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: local.annotations,
lineAnnotations: [],
containerWrapper: container,
})
@@ -942,10 +942,22 @@ export function Code<T>(props: CodeProps<T>) {
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const active = instance
if (!active) return
active.setLineAnnotations(local.annotations ?? [])
active.rerender()
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedFileLines(root, ranges)
})
})
createEffect(() => {
@@ -998,6 +1010,7 @@ export function Code<T>(props: CodeProps<T>) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
bridge.reset()
lastSelection = null
pendingSelectionEnd = false
})

View File

@@ -3,6 +3,7 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { useWorkerPool } from "../context/worker-pool"
@@ -21,6 +22,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineNumberSelectionEnd",
])
const workerPool = useWorkerPool(props.diffStyle)
@@ -72,7 +74,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(split, node)
if (findDiffSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
@@ -121,100 +123,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
diff.setSelectedLines(fixed)
}
const findSide = (element: HTMLElement): "additions" | "deletions" => {
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.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 "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "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")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
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
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
}
}
for (const range of ranges) {
const start = rowIndex(range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
onMount(() => {
if (isServer || !props.preloadedDiff) return
@@ -261,7 +169,10 @@ export function Diff<T>(props: SSRDiffProps<T>) {
setSelectedLines(local.selectedLines ?? null)
createEffect(() => {
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
const diff = fileDiffInstance
if (!diff) return
diff.setLineAnnotations(local.annotations ?? [])
diff.rerender()
})
createEffect(() => {
@@ -270,7 +181,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
createEffect(() => {
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
})
// Hydrate annotation slots with interactive SolidJS components

View File

@@ -3,6 +3,8 @@ import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFile
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
@@ -35,19 +37,7 @@ function findLineNumber(node: Node | null): number | undefined {
function findSide(node: Node | null): SelectionSide | undefined {
const element = findElement(node)
if (!element) return
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.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
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
return findDiffSide(element)
}
export function Diff<T>(props: DiffProps<T>) {
@@ -64,6 +54,7 @@ export function Diff<T>(props: DiffProps<T>) {
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const bridge = createLineNumberSelectionBridge()
const [local, others] = splitProps(props, [
"before",
@@ -73,6 +64,9 @@ export function Diff<T>(props: DiffProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
])
@@ -94,6 +88,20 @@ export function Diff<T>(props: DiffProps<T>) {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
const fixed = fixSelection(range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelected?.(next)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
const fixed = fixSelection(range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelectionEnd?.(next)
if (!bridge.consume(next)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(next))
},
}
const perf = large() ? { ...base, ...largeOptions } : base
@@ -278,61 +286,7 @@ export function Diff<T>(props: DiffProps<T>) {
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")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
const setSelectedLines = (range: SelectedLineRange | null) => {
const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
const active = current()
if (!active) return
@@ -344,9 +298,10 @@ export function Diff<T>(props: DiffProps<T>) {
lastSelection = fixed
active.setSelectedLines(fixed)
restoreShadowTextSelection(preserve?.root, preserve?.text)
}
const updateSelection = () => {
const updateSelection = (preserveTextSelection = false) => {
const root = getRoot()
if (!root) return
@@ -384,6 +339,12 @@ export function Diff<T>(props: DiffProps<T>) {
if (side) selected.side = side
if (endSide && side && endSide !== side) selected.endSide = endSide
const text = preserveTextSelection && domRange ? domRange.cloneRange() : undefined
if (text) {
setSelectedLines(selected, { root, text })
return
}
setSelectedLines(selected)
}
@@ -392,11 +353,12 @@ export function Diff<T>(props: DiffProps<T>) {
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
const finishing = pendingSelectionEnd
updateSelection(finishing)
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
props.onLineSelectionEnd?.(lastSelection)
local.onLineSelectionEnd?.(lastSelection)
})
}
@@ -466,9 +428,13 @@ export function Diff<T>(props: DiffProps<T>) {
if (event.button !== 0) return
const { line, numberColumn, side } = lineFromMouseEvent(event)
if (numberColumn) return
if (numberColumn) {
bridge.begin(true, line)
return
}
if (line === undefined) return
bridge.begin(false, line)
dragStart = line
dragEnd = line
dragSide = side
@@ -478,6 +444,10 @@ export function Diff<T>(props: DiffProps<T>) {
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
@@ -486,10 +456,11 @@ export function Diff<T>(props: DiffProps<T>) {
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.finish()
return
}
const { line, side } = lineFromMouseEvent(event)
const { line, side } = next
if (line === undefined) return
dragEnd = line
@@ -500,6 +471,11 @@ export function Diff<T>(props: DiffProps<T>) {
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") {
return
}
if (dragStart === undefined) return
if (!dragMoved) {
@@ -511,7 +487,7 @@ export function Diff<T>(props: DiffProps<T>) {
}
if (dragSide) selected.side = dragSide
setSelectedLines(selected)
props.onLineSelectionEnd?.(lastSelection)
local.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragSide = undefined
@@ -545,7 +521,6 @@ export function Diff<T>(props: DiffProps<T>) {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
@@ -572,7 +547,7 @@ export function Diff<T>(props: DiffProps<T>) {
contents: afterContents,
cacheKey: cacheKey(afterContents),
},
lineAnnotations: annotations,
lineAnnotations: [],
containerWrapper: container,
})
@@ -594,10 +569,22 @@ export function Diff<T>(props: DiffProps<T>) {
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const active = current()
if (!active) return
active.setLineAnnotations(local.annotations ?? [])
active.rerender()
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
})
createEffect(() => {
@@ -639,6 +626,7 @@ export function Diff<T>(props: DiffProps<T>) {
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.reset()
lastSelection = null
pendingSelectionEnd = false

View File

@@ -0,0 +1,107 @@
import { type SelectedLineRange } from "@pierre/diffs"
import { createMemo, createSignal, type JSX } from "solid-js"
import { render as renderSolid } from "solid-js/web"
import { LineComment, LineCommentEditor } from "./line-comment"
export type LineCommentAnnotationMeta<T> =
| { kind: "comment"; key: string; comment: T }
| { kind: "draft"; key: string; range: SelectedLineRange }
type CommentProps = {
id?: string
open: boolean
comment: JSX.Element
selection: JSX.Element
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}
type DraftProps = {
value: string
selection: JSX.Element
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
}
export function createLineCommentAnnotationRenderer<T>(props: {
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const nodes = new Map<
string,
{
host: HTMLDivElement
dispose: VoidFunction
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
}
>()
const mount = (meta: LineCommentAnnotationMeta<T>) => {
if (typeof document === "undefined") return
const host = document.createElement("div")
const [current, setCurrent] = createSignal(meta)
const view = createMemo<JSX.Element>(() => {
const next = current()
if (next.kind === "comment") {
const view = props.renderComment(next.comment)
return (
<LineComment
inline
id={view.id}
open={view.open}
comment={view.comment}
selection={view.selection}
onClick={view.onClick}
onMouseEnter={view.onMouseEnter}
/>
)
}
const view = props.renderDraft(next.range)
return (
<LineCommentEditor
inline
value={view.value}
selection={view.selection}
onInput={view.onInput}
onCancel={view.onCancel}
onSubmit={view.onSubmit}
onPopoverFocusOut={view.onPopoverFocusOut}
/>
)
})
const dispose = renderSolid(() => <>{view()}</>, host)
const node = { host, dispose, setMeta: setCurrent }
nodes.set(meta.key, node)
return node
}
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
const meta = annotation.metadata
const node = nodes.get(meta.key) ?? mount(meta)
if (!node) return
node.setMeta(meta)
return node.host
}
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
for (const [key, node] of nodes) {
if (next.has(key)) continue
node.dispose()
nodes.delete(key)
}
}
const cleanup = () => {
for (const [, node] of nodes) node.dispose()
nodes.clear()
}
return { render, reconcile, cleanup }
}

View File

@@ -1,9 +1,17 @@
export const lineCommentStyles = `
[data-component="line-comment"] {
position: absolute;
right: 24px;
z-index: var(--line-comment-z, 30);
}
[data-component="line-comment"][data-inline] {
position: relative;
right: auto;
display: inline-flex;
align-items: flex-start;
}
[data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100);
}
@@ -21,10 +29,20 @@
border: none;
}
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
background: var(--syntax-diff-add);
}
[data-component="line-comment"] [data-component="icon"] {
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-icon"] {
width: 12px;
height: 12px;
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
outline: none;
}
@@ -46,6 +64,21 @@
padding: 12px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
position: relative;
top: auto;
right: auto;
margin-left: 8px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
margin-left: 0;
}
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
cursor: pointer;
}
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 380px;
max-width: min(380px, calc(100vw - 48px));
@@ -113,3 +146,50 @@
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
margin-right: auto;
}
[data-component="line-comment"] [data-slot="line-comment-action"] {
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-strong);
border-radius: var(--radius-md);
height: 28px;
padding: 0 10px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
background: transparent;
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
background: var(--text-strong);
border-color: var(--text-strong);
color: var(--background-base);
}
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
opacity: 0.5;
pointer-events: none;
}
`
let installed = false
export function installLineCommentStyles() {
if (installed) return
if (typeof document === "undefined") return
const id = "opencode-line-comment-styles"
if (document.getElementById(id)) {
installed = true
return
}
const style = document.createElement("style")
style.id = id
style.textContent = lineCommentStyles
document.head.appendChild(style)
installed = true
}

View File

@@ -1,26 +1,54 @@
import { onMount, Show, splitProps, type JSX } from "solid-js"
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
export type LineCommentVariant = "default" | "editor"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<Show
when={props.icon === "comment"}
fallback={
<path
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
stroke="currentColor"
stroke-linecap="square"
/>
}
>
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
</Show>
</svg>
)
}
export type LineCommentAnchorProps = {
id?: string
top?: number
inline?: boolean
hideButton?: boolean
open: boolean
variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
children: JSX.Element
children?: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => props.top === undefined
const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return (
<div
@@ -28,25 +56,65 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
style={{
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}}
style={
props.inline
? undefined
: {
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}
}
>
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
<Icon name="comment" size="small" />
</button>
<Show when={props.open}>
<Show
when={inlineBody()}
fallback={
<>
<button
type="button"
aria-label={props.buttonLabel}
data-slot="line-comment-button"
on:mousedown={(e) => e.stopPropagation()}
on:mouseup={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
>
<Show
when={props.inline}
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
>
<InlineGlyph icon={icon()} />
</Show>
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</>
}
>
<div
data-slot="line-comment-popover"
data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
onFocusOut={props.onPopoverFocusOut}
on:mousedown={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
@@ -65,7 +133,7 @@ export const LineComment = (props: LineCommentProps) => {
const [split, rest] = splitProps(props, ["comment", "selection"])
return (
<LineCommentAnchor {...rest} variant="default">
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<div data-slot="line-comment-content">
<div data-slot="line-comment-text">{split.comment}</div>
<div data-slot="line-comment-label">
@@ -78,6 +146,25 @@ export const LineComment = (props: LineCommentProps) => {
)
}
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
@@ -109,11 +196,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const refs = {
textarea: undefined as HTMLTextAreaElement | undefined,
}
const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus()
createEffect(() => {
setText(split.value)
})
const submit = () => {
const value = split.value.trim()
const value = text().trim()
if (!value) return
split.onSubmit(value)
}
@@ -124,7 +216,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
})
return (
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
<div data-slot="line-comment-editor">
<textarea
ref={(el) => {
@@ -133,19 +225,24 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-textarea"
rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
value={split.value}
onInput={(e) => split.onInput(e.currentTarget.value)}
onKeyDown={(e) => {
value={text()}
on:input={(e) => {
const value = (e.currentTarget as HTMLTextAreaElement).value
setText(value)
split.onInput(value)
}}
on:keydown={(e) => {
const event = e as KeyboardEvent
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
event.preventDefault()
event.stopPropagation()
split.onCancel()
return
}
if (e.key !== "Enter") return
if (e.shiftKey) return
e.preventDefault()
e.stopPropagation()
event.preventDefault()
event.stopPropagation()
submit()
}}
/>
@@ -155,12 +252,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
</div>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
<Show
when={!props.inline}
fallback={
<>
<button
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:click={split.onCancel as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
<button
type="button"
data-slot="line-comment-action"
data-variant="primary"
disabled={text().trim().length === 0}
on:click={submit as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>
</>
}
>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</Show>
</div>
</div>
</LineCommentAnchor>

View File

@@ -4,7 +4,6 @@ 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 { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
@@ -12,12 +11,15 @@ import { useDiffComponent } from "../context/diff"
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 { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
import { createHoverCommentUtility } from "../pierre/comment-hover"
import { cloneSelectedLineRange, lineInSelectedRange } from "../pierre/selection-bridge"
import { createLineCommentAnnotationRenderer, type LineCommentAnnotationMeta } from "./line-comment-annotations"
const MAX_DIFF_CHANGED_LINES = 500
@@ -137,42 +139,7 @@ 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)
}
type SessionReviewAnnotation = LineCommentAnnotationMeta<SessionReviewComment>
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
@@ -236,7 +203,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 +216,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))
@@ -376,51 +343,114 @@ export const SessionReview = (props: SessionReviewProps) => {
})
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 annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
const annotationSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selected = () => selectedLines()
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 annotations = createMemo<DiffLineAnnotation<SessionReviewAnnotation>[]>(() => {
const list = comments().map((comment) => ({
side: annotationSide(comment.selection),
lineNumber: annotationLine(comment.selection),
metadata: {
kind: "comment",
key: `comment:${comment.id}`,
comment,
} satisfies SessionReviewAnnotation,
}))
const range = draftRange()
if (!range) {
setDraftTop(undefined)
return
if (range) {
return [
...list,
{
side: annotationSide(range),
lineNumber: annotationLine(range),
metadata: {
kind: "draft",
key: `draft:${file}`,
range,
} satisfies SessionReviewAnnotation,
},
]
}
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
}
return list
})
setDraftTop(markerTop(el, marker))
}
const annotationRenderer = createLineCommentAnnotationRenderer<SessionReviewComment>({
renderComment: (comment) => ({
id: comment.id,
open: isCommentOpen(comment),
comment: comment.comment,
selection: selectionLabel(comment.selection),
onMouseEnter: () =>
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) }),
onClick: () => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
const scheduleAnchors = () => {
requestAnimationFrame(updateAnchors)
}
openComment(comment)
},
}),
renderDraft: (range) => ({
value: draft(),
selection: selectionLabel(range),
onInput: setDraft,
onCancel: () => {
setDraft("")
setCommenting(null)
},
onSubmit: (comment) => {
props.onLineComment?.({
file,
selection: cloneSelectedLineRange(range),
comment,
preview: selectionPreview(item(), range),
})
setDraft("")
setCommenting(null)
},
}),
})
const renderAnnotation = (annotation: DiffLineAnnotation<SessionReviewAnnotation>) =>
annotationRenderer.render(annotation)
const renderHoverUtility = (
getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" },
) =>
createHoverCommentUtility({
label: i18n.t("ui.lineComment.submit"),
getHoveredLine,
onSelect: (hovered) => {
const current = opened()?.file === file ? null : selected()
const range = (() => {
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
return cloneSelectedLineRange(current)
}
const next: SelectedLineRange = {
start: hovered.lineNumber,
end: hovered.lineNumber,
}
if (hovered.side) next.side = hovered.side
return next
})()
openDraft(range)
},
})
createEffect(() => {
annotationRenderer.reconcile(annotations())
})
onCleanup(() => {
anchors.delete(file)
annotationRenderer.cleanup()
})
createEffect(() => {
if (!isImage()) return
@@ -438,15 +468,8 @@ export const SessionReview = (props: SessionReviewProps) => {
})
createEffect(() => {
comments()
scheduleAnchors()
})
createEffect(() => {
const range = draftRange()
if (!range) return
draftRange()
setDraft("")
scheduleAnchors()
})
createEffect(() => {
@@ -506,27 +529,39 @@ export const SessionReview = (props: SessionReviewProps) => {
if (!range) {
setSelection(null)
setDraft("")
setCommenting(null)
return
}
setSelection({ file, range })
setSelection({ file, range: cloneSelectedLineRange(range) })
}
const openDraft = (range: SelectedLineRange) => {
const next = cloneSelectedLineRange(range)
setOpened(null)
setSelection({ file, range: cloneSelectedLineRange(next) })
setCommenting({ file, range: next })
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setDraft("")
setCommenting(null)
return
}
setSelection({ file, range })
setCommenting({ file, range })
const next = cloneSelectedLineRange(range)
setOpened(null)
setSelection({ file, range: next })
setCommenting(null)
}
const openComment = (comment: SessionReviewComment) => {
setOpened({ file: comment.file, id: comment.id })
setSelection({ file: comment.file, range: comment.selection })
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
}
const isCommentOpen = (comment: SessionReviewComment) => {
@@ -607,7 +642,6 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => {
wrapper = el
anchors.set(file, el)
scheduleAnchors()
}}
>
<Show when={expanded()}>
@@ -658,11 +692,18 @@ export const SessionReview = (props: SessionReviewProps) => {
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
scheduleAnchors()
}}
enableLineSelection={props.onLineComment != null}
enableHoverUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) return
openDraft(range)
}}
annotations={annotations()}
renderAnnotation={renderAnnotation}
renderHoverUtility={props.onLineComment ? renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
@@ -676,50 +717,6 @@ export const SessionReview = (props: SessionReviewProps) => {
/>
</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>

View File

@@ -0,0 +1,73 @@
export type HoverCommentLine = {
lineNumber: number
side?: "additions" | "deletions"
}
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
onSelect: (line: HoverCommentLine) => void
}) {
if (typeof document === "undefined") return
const button = document.createElement("button")
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = "20px"
button.style.height = "20px"
button.style.display = "flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.border = "none"
button.style.borderRadius = "var(--radius-md)"
button.style.background = "var(--syntax-diff-add)"
button.style.color = "var(--white)"
button.style.boxShadow = "var(--shadow-xs)"
button.style.fontSize = "14px"
button.style.lineHeight = "1"
button.style.cursor = "pointer"
button.style.position = "relative"
button.style.left = "22px"
let line: HoverCommentLine | undefined
const sync = () => {
const next = props.getHoveredLine()
if (!next) return
line = next
}
const loop = () => {
if (!button.isConnected) return
sync()
requestAnimationFrame(loop)
}
const open = () => {
const next = props.getHoveredLine() ?? line
if (!next) return
props.onSelect(next)
}
requestAnimationFrame(loop)
button.addEventListener("mouseenter", sync)
button.addEventListener("mousemove", sync)
button.addEventListener("pointerdown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("click", (event) => {
event.preventDefault()
event.stopPropagation()
open()
})
return button
}

View File

@@ -0,0 +1,130 @@
import { type SelectedLineRange } from "@pierre/diffs"
export type CommentSide = "additions" | "deletions"
function annotationIndex(node: HTMLElement) {
const value = node.dataset.lineAnnotation?.split(",")[1]
if (!value) return
const line = parseInt(value, 10)
if (Number.isNaN(line)) return
return line
}
function clear(root: ShadowRoot) {
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of marked) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
}
export function findDiffSide(node: HTMLElement): CommentSide {
const line = node.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = node.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
function lineIndex(split: boolean, node: HTMLElement) {
const raw = node.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((x) => parseInt(x, 10))
.filter((x) => !Number.isNaN(x))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
function rowIndex(root: ShadowRoot, split: boolean, line: number, side: CommentSide | undefined) {
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const target = side ?? "additions"
for (const row of rows) {
if (findDiffSide(row) === target) return lineIndex(split, row)
if (parseInt(row.dataset.altLine ?? "", 10) === line) return lineIndex(split, row)
}
}
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined || idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = annotationIndex(annotation)
if (idx === undefined || idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
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 nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = annotationIndex(annotation)
if (line === undefined || line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}

View File

@@ -1,5 +1,6 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
@@ -7,6 +8,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
@@ -125,7 +127,11 @@ const unsafeCSS = `
overflow-x: auto !important;
overflow-y: hidden !important;
}
}`
}
${lineCommentStyles}
`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {

View File

@@ -0,0 +1,105 @@
import { type SelectedLineRange } from "@pierre/diffs"
type PointerMode = "none" | "text" | "numbers"
type Side = SelectedLineRange["side"]
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: range.start,
end: range.end,
}
if (range.side) next.side = range.side
if (range.endSide) next.endSide = range.endSide
return next
}
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
if (!range) return false
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (line < start || line > end) return false
if (!side) return true
const first = range.side
const last = range.endSide ?? first
if (!first && !last) return true
if (!first || !last) return (first ?? last) === side
if (first === last) return first === side
if (line === start) return first === side
if (line === end) return last === side
return true
}
export function isSingleLineSelection(range: SelectedLineRange | null) {
if (!range) return false
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
}
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
if (!root || !range) return
requestAnimationFrame(() => {
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection) return
try {
selection.removeAllRanges()
selection.addRange(range)
} catch {}
})
}
export function createLineNumberSelectionBridge() {
let mode: PointerMode = "none"
let line: number | undefined
let moved = false
let pending = false
const clear = () => {
mode = "none"
line = undefined
moved = false
}
return {
begin(numberColumn: boolean, next: number | undefined) {
if (!numberColumn) {
mode = "text"
return
}
mode = "numbers"
line = next
moved = false
},
track(buttons: number, next: number | undefined) {
if (mode !== "numbers") return false
if ((buttons & 1) === 0) {
clear()
return true
}
if (next !== undefined && line !== undefined && next !== line) moved = true
return true
},
finish() {
const current = mode
pending = current === "numbers" && moved
clear()
return current
},
consume(range: SelectedLineRange | null) {
const result = pending && !isSingleLineSelection(range)
pending = false
return result
},
reset() {
pending = false
clear()
},
}
}

View File

@@ -28,7 +28,6 @@
@import "../components/icon-button.css" layer(components);
@import "../components/image-preview.css" layer(components);
@import "../components/keybind.css" layer(components);
@import "../components/line-comment.css" layer(components);
@import "../components/text-field.css" layer(components);
@import "../components/inline-input.css" layer(components);
@import "../components/list.css" layer(components);