diff.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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 applyScheme = () => {
  86. const host = container.querySelector("diffs-container")
  87. if (!(host instanceof HTMLElement)) return
  88. const scheme = document.documentElement.dataset.colorScheme
  89. if (scheme === "dark" || scheme === "light") {
  90. host.dataset.colorScheme = scheme
  91. return
  92. }
  93. host.removeAttribute("data-color-scheme")
  94. }
  95. const lineIndex = (split: boolean, element: HTMLElement) => {
  96. const raw = element.dataset.lineIndex
  97. if (!raw) return
  98. const values = raw
  99. .split(",")
  100. .map((value) => parseInt(value, 10))
  101. .filter((value) => !Number.isNaN(value))
  102. if (values.length === 0) return
  103. if (!split) return values[0]
  104. if (values.length === 2) return values[1]
  105. return values[0]
  106. }
  107. const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
  108. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
  109. (node): node is HTMLElement => node instanceof HTMLElement,
  110. )
  111. if (nodes.length === 0) return
  112. const targetSide = side ?? "additions"
  113. for (const node of nodes) {
  114. if (findSide(node) === targetSide) return lineIndex(split, node)
  115. if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
  116. }
  117. }
  118. const fixSelection = (range: SelectedLineRange | null) => {
  119. if (!range) return range
  120. const root = getRoot()
  121. if (!root) return
  122. const diffs = root.querySelector("[data-diffs]")
  123. if (!(diffs instanceof HTMLElement)) return
  124. const split = diffs.dataset.type === "split"
  125. const start = rowIndex(root, split, range.start, range.side)
  126. const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
  127. if (start === undefined || end === undefined) {
  128. if (root.querySelector("[data-line], [data-alt-line]") == null) return
  129. return null
  130. }
  131. if (start <= end) return range
  132. const side = range.endSide ?? range.side
  133. const swapped: SelectedLineRange = {
  134. start: range.end,
  135. end: range.start,
  136. }
  137. if (side) swapped.side = side
  138. if (range.endSide && range.side) swapped.endSide = range.side
  139. return swapped
  140. }
  141. const notifyRendered = () => {
  142. observer?.disconnect()
  143. observer = undefined
  144. renderToken++
  145. const token = renderToken
  146. let settle = 0
  147. const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
  148. const notify = () => {
  149. if (token !== renderToken) return
  150. observer?.disconnect()
  151. observer = undefined
  152. requestAnimationFrame(() => {
  153. if (token !== renderToken) return
  154. setSelectedLines(lastSelection)
  155. local.onRendered?.()
  156. })
  157. }
  158. const schedule = () => {
  159. settle++
  160. const current = settle
  161. requestAnimationFrame(() => {
  162. if (token !== renderToken) return
  163. if (current !== settle) return
  164. requestAnimationFrame(() => {
  165. if (token !== renderToken) return
  166. if (current !== settle) return
  167. notify()
  168. })
  169. })
  170. }
  171. const observeRoot = (root: ShadowRoot) => {
  172. observer?.disconnect()
  173. observer = new MutationObserver(() => {
  174. if (token !== renderToken) return
  175. if (!isReady(root)) return
  176. schedule()
  177. })
  178. observer.observe(root, { childList: true, subtree: true })
  179. if (!isReady(root)) return
  180. schedule()
  181. }
  182. const root = getRoot()
  183. if (typeof MutationObserver === "undefined") {
  184. if (!root || !isReady(root)) return
  185. setSelectedLines(lastSelection)
  186. local.onRendered?.()
  187. return
  188. }
  189. if (root) {
  190. observeRoot(root)
  191. return
  192. }
  193. observer = new MutationObserver(() => {
  194. if (token !== renderToken) return
  195. const root = getRoot()
  196. if (!root) return
  197. observeRoot(root)
  198. })
  199. observer.observe(container, { childList: true, subtree: true })
  200. }
  201. const applyCommentedLines = (ranges: SelectedLineRange[]) => {
  202. const root = getRoot()
  203. if (!root) return
  204. const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
  205. for (const node of existing) {
  206. if (!(node instanceof HTMLElement)) continue
  207. node.removeAttribute("data-comment-selected")
  208. }
  209. const diffs = root.querySelector("[data-diffs]")
  210. if (!(diffs instanceof HTMLElement)) return
  211. const split = diffs.dataset.type === "split"
  212. const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
  213. (node): node is HTMLElement => node instanceof HTMLElement,
  214. )
  215. if (code.length === 0) return
  216. for (const range of ranges) {
  217. const start = rowIndex(root, split, range.start, range.side)
  218. if (start === undefined) continue
  219. const end = (() => {
  220. const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
  221. if (same) return start
  222. return rowIndex(root, split, range.end, range.endSide ?? range.side)
  223. })()
  224. if (end === undefined) continue
  225. const first = Math.min(start, end)
  226. const last = Math.max(start, end)
  227. for (const block of code) {
  228. for (const element of Array.from(block.children)) {
  229. if (!(element instanceof HTMLElement)) continue
  230. const idx = lineIndex(split, element)
  231. if (idx === undefined) continue
  232. if (idx > last) break
  233. if (idx < first) continue
  234. element.setAttribute("data-comment-selected", "")
  235. const next = element.nextSibling
  236. if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
  237. next.setAttribute("data-comment-selected", "")
  238. }
  239. }
  240. }
  241. }
  242. }
  243. const setSelectedLines = (range: SelectedLineRange | null) => {
  244. const active = current()
  245. if (!active) return
  246. const fixed = fixSelection(range)
  247. if (fixed === undefined) {
  248. lastSelection = range
  249. return
  250. }
  251. lastSelection = fixed
  252. active.setSelectedLines(fixed)
  253. }
  254. const updateSelection = () => {
  255. const root = getRoot()
  256. if (!root) return
  257. const selection =
  258. (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
  259. if (!selection || selection.isCollapsed) return
  260. const domRange =
  261. (
  262. selection as unknown as {
  263. getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
  264. }
  265. ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
  266. (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
  267. const startNode = domRange?.startContainer ?? selection.anchorNode
  268. const endNode = domRange?.endContainer ?? selection.focusNode
  269. if (!startNode || !endNode) return
  270. if (!root.contains(startNode) || !root.contains(endNode)) return
  271. const start = findLineNumber(startNode)
  272. const end = findLineNumber(endNode)
  273. if (start === undefined || end === undefined) return
  274. const startSide = findSide(startNode)
  275. const endSide = findSide(endNode)
  276. const side = startSide ?? endSide
  277. const selected: SelectedLineRange = {
  278. start,
  279. end,
  280. }
  281. if (side) selected.side = side
  282. if (endSide && side && endSide !== side) selected.endSide = endSide
  283. setSelectedLines(selected)
  284. }
  285. const scheduleSelectionUpdate = () => {
  286. if (selectionFrame !== undefined) return
  287. selectionFrame = requestAnimationFrame(() => {
  288. selectionFrame = undefined
  289. updateSelection()
  290. if (!pendingSelectionEnd) return
  291. pendingSelectionEnd = false
  292. props.onLineSelectionEnd?.(lastSelection)
  293. })
  294. }
  295. const updateDragSelection = () => {
  296. if (dragStart === undefined || dragEnd === undefined) return
  297. const selected: SelectedLineRange = {
  298. start: dragStart,
  299. end: dragEnd,
  300. }
  301. if (dragSide) selected.side = dragSide
  302. if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
  303. setSelectedLines(selected)
  304. }
  305. const scheduleDragUpdate = () => {
  306. if (dragFrame !== undefined) return
  307. dragFrame = requestAnimationFrame(() => {
  308. dragFrame = undefined
  309. updateDragSelection()
  310. })
  311. }
  312. const lineFromMouseEvent = (event: MouseEvent) => {
  313. const path = event.composedPath()
  314. let numberColumn = false
  315. let line: number | undefined
  316. let side: SelectionSide | undefined
  317. for (const item of path) {
  318. if (!(item instanceof HTMLElement)) continue
  319. numberColumn = numberColumn || item.dataset.columnNumber != null
  320. if (side === undefined) {
  321. const type = item.dataset.lineType
  322. if (type === "change-deletion") side = "deletions"
  323. if (type === "change-addition" || type === "change-additions") side = "additions"
  324. }
  325. if (side === undefined && item.dataset.code != null) {
  326. side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
  327. }
  328. if (line === undefined) {
  329. const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
  330. if (!Number.isNaN(primary)) {
  331. line = primary
  332. } else {
  333. const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
  334. if (!Number.isNaN(alt)) line = alt
  335. }
  336. }
  337. if (numberColumn && line !== undefined && side !== undefined) break
  338. }
  339. return { line, numberColumn, side }
  340. }
  341. const handleMouseDown = (event: MouseEvent) => {
  342. if (props.enableLineSelection !== true) return
  343. if (event.button !== 0) return
  344. const { line, numberColumn, side } = lineFromMouseEvent(event)
  345. if (numberColumn) return
  346. if (line === undefined) return
  347. dragStart = line
  348. dragEnd = line
  349. dragSide = side
  350. dragEndSide = side
  351. dragMoved = false
  352. }
  353. const handleMouseMove = (event: MouseEvent) => {
  354. if (props.enableLineSelection !== true) return
  355. if (dragStart === undefined) return
  356. if ((event.buttons & 1) === 0) {
  357. dragStart = undefined
  358. dragEnd = undefined
  359. dragSide = undefined
  360. dragEndSide = undefined
  361. dragMoved = false
  362. return
  363. }
  364. const { line, side } = lineFromMouseEvent(event)
  365. if (line === undefined) return
  366. dragEnd = line
  367. dragEndSide = side
  368. dragMoved = true
  369. scheduleDragUpdate()
  370. }
  371. const handleMouseUp = () => {
  372. if (props.enableLineSelection !== true) return
  373. if (dragStart === undefined) return
  374. if (!dragMoved) {
  375. pendingSelectionEnd = false
  376. const line = dragStart
  377. const selected: SelectedLineRange = {
  378. start: line,
  379. end: line,
  380. }
  381. if (dragSide) selected.side = dragSide
  382. setSelectedLines(selected)
  383. props.onLineSelectionEnd?.(lastSelection)
  384. dragStart = undefined
  385. dragEnd = undefined
  386. dragSide = undefined
  387. dragEndSide = undefined
  388. dragMoved = false
  389. return
  390. }
  391. pendingSelectionEnd = true
  392. scheduleDragUpdate()
  393. scheduleSelectionUpdate()
  394. dragStart = undefined
  395. dragEnd = undefined
  396. dragSide = undefined
  397. dragEndSide = undefined
  398. dragMoved = false
  399. }
  400. const handleSelectionChange = () => {
  401. if (props.enableLineSelection !== true) return
  402. if (dragStart === undefined) return
  403. const selection = window.getSelection()
  404. if (!selection || selection.isCollapsed) return
  405. scheduleSelectionUpdate()
  406. }
  407. createEffect(() => {
  408. const opts = options()
  409. const workerPool = getWorkerPool(props.diffStyle)
  410. const annotations = local.annotations
  411. const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
  412. const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
  413. instance?.cleanUp()
  414. instance = new FileDiff<T>(opts, workerPool)
  415. setCurrent(instance)
  416. container.innerHTML = ""
  417. instance.render({
  418. oldFile: {
  419. ...local.before,
  420. contents: beforeContents,
  421. cacheKey: checksum(beforeContents),
  422. },
  423. newFile: {
  424. ...local.after,
  425. contents: afterContents,
  426. cacheKey: checksum(afterContents),
  427. },
  428. lineAnnotations: annotations,
  429. containerWrapper: container,
  430. })
  431. applyScheme()
  432. setRendered((value) => value + 1)
  433. notifyRendered()
  434. })
  435. createEffect(() => {
  436. if (typeof document === "undefined") return
  437. if (typeof MutationObserver === "undefined") return
  438. const root = document.documentElement
  439. const monitor = new MutationObserver(() => applyScheme())
  440. monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
  441. applyScheme()
  442. onCleanup(() => monitor.disconnect())
  443. })
  444. createEffect(() => {
  445. rendered()
  446. const ranges = local.commentedLines ?? []
  447. requestAnimationFrame(() => applyCommentedLines(ranges))
  448. })
  449. createEffect(() => {
  450. const selected = local.selectedLines ?? null
  451. setSelectedLines(selected)
  452. })
  453. createEffect(() => {
  454. if (props.enableLineSelection !== true) return
  455. container.addEventListener("mousedown", handleMouseDown)
  456. container.addEventListener("mousemove", handleMouseMove)
  457. window.addEventListener("mouseup", handleMouseUp)
  458. document.addEventListener("selectionchange", handleSelectionChange)
  459. onCleanup(() => {
  460. container.removeEventListener("mousedown", handleMouseDown)
  461. container.removeEventListener("mousemove", handleMouseMove)
  462. window.removeEventListener("mouseup", handleMouseUp)
  463. document.removeEventListener("selectionchange", handleSelectionChange)
  464. })
  465. })
  466. onCleanup(() => {
  467. observer?.disconnect()
  468. if (selectionFrame !== undefined) {
  469. cancelAnimationFrame(selectionFrame)
  470. selectionFrame = undefined
  471. }
  472. if (dragFrame !== undefined) {
  473. cancelAnimationFrame(dragFrame)
  474. dragFrame = undefined
  475. }
  476. dragStart = undefined
  477. dragEnd = undefined
  478. dragSide = undefined
  479. dragEndSide = undefined
  480. dragMoved = false
  481. lastSelection = null
  482. pendingSelectionEnd = false
  483. instance?.cleanUp()
  484. setCurrent(undefined)
  485. })
  486. return <div data-component="diff" style={styleVariables} ref={container} />
  487. }