| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import { checksum } from "@opencode-ai/util/encode"
- import { FileDiff, type SelectedLineRange } from "@pierre/diffs"
- import { createMediaQuery } from "@solid-primitives/media"
- 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 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"
- }
- export function Diff<T>(props: DiffProps<T>) {
- let container!: HTMLDivElement
- let observer: MutationObserver | undefined
- let renderToken = 0
- 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",
- "commentedLines",
- "onRendered",
- ])
- const mobile = createMediaQuery("(max-width: 640px)")
- const options = createMemo(() => {
- const opts = {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- }
- if (!mobile()) return opts
- return {
- ...opts,
- disableLineNumbers: true,
- }
- })
- let instance: FileDiff<T> | undefined
- const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
- const [rendered, setRendered] = createSignal(0)
- const getRoot = () => {
- const host = container.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
- const root = host.shadowRoot
- if (!root) return
- return root
- }
- const notifyRendered = () => {
- if (!local.onRendered) return
- observer?.disconnect()
- observer = undefined
- renderToken++
- const token = renderToken
- let settle = 0
- const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
- const notify = () => {
- if (token !== renderToken) return
- observer?.disconnect()
- observer = undefined
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- local.onRendered?.()
- })
- }
- const schedule = () => {
- settle++
- const current = settle
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- if (current !== settle) return
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- if (current !== settle) return
- notify()
- })
- })
- }
- const observeRoot = (root: ShadowRoot) => {
- observer?.disconnect()
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
- if (!isReady(root)) return
- schedule()
- })
- observer.observe(root, { childList: true, subtree: true })
- if (!isReady(root)) return
- schedule()
- }
- const root = getRoot()
- if (typeof MutationObserver === "undefined") {
- if (!root || !isReady(root)) return
- local.onRendered()
- return
- }
- if (root) {
- observeRoot(root)
- return
- }
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
- const root = getRoot()
- if (!root) return
- observeRoot(root)
- })
- 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
- 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) {
- const type = item.dataset.lineType
- if (type === "change-deletion") side = "deletions"
- if (type === "change-addition" || type === "change-additions") side = "additions"
- }
- 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 = false
- const line = dragStart
- const selected: SelectedLineRange = {
- start: line,
- end: line,
- }
- if (dragSide) selected.side = dragSide
- setSelectedLines(selected)
- props.onLineSelectionEnd?.(lastSelection)
- dragStart = undefined
- dragEnd = undefined
- dragSide = undefined
- dragEndSide = undefined
- dragMoved = false
- return
- }
- 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)
- const annotations = local.annotations
- const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
- const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
- instance?.cleanUp()
- instance = new FileDiff<T>(opts, workerPool)
- setCurrent(instance)
- container.innerHTML = ""
- instance.render({
- oldFile: {
- ...local.before,
- contents: beforeContents,
- cacheKey: checksum(beforeContents),
- },
- newFile: {
- ...local.after,
- contents: afterContents,
- cacheKey: checksum(afterContents),
- },
- lineAnnotations: annotations,
- containerWrapper: container,
- })
- setRendered((value) => value + 1)
- notifyRendered()
- })
- createEffect(() => {
- rendered()
- const ranges = local.commentedLines ?? []
- requestAnimationFrame(() => applyCommentedLines(ranges))
- })
- 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} />
- }
|