diff.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  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 code = element.closest("[data-code]")
  30. if (!(code instanceof HTMLElement)) return
  31. if (code.hasAttribute("data-deletions")) return "deletions"
  32. return "additions"
  33. }
  34. export function Diff<T>(props: DiffProps<T>) {
  35. let container!: HTMLDivElement
  36. let observer: MutationObserver | undefined
  37. let renderToken = 0
  38. let selectionFrame: number | undefined
  39. let dragFrame: number | undefined
  40. let dragStart: number | undefined
  41. let dragEnd: number | undefined
  42. let dragSide: SelectionSide | undefined
  43. let dragEndSide: SelectionSide | undefined
  44. let dragMoved = false
  45. let lastSelection: SelectedLineRange | null = null
  46. let pendingSelectionEnd = false
  47. const [local, others] = splitProps(props, [
  48. "before",
  49. "after",
  50. "class",
  51. "classList",
  52. "annotations",
  53. "selectedLines",
  54. "onRendered",
  55. ])
  56. const mobile = createMediaQuery("(max-width: 640px)")
  57. const options = createMemo(() => {
  58. const opts = {
  59. ...createDefaultOptions(props.diffStyle),
  60. ...others,
  61. }
  62. if (!mobile()) return opts
  63. return {
  64. ...opts,
  65. disableLineNumbers: true,
  66. }
  67. })
  68. let instance: FileDiff<T> | undefined
  69. const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
  70. const getRoot = () => {
  71. const host = container.querySelector("diffs-container")
  72. if (!(host instanceof HTMLElement)) return
  73. const root = host.shadowRoot
  74. if (!root) return
  75. return root
  76. }
  77. const notifyRendered = () => {
  78. if (!local.onRendered) return
  79. observer?.disconnect()
  80. observer = undefined
  81. renderToken++
  82. const token = renderToken
  83. let settle = 0
  84. const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
  85. const notify = () => {
  86. if (token !== renderToken) return
  87. observer?.disconnect()
  88. observer = undefined
  89. requestAnimationFrame(() => {
  90. if (token !== renderToken) return
  91. local.onRendered?.()
  92. })
  93. }
  94. const schedule = () => {
  95. settle++
  96. const current = settle
  97. requestAnimationFrame(() => {
  98. if (token !== renderToken) return
  99. if (current !== settle) return
  100. requestAnimationFrame(() => {
  101. if (token !== renderToken) return
  102. if (current !== settle) return
  103. notify()
  104. })
  105. })
  106. }
  107. const observeRoot = (root: ShadowRoot) => {
  108. observer?.disconnect()
  109. observer = new MutationObserver(() => {
  110. if (token !== renderToken) return
  111. if (!isReady(root)) return
  112. schedule()
  113. })
  114. observer.observe(root, { childList: true, subtree: true })
  115. if (!isReady(root)) return
  116. schedule()
  117. }
  118. const root = getRoot()
  119. if (typeof MutationObserver === "undefined") {
  120. if (!root || !isReady(root)) return
  121. local.onRendered()
  122. return
  123. }
  124. if (root) {
  125. observeRoot(root)
  126. return
  127. }
  128. observer = new MutationObserver(() => {
  129. if (token !== renderToken) return
  130. const root = getRoot()
  131. if (!root) return
  132. observeRoot(root)
  133. })
  134. observer.observe(container, { childList: true, subtree: true })
  135. }
  136. const setSelectedLines = (range: SelectedLineRange | null) => {
  137. const active = current()
  138. if (!active) return
  139. lastSelection = range
  140. active.setSelectedLines(range)
  141. }
  142. const updateSelection = () => {
  143. const root = getRoot()
  144. if (!root) return
  145. const selection =
  146. (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
  147. if (!selection || selection.isCollapsed) return
  148. const domRange =
  149. (
  150. selection as unknown as {
  151. getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
  152. }
  153. ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
  154. (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
  155. const startNode = domRange?.startContainer ?? selection.anchorNode
  156. const endNode = domRange?.endContainer ?? selection.focusNode
  157. if (!startNode || !endNode) return
  158. if (!root.contains(startNode) || !root.contains(endNode)) return
  159. const start = findLineNumber(startNode)
  160. const end = findLineNumber(endNode)
  161. if (start === undefined || end === undefined) return
  162. const startSide = findSide(startNode)
  163. const endSide = findSide(endNode)
  164. const side = startSide ?? endSide
  165. const selected: SelectedLineRange = {
  166. start,
  167. end,
  168. }
  169. if (side) selected.side = side
  170. if (endSide && side && endSide !== side) selected.endSide = endSide
  171. setSelectedLines(selected)
  172. }
  173. const scheduleSelectionUpdate = () => {
  174. if (selectionFrame !== undefined) return
  175. selectionFrame = requestAnimationFrame(() => {
  176. selectionFrame = undefined
  177. updateSelection()
  178. if (!pendingSelectionEnd) return
  179. pendingSelectionEnd = false
  180. props.onLineSelectionEnd?.(lastSelection)
  181. })
  182. }
  183. const updateDragSelection = () => {
  184. if (dragStart === undefined || dragEnd === undefined) return
  185. const selected: SelectedLineRange = {
  186. start: dragStart,
  187. end: dragEnd,
  188. }
  189. if (dragSide) selected.side = dragSide
  190. if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
  191. setSelectedLines(selected)
  192. }
  193. const scheduleDragUpdate = () => {
  194. if (dragFrame !== undefined) return
  195. dragFrame = requestAnimationFrame(() => {
  196. dragFrame = undefined
  197. updateDragSelection()
  198. })
  199. }
  200. const lineFromMouseEvent = (event: MouseEvent) => {
  201. const path = event.composedPath()
  202. let numberColumn = false
  203. let line: number | undefined
  204. let side: SelectionSide | undefined
  205. for (const item of path) {
  206. if (!(item instanceof HTMLElement)) continue
  207. numberColumn = numberColumn || item.dataset.columnNumber != null
  208. if (side === undefined && item.dataset.code != null) {
  209. side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
  210. }
  211. if (line === undefined) {
  212. const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
  213. if (!Number.isNaN(primary)) {
  214. line = primary
  215. } else {
  216. const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
  217. if (!Number.isNaN(alt)) line = alt
  218. }
  219. }
  220. if (numberColumn && line !== undefined && side !== undefined) break
  221. }
  222. return { line, numberColumn, side }
  223. }
  224. const handleMouseDown = (event: MouseEvent) => {
  225. if (props.enableLineSelection !== true) return
  226. if (event.button !== 0) return
  227. const { line, numberColumn, side } = lineFromMouseEvent(event)
  228. if (numberColumn) return
  229. if (line === undefined) return
  230. dragStart = line
  231. dragEnd = line
  232. dragSide = side
  233. dragEndSide = side
  234. dragMoved = false
  235. }
  236. const handleMouseMove = (event: MouseEvent) => {
  237. if (props.enableLineSelection !== true) return
  238. if (dragStart === undefined) return
  239. if ((event.buttons & 1) === 0) {
  240. dragStart = undefined
  241. dragEnd = undefined
  242. dragSide = undefined
  243. dragEndSide = undefined
  244. dragMoved = false
  245. return
  246. }
  247. const { line, side } = lineFromMouseEvent(event)
  248. if (line === undefined) return
  249. dragEnd = line
  250. dragEndSide = side
  251. dragMoved = true
  252. scheduleDragUpdate()
  253. }
  254. const handleMouseUp = () => {
  255. if (props.enableLineSelection !== true) return
  256. if (dragStart === undefined) return
  257. if (dragMoved) {
  258. pendingSelectionEnd = true
  259. scheduleDragUpdate()
  260. scheduleSelectionUpdate()
  261. }
  262. dragStart = undefined
  263. dragEnd = undefined
  264. dragSide = undefined
  265. dragEndSide = undefined
  266. dragMoved = false
  267. }
  268. const handleSelectionChange = () => {
  269. if (props.enableLineSelection !== true) return
  270. if (dragStart === undefined) return
  271. const selection = window.getSelection()
  272. if (!selection || selection.isCollapsed) return
  273. scheduleSelectionUpdate()
  274. }
  275. createEffect(() => {
  276. const opts = options()
  277. const workerPool = getWorkerPool(props.diffStyle)
  278. const annotations = local.annotations
  279. const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
  280. const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
  281. instance?.cleanUp()
  282. instance = new FileDiff<T>(opts, workerPool)
  283. setCurrent(instance)
  284. container.innerHTML = ""
  285. instance.render({
  286. oldFile: {
  287. ...local.before,
  288. contents: beforeContents,
  289. cacheKey: checksum(beforeContents),
  290. },
  291. newFile: {
  292. ...local.after,
  293. contents: afterContents,
  294. cacheKey: checksum(afterContents),
  295. },
  296. lineAnnotations: annotations,
  297. containerWrapper: container,
  298. })
  299. notifyRendered()
  300. })
  301. createEffect(() => {
  302. const selected = local.selectedLines ?? null
  303. setSelectedLines(selected)
  304. })
  305. createEffect(() => {
  306. if (props.enableLineSelection !== true) return
  307. container.addEventListener("mousedown", handleMouseDown)
  308. container.addEventListener("mousemove", handleMouseMove)
  309. window.addEventListener("mouseup", handleMouseUp)
  310. document.addEventListener("selectionchange", handleSelectionChange)
  311. onCleanup(() => {
  312. container.removeEventListener("mousedown", handleMouseDown)
  313. container.removeEventListener("mousemove", handleMouseMove)
  314. window.removeEventListener("mouseup", handleMouseUp)
  315. document.removeEventListener("selectionchange", handleSelectionChange)
  316. })
  317. })
  318. onCleanup(() => {
  319. observer?.disconnect()
  320. if (selectionFrame !== undefined) {
  321. cancelAnimationFrame(selectionFrame)
  322. selectionFrame = undefined
  323. }
  324. if (dragFrame !== undefined) {
  325. cancelAnimationFrame(dragFrame)
  326. dragFrame = undefined
  327. }
  328. dragStart = undefined
  329. dragEnd = undefined
  330. dragSide = undefined
  331. dragEndSide = undefined
  332. dragMoved = false
  333. lastSelection = null
  334. pendingSelectionEnd = false
  335. instance?.cleanUp()
  336. setCurrent(undefined)
  337. })
  338. return <div data-component="diff" style={styleVariables} ref={container} />
  339. }