diff.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. import { checksum } from "@opencode-ai/util/encode"
  2. import { FileDiff, type SelectedLineRange } from "@pierre/diffs"
  3. import { createMediaQuery } from "@solid-primitives/media"
  4. import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
  5. import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
  6. import { getWorkerPool } from "../pierre/worker"
  7. type SelectionSide = "additions" | "deletions"
  8. function findElement(node: Node | null): HTMLElement | undefined {
  9. if (!node) return
  10. if (node instanceof HTMLElement) return node
  11. return node.parentElement ?? undefined
  12. }
  13. function findLineNumber(node: Node | null): number | undefined {
  14. const element = findElement(node)
  15. if (!element) return
  16. const line = element.closest("[data-line], [data-alt-line]")
  17. if (!(line instanceof HTMLElement)) return
  18. const value = (() => {
  19. const primary = parseInt(line.dataset.line ?? "", 10)
  20. if (!Number.isNaN(primary)) return primary
  21. const alt = parseInt(line.dataset.altLine ?? "", 10)
  22. if (!Number.isNaN(alt)) return alt
  23. })()
  24. return value
  25. }
  26. function findSide(node: Node | null): SelectionSide | undefined {
  27. const element = findElement(node)
  28. if (!element) return
  29. const line = element.closest("[data-line], [data-alt-line]")
  30. if (line instanceof HTMLElement) {
  31. const type = line.dataset.lineType
  32. if (type === "change-deletion") return "deletions"
  33. if (type === "change-addition" || type === "change-additions") return "additions"
  34. }
  35. const code = element.closest("[data-code]")
  36. if (!(code instanceof HTMLElement)) return
  37. if (code.hasAttribute("data-deletions")) return "deletions"
  38. return "additions"
  39. }
  40. export function Diff<T>(props: DiffProps<T>) {
  41. let container!: HTMLDivElement
  42. let observer: MutationObserver | undefined
  43. let renderToken = 0
  44. let selectionFrame: number | undefined
  45. let dragFrame: number | undefined
  46. let dragStart: number | undefined
  47. let dragEnd: number | undefined
  48. let dragSide: SelectionSide | undefined
  49. let dragEndSide: SelectionSide | undefined
  50. let dragMoved = false
  51. let lastSelection: SelectedLineRange | null = null
  52. let pendingSelectionEnd = false
  53. const [local, others] = splitProps(props, [
  54. "before",
  55. "after",
  56. "class",
  57. "classList",
  58. "annotations",
  59. "selectedLines",
  60. "commentedLines",
  61. "onRendered",
  62. ])
  63. const mobile = createMediaQuery("(max-width: 640px)")
  64. const options = createMemo(() => {
  65. const opts = {
  66. ...createDefaultOptions(props.diffStyle),
  67. ...others,
  68. }
  69. if (!mobile()) return opts
  70. return {
  71. ...opts,
  72. disableLineNumbers: true,
  73. }
  74. })
  75. let instance: FileDiff<T> | undefined
  76. const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
  77. const [rendered, setRendered] = createSignal(0)
  78. const getRoot = () => {
  79. const host = container.querySelector("diffs-container")
  80. if (!(host instanceof HTMLElement)) return
  81. const root = host.shadowRoot
  82. if (!root) return
  83. return root
  84. }
  85. const notifyRendered = () => {
  86. if (!local.onRendered) return
  87. observer?.disconnect()
  88. observer = undefined
  89. renderToken++
  90. const token = renderToken
  91. let settle = 0
  92. const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
  93. const notify = () => {
  94. if (token !== renderToken) return
  95. observer?.disconnect()
  96. observer = undefined
  97. requestAnimationFrame(() => {
  98. if (token !== renderToken) return
  99. local.onRendered?.()
  100. })
  101. }
  102. const schedule = () => {
  103. settle++
  104. const current = settle
  105. requestAnimationFrame(() => {
  106. if (token !== renderToken) return
  107. if (current !== settle) return
  108. requestAnimationFrame(() => {
  109. if (token !== renderToken) return
  110. if (current !== settle) return
  111. notify()
  112. })
  113. })
  114. }
  115. const observeRoot = (root: ShadowRoot) => {
  116. observer?.disconnect()
  117. observer = new MutationObserver(() => {
  118. if (token !== renderToken) return
  119. if (!isReady(root)) return
  120. schedule()
  121. })
  122. observer.observe(root, { childList: true, subtree: true })
  123. if (!isReady(root)) return
  124. schedule()
  125. }
  126. const root = getRoot()
  127. if (typeof MutationObserver === "undefined") {
  128. if (!root || !isReady(root)) return
  129. local.onRendered()
  130. return
  131. }
  132. if (root) {
  133. observeRoot(root)
  134. return
  135. }
  136. observer = new MutationObserver(() => {
  137. if (token !== renderToken) return
  138. const root = getRoot()
  139. if (!root) return
  140. observeRoot(root)
  141. })
  142. observer.observe(container, { childList: true, subtree: true })
  143. }
  144. const applyCommentedLines = (ranges: SelectedLineRange[]) => {
  145. const root = getRoot()
  146. if (!root) return
  147. const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
  148. for (const node of existing) {
  149. if (!(node instanceof HTMLElement)) continue
  150. node.removeAttribute("data-comment-selected")
  151. }
  152. for (const range of ranges) {
  153. const start = Math.max(1, Math.min(range.start, range.end))
  154. const end = Math.max(range.start, range.end)
  155. for (let line = start; line <= end; line++) {
  156. const expectedSide =
  157. line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide)
  158. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`))
  159. for (const node of nodes) {
  160. if (!(node instanceof HTMLElement)) continue
  161. if (expectedSide) {
  162. const side = findSide(node)
  163. if (side && side !== expectedSide) continue
  164. }
  165. node.setAttribute("data-comment-selected", "")
  166. }
  167. }
  168. }
  169. }
  170. const setSelectedLines = (range: SelectedLineRange | null) => {
  171. const active = current()
  172. if (!active) return
  173. lastSelection = range
  174. active.setSelectedLines(range)
  175. }
  176. const updateSelection = () => {
  177. const root = getRoot()
  178. if (!root) return
  179. const selection =
  180. (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
  181. if (!selection || selection.isCollapsed) return
  182. const domRange =
  183. (
  184. selection as unknown as {
  185. getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
  186. }
  187. ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
  188. (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
  189. const startNode = domRange?.startContainer ?? selection.anchorNode
  190. const endNode = domRange?.endContainer ?? selection.focusNode
  191. if (!startNode || !endNode) return
  192. if (!root.contains(startNode) || !root.contains(endNode)) return
  193. const start = findLineNumber(startNode)
  194. const end = findLineNumber(endNode)
  195. if (start === undefined || end === undefined) return
  196. const startSide = findSide(startNode)
  197. const endSide = findSide(endNode)
  198. const side = startSide ?? endSide
  199. const selected: SelectedLineRange = {
  200. start,
  201. end,
  202. }
  203. if (side) selected.side = side
  204. if (endSide && side && endSide !== side) selected.endSide = endSide
  205. setSelectedLines(selected)
  206. }
  207. const scheduleSelectionUpdate = () => {
  208. if (selectionFrame !== undefined) return
  209. selectionFrame = requestAnimationFrame(() => {
  210. selectionFrame = undefined
  211. updateSelection()
  212. if (!pendingSelectionEnd) return
  213. pendingSelectionEnd = false
  214. props.onLineSelectionEnd?.(lastSelection)
  215. })
  216. }
  217. const updateDragSelection = () => {
  218. if (dragStart === undefined || dragEnd === undefined) return
  219. const selected: SelectedLineRange = {
  220. start: dragStart,
  221. end: dragEnd,
  222. }
  223. if (dragSide) selected.side = dragSide
  224. if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
  225. setSelectedLines(selected)
  226. }
  227. const scheduleDragUpdate = () => {
  228. if (dragFrame !== undefined) return
  229. dragFrame = requestAnimationFrame(() => {
  230. dragFrame = undefined
  231. updateDragSelection()
  232. })
  233. }
  234. const lineFromMouseEvent = (event: MouseEvent) => {
  235. const path = event.composedPath()
  236. let numberColumn = false
  237. let line: number | undefined
  238. let side: SelectionSide | undefined
  239. for (const item of path) {
  240. if (!(item instanceof HTMLElement)) continue
  241. numberColumn = numberColumn || item.dataset.columnNumber != null
  242. if (side === undefined) {
  243. const type = item.dataset.lineType
  244. if (type === "change-deletion") side = "deletions"
  245. if (type === "change-addition" || type === "change-additions") side = "additions"
  246. }
  247. if (side === undefined && item.dataset.code != null) {
  248. side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
  249. }
  250. if (line === undefined) {
  251. const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
  252. if (!Number.isNaN(primary)) {
  253. line = primary
  254. } else {
  255. const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
  256. if (!Number.isNaN(alt)) line = alt
  257. }
  258. }
  259. if (numberColumn && line !== undefined && side !== undefined) break
  260. }
  261. return { line, numberColumn, side }
  262. }
  263. const handleMouseDown = (event: MouseEvent) => {
  264. if (props.enableLineSelection !== true) return
  265. if (event.button !== 0) return
  266. const { line, numberColumn, side } = lineFromMouseEvent(event)
  267. if (numberColumn) return
  268. if (line === undefined) return
  269. dragStart = line
  270. dragEnd = line
  271. dragSide = side
  272. dragEndSide = side
  273. dragMoved = false
  274. }
  275. const handleMouseMove = (event: MouseEvent) => {
  276. if (props.enableLineSelection !== true) return
  277. if (dragStart === undefined) return
  278. if ((event.buttons & 1) === 0) {
  279. dragStart = undefined
  280. dragEnd = undefined
  281. dragSide = undefined
  282. dragEndSide = undefined
  283. dragMoved = false
  284. return
  285. }
  286. const { line, side } = lineFromMouseEvent(event)
  287. if (line === undefined) return
  288. dragEnd = line
  289. dragEndSide = side
  290. dragMoved = true
  291. scheduleDragUpdate()
  292. }
  293. const handleMouseUp = () => {
  294. if (props.enableLineSelection !== true) return
  295. if (dragStart === undefined) return
  296. if (!dragMoved) {
  297. pendingSelectionEnd = false
  298. const line = dragStart
  299. const selected: SelectedLineRange = {
  300. start: line,
  301. end: line,
  302. }
  303. if (dragSide) selected.side = dragSide
  304. setSelectedLines(selected)
  305. props.onLineSelectionEnd?.(lastSelection)
  306. dragStart = undefined
  307. dragEnd = undefined
  308. dragSide = undefined
  309. dragEndSide = undefined
  310. dragMoved = false
  311. return
  312. }
  313. pendingSelectionEnd = true
  314. scheduleDragUpdate()
  315. scheduleSelectionUpdate()
  316. dragStart = undefined
  317. dragEnd = undefined
  318. dragSide = undefined
  319. dragEndSide = undefined
  320. dragMoved = false
  321. }
  322. const handleSelectionChange = () => {
  323. if (props.enableLineSelection !== true) return
  324. if (dragStart === undefined) return
  325. const selection = window.getSelection()
  326. if (!selection || selection.isCollapsed) return
  327. scheduleSelectionUpdate()
  328. }
  329. createEffect(() => {
  330. const opts = options()
  331. const workerPool = getWorkerPool(props.diffStyle)
  332. const annotations = local.annotations
  333. const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
  334. const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
  335. instance?.cleanUp()
  336. instance = new FileDiff<T>(opts, workerPool)
  337. setCurrent(instance)
  338. container.innerHTML = ""
  339. instance.render({
  340. oldFile: {
  341. ...local.before,
  342. contents: beforeContents,
  343. cacheKey: checksum(beforeContents),
  344. },
  345. newFile: {
  346. ...local.after,
  347. contents: afterContents,
  348. cacheKey: checksum(afterContents),
  349. },
  350. lineAnnotations: annotations,
  351. containerWrapper: container,
  352. })
  353. setRendered((value) => value + 1)
  354. notifyRendered()
  355. })
  356. createEffect(() => {
  357. rendered()
  358. const ranges = local.commentedLines ?? []
  359. requestAnimationFrame(() => applyCommentedLines(ranges))
  360. })
  361. createEffect(() => {
  362. const selected = local.selectedLines ?? null
  363. setSelectedLines(selected)
  364. })
  365. createEffect(() => {
  366. if (props.enableLineSelection !== true) return
  367. container.addEventListener("mousedown", handleMouseDown)
  368. container.addEventListener("mousemove", handleMouseMove)
  369. window.addEventListener("mouseup", handleMouseUp)
  370. document.addEventListener("selectionchange", handleSelectionChange)
  371. onCleanup(() => {
  372. container.removeEventListener("mousedown", handleMouseDown)
  373. container.removeEventListener("mousemove", handleMouseMove)
  374. window.removeEventListener("mouseup", handleMouseUp)
  375. document.removeEventListener("selectionchange", handleSelectionChange)
  376. })
  377. })
  378. onCleanup(() => {
  379. observer?.disconnect()
  380. if (selectionFrame !== undefined) {
  381. cancelAnimationFrame(selectionFrame)
  382. selectionFrame = undefined
  383. }
  384. if (dragFrame !== undefined) {
  385. cancelAnimationFrame(dragFrame)
  386. dragFrame = undefined
  387. }
  388. dragStart = undefined
  389. dragEnd = undefined
  390. dragSide = undefined
  391. dragEndSide = undefined
  392. dragMoved = false
  393. lastSelection = null
  394. pendingSelectionEnd = false
  395. instance?.cleanUp()
  396. setCurrent(undefined)
  397. })
  398. return <div data-component="diff" style={styleVariables} ref={container} />
  399. }