diff.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. import { sampledChecksum } from "@opencode-ai/util/encode"
  2. import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } 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 { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
  7. import { getWorkerPool } from "../pierre/worker"
  8. type SelectionSide = "additions" | "deletions"
  9. function findElement(node: Node | null): HTMLElement | undefined {
  10. if (!node) return
  11. if (node instanceof HTMLElement) return node
  12. return node.parentElement ?? undefined
  13. }
  14. function findLineNumber(node: Node | null): number | undefined {
  15. const element = findElement(node)
  16. if (!element) return
  17. const line = element.closest("[data-line], [data-alt-line]")
  18. if (!(line instanceof HTMLElement)) return
  19. const value = (() => {
  20. const primary = parseInt(line.dataset.line ?? "", 10)
  21. if (!Number.isNaN(primary)) return primary
  22. const alt = parseInt(line.dataset.altLine ?? "", 10)
  23. if (!Number.isNaN(alt)) return alt
  24. })()
  25. return value
  26. }
  27. function findSide(node: Node | null): SelectionSide | undefined {
  28. const element = findElement(node)
  29. if (!element) return
  30. const line = element.closest("[data-line], [data-alt-line]")
  31. if (line instanceof HTMLElement) {
  32. const type = line.dataset.lineType
  33. if (type === "change-deletion") return "deletions"
  34. if (type === "change-addition" || type === "change-additions") return "additions"
  35. }
  36. const code = element.closest("[data-code]")
  37. if (!(code instanceof HTMLElement)) return
  38. if (code.hasAttribute("data-deletions")) return "deletions"
  39. return "additions"
  40. }
  41. export function Diff<T>(props: DiffProps<T>) {
  42. let container!: HTMLDivElement
  43. let observer: MutationObserver | undefined
  44. let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
  45. let renderToken = 0
  46. let selectionFrame: number | undefined
  47. let dragFrame: number | undefined
  48. let dragStart: number | undefined
  49. let dragEnd: number | undefined
  50. let dragSide: SelectionSide | undefined
  51. let dragEndSide: SelectionSide | undefined
  52. let dragMoved = false
  53. let lastSelection: SelectedLineRange | null = null
  54. let pendingSelectionEnd = false
  55. const [local, others] = splitProps(props, [
  56. "before",
  57. "after",
  58. "class",
  59. "classList",
  60. "annotations",
  61. "selectedLines",
  62. "commentedLines",
  63. "onRendered",
  64. ])
  65. const mobile = createMediaQuery("(max-width: 640px)")
  66. const large = createMemo(() => {
  67. const before = typeof local.before?.contents === "string" ? local.before.contents : ""
  68. const after = typeof local.after?.contents === "string" ? local.after.contents : ""
  69. return Math.max(before.length, after.length) > 500_000
  70. })
  71. const largeOptions = {
  72. lineDiffType: "none",
  73. maxLineDiffLength: 0,
  74. tokenizeMaxLineLength: 1,
  75. } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
  76. const options = createMemo<FileDiffOptions<T>>(() => {
  77. const base = {
  78. ...createDefaultOptions(props.diffStyle),
  79. ...others,
  80. }
  81. const perf = large() ? { ...base, ...largeOptions } : base
  82. if (!mobile()) return perf
  83. return {
  84. ...perf,
  85. disableLineNumbers: true,
  86. }
  87. })
  88. let instance: FileDiff<T> | undefined
  89. const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
  90. const [rendered, setRendered] = createSignal(0)
  91. const getVirtualizer = () => {
  92. if (sharedVirtualizer) return sharedVirtualizer.virtualizer
  93. const result = acquireVirtualizer(container)
  94. if (!result) return
  95. sharedVirtualizer = result
  96. return result.virtualizer
  97. }
  98. const getRoot = () => {
  99. const host = container.querySelector("diffs-container")
  100. if (!(host instanceof HTMLElement)) return
  101. const root = host.shadowRoot
  102. if (!root) return
  103. return root
  104. }
  105. const applyScheme = () => {
  106. const host = container.querySelector("diffs-container")
  107. if (!(host instanceof HTMLElement)) return
  108. const scheme = document.documentElement.dataset.colorScheme
  109. if (scheme === "dark" || scheme === "light") {
  110. host.dataset.colorScheme = scheme
  111. return
  112. }
  113. host.removeAttribute("data-color-scheme")
  114. }
  115. const lineIndex = (split: boolean, element: HTMLElement) => {
  116. const raw = element.dataset.lineIndex
  117. if (!raw) return
  118. const values = raw
  119. .split(",")
  120. .map((value) => parseInt(value, 10))
  121. .filter((value) => !Number.isNaN(value))
  122. if (values.length === 0) return
  123. if (!split) return values[0]
  124. if (values.length === 2) return values[1]
  125. return values[0]
  126. }
  127. const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
  128. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
  129. (node): node is HTMLElement => node instanceof HTMLElement,
  130. )
  131. if (nodes.length === 0) return
  132. const targetSide = side ?? "additions"
  133. for (const node of nodes) {
  134. if (findSide(node) === targetSide) return lineIndex(split, node)
  135. if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
  136. }
  137. }
  138. const fixSelection = (range: SelectedLineRange | null) => {
  139. if (!range) return range
  140. const root = getRoot()
  141. if (!root) return
  142. const diffs = root.querySelector("[data-diff]")
  143. if (!(diffs instanceof HTMLElement)) return
  144. const split = diffs.dataset.diffType === "split"
  145. const start = rowIndex(root, split, range.start, range.side)
  146. const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
  147. if (start === undefined || end === undefined) {
  148. if (root.querySelector("[data-line], [data-alt-line]") == null) return
  149. return null
  150. }
  151. if (start <= end) return range
  152. const side = range.endSide ?? range.side
  153. const swapped: SelectedLineRange = {
  154. start: range.end,
  155. end: range.start,
  156. }
  157. if (side) swapped.side = side
  158. if (range.endSide && range.side) swapped.endSide = range.side
  159. return swapped
  160. }
  161. const notifyRendered = () => {
  162. observer?.disconnect()
  163. observer = undefined
  164. renderToken++
  165. const token = renderToken
  166. let settle = 0
  167. const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
  168. const notify = () => {
  169. if (token !== renderToken) return
  170. observer?.disconnect()
  171. observer = undefined
  172. requestAnimationFrame(() => {
  173. if (token !== renderToken) return
  174. setSelectedLines(lastSelection)
  175. local.onRendered?.()
  176. })
  177. }
  178. const schedule = () => {
  179. settle++
  180. const current = settle
  181. requestAnimationFrame(() => {
  182. if (token !== renderToken) return
  183. if (current !== settle) return
  184. requestAnimationFrame(() => {
  185. if (token !== renderToken) return
  186. if (current !== settle) return
  187. notify()
  188. })
  189. })
  190. }
  191. const observeRoot = (root: ShadowRoot) => {
  192. observer?.disconnect()
  193. observer = new MutationObserver(() => {
  194. if (token !== renderToken) return
  195. if (!isReady(root)) return
  196. schedule()
  197. })
  198. observer.observe(root, { childList: true, subtree: true })
  199. if (!isReady(root)) return
  200. schedule()
  201. }
  202. const root = getRoot()
  203. if (typeof MutationObserver === "undefined") {
  204. if (!root || !isReady(root)) return
  205. setSelectedLines(lastSelection)
  206. local.onRendered?.()
  207. return
  208. }
  209. if (root) {
  210. observeRoot(root)
  211. return
  212. }
  213. observer = new MutationObserver(() => {
  214. if (token !== renderToken) return
  215. const root = getRoot()
  216. if (!root) return
  217. observeRoot(root)
  218. })
  219. observer.observe(container, { childList: true, subtree: true })
  220. }
  221. const applyCommentedLines = (ranges: SelectedLineRange[]) => {
  222. const root = getRoot()
  223. if (!root) return
  224. const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
  225. for (const node of existing) {
  226. if (!(node instanceof HTMLElement)) continue
  227. node.removeAttribute("data-comment-selected")
  228. }
  229. const diffs = root.querySelector("[data-diff]")
  230. if (!(diffs instanceof HTMLElement)) return
  231. const split = diffs.dataset.diffType === "split"
  232. const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
  233. (node): node is HTMLElement => node instanceof HTMLElement,
  234. )
  235. if (rows.length === 0) return
  236. const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
  237. (node): node is HTMLElement => node instanceof HTMLElement,
  238. )
  239. for (const range of ranges) {
  240. const start = rowIndex(root, split, range.start, range.side)
  241. if (start === undefined) continue
  242. const end = (() => {
  243. const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
  244. if (same) return start
  245. return rowIndex(root, split, range.end, range.endSide ?? range.side)
  246. })()
  247. if (end === undefined) continue
  248. const first = Math.min(start, end)
  249. const last = Math.max(start, end)
  250. for (const row of rows) {
  251. const idx = lineIndex(split, row)
  252. if (idx === undefined) continue
  253. if (idx < first || idx > last) continue
  254. row.setAttribute("data-comment-selected", "")
  255. }
  256. for (const annotation of annotations) {
  257. const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
  258. if (Number.isNaN(idx)) continue
  259. if (idx < first || idx > last) continue
  260. annotation.setAttribute("data-comment-selected", "")
  261. }
  262. }
  263. }
  264. const setSelectedLines = (range: SelectedLineRange | null) => {
  265. const active = current()
  266. if (!active) return
  267. const fixed = fixSelection(range)
  268. if (fixed === undefined) {
  269. lastSelection = range
  270. return
  271. }
  272. lastSelection = fixed
  273. active.setSelectedLines(fixed)
  274. }
  275. const updateSelection = () => {
  276. const root = getRoot()
  277. if (!root) return
  278. const selection =
  279. (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
  280. if (!selection || selection.isCollapsed) return
  281. const domRange =
  282. (
  283. selection as unknown as {
  284. getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
  285. }
  286. ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
  287. (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
  288. const startNode = domRange?.startContainer ?? selection.anchorNode
  289. const endNode = domRange?.endContainer ?? selection.focusNode
  290. if (!startNode || !endNode) return
  291. if (!root.contains(startNode) || !root.contains(endNode)) return
  292. const start = findLineNumber(startNode)
  293. const end = findLineNumber(endNode)
  294. if (start === undefined || end === undefined) return
  295. const startSide = findSide(startNode)
  296. const endSide = findSide(endNode)
  297. const side = startSide ?? endSide
  298. const selected: SelectedLineRange = {
  299. start,
  300. end,
  301. }
  302. if (side) selected.side = side
  303. if (endSide && side && endSide !== side) selected.endSide = endSide
  304. setSelectedLines(selected)
  305. }
  306. const scheduleSelectionUpdate = () => {
  307. if (selectionFrame !== undefined) return
  308. selectionFrame = requestAnimationFrame(() => {
  309. selectionFrame = undefined
  310. updateSelection()
  311. if (!pendingSelectionEnd) return
  312. pendingSelectionEnd = false
  313. props.onLineSelectionEnd?.(lastSelection)
  314. })
  315. }
  316. const updateDragSelection = () => {
  317. if (dragStart === undefined || dragEnd === undefined) return
  318. const selected: SelectedLineRange = {
  319. start: dragStart,
  320. end: dragEnd,
  321. }
  322. if (dragSide) selected.side = dragSide
  323. if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
  324. setSelectedLines(selected)
  325. }
  326. const scheduleDragUpdate = () => {
  327. if (dragFrame !== undefined) return
  328. dragFrame = requestAnimationFrame(() => {
  329. dragFrame = undefined
  330. updateDragSelection()
  331. })
  332. }
  333. const lineFromMouseEvent = (event: MouseEvent) => {
  334. const path = event.composedPath()
  335. let numberColumn = false
  336. let line: number | undefined
  337. let side: SelectionSide | undefined
  338. for (const item of path) {
  339. if (!(item instanceof HTMLElement)) continue
  340. numberColumn = numberColumn || item.dataset.columnNumber != null
  341. if (side === undefined) {
  342. const type = item.dataset.lineType
  343. if (type === "change-deletion") side = "deletions"
  344. if (type === "change-addition" || type === "change-additions") side = "additions"
  345. }
  346. if (side === undefined && item.dataset.code != null) {
  347. side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
  348. }
  349. if (line === undefined) {
  350. const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
  351. if (!Number.isNaN(primary)) {
  352. line = primary
  353. } else {
  354. const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
  355. if (!Number.isNaN(alt)) line = alt
  356. }
  357. }
  358. if (numberColumn && line !== undefined && side !== undefined) break
  359. }
  360. return { line, numberColumn, side }
  361. }
  362. const handleMouseDown = (event: MouseEvent) => {
  363. if (props.enableLineSelection !== true) return
  364. if (event.button !== 0) return
  365. const { line, numberColumn, side } = lineFromMouseEvent(event)
  366. if (numberColumn) return
  367. if (line === undefined) return
  368. dragStart = line
  369. dragEnd = line
  370. dragSide = side
  371. dragEndSide = side
  372. dragMoved = false
  373. }
  374. const handleMouseMove = (event: MouseEvent) => {
  375. if (props.enableLineSelection !== true) return
  376. if (dragStart === undefined) return
  377. if ((event.buttons & 1) === 0) {
  378. dragStart = undefined
  379. dragEnd = undefined
  380. dragSide = undefined
  381. dragEndSide = undefined
  382. dragMoved = false
  383. return
  384. }
  385. const { line, side } = lineFromMouseEvent(event)
  386. if (line === undefined) return
  387. dragEnd = line
  388. dragEndSide = side
  389. dragMoved = true
  390. scheduleDragUpdate()
  391. }
  392. const handleMouseUp = () => {
  393. if (props.enableLineSelection !== true) return
  394. if (dragStart === undefined) return
  395. if (!dragMoved) {
  396. pendingSelectionEnd = false
  397. const line = dragStart
  398. const selected: SelectedLineRange = {
  399. start: line,
  400. end: line,
  401. }
  402. if (dragSide) selected.side = dragSide
  403. setSelectedLines(selected)
  404. props.onLineSelectionEnd?.(lastSelection)
  405. dragStart = undefined
  406. dragEnd = undefined
  407. dragSide = undefined
  408. dragEndSide = undefined
  409. dragMoved = false
  410. return
  411. }
  412. pendingSelectionEnd = true
  413. scheduleDragUpdate()
  414. scheduleSelectionUpdate()
  415. dragStart = undefined
  416. dragEnd = undefined
  417. dragSide = undefined
  418. dragEndSide = undefined
  419. dragMoved = false
  420. }
  421. const handleSelectionChange = () => {
  422. if (props.enableLineSelection !== true) return
  423. if (dragStart === undefined) return
  424. const selection = window.getSelection()
  425. if (!selection || selection.isCollapsed) return
  426. scheduleSelectionUpdate()
  427. }
  428. createEffect(() => {
  429. const opts = options()
  430. const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
  431. const virtualizer = getVirtualizer()
  432. const annotations = local.annotations
  433. const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
  434. const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
  435. const cacheKey = (contents: string) => {
  436. if (!large()) return sampledChecksum(contents, contents.length)
  437. return sampledChecksum(contents)
  438. }
  439. instance?.cleanUp()
  440. instance = virtualizer
  441. ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
  442. : new FileDiff<T>(opts, workerPool)
  443. setCurrent(instance)
  444. container.innerHTML = ""
  445. instance.render({
  446. oldFile: {
  447. ...local.before,
  448. contents: beforeContents,
  449. cacheKey: cacheKey(beforeContents),
  450. },
  451. newFile: {
  452. ...local.after,
  453. contents: afterContents,
  454. cacheKey: cacheKey(afterContents),
  455. },
  456. lineAnnotations: annotations,
  457. containerWrapper: container,
  458. })
  459. applyScheme()
  460. setRendered((value) => value + 1)
  461. notifyRendered()
  462. })
  463. createEffect(() => {
  464. if (typeof document === "undefined") return
  465. if (typeof MutationObserver === "undefined") return
  466. const root = document.documentElement
  467. const monitor = new MutationObserver(() => applyScheme())
  468. monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
  469. applyScheme()
  470. onCleanup(() => monitor.disconnect())
  471. })
  472. createEffect(() => {
  473. rendered()
  474. const ranges = local.commentedLines ?? []
  475. requestAnimationFrame(() => applyCommentedLines(ranges))
  476. })
  477. createEffect(() => {
  478. const selected = local.selectedLines ?? null
  479. setSelectedLines(selected)
  480. })
  481. createEffect(() => {
  482. if (props.enableLineSelection !== true) return
  483. container.addEventListener("mousedown", handleMouseDown)
  484. container.addEventListener("mousemove", handleMouseMove)
  485. window.addEventListener("mouseup", handleMouseUp)
  486. document.addEventListener("selectionchange", handleSelectionChange)
  487. onCleanup(() => {
  488. container.removeEventListener("mousedown", handleMouseDown)
  489. container.removeEventListener("mousemove", handleMouseMove)
  490. window.removeEventListener("mouseup", handleMouseUp)
  491. document.removeEventListener("selectionchange", handleSelectionChange)
  492. })
  493. })
  494. onCleanup(() => {
  495. observer?.disconnect()
  496. if (selectionFrame !== undefined) {
  497. cancelAnimationFrame(selectionFrame)
  498. selectionFrame = undefined
  499. }
  500. if (dragFrame !== undefined) {
  501. cancelAnimationFrame(dragFrame)
  502. dragFrame = undefined
  503. }
  504. dragStart = undefined
  505. dragEnd = undefined
  506. dragSide = undefined
  507. dragEndSide = undefined
  508. dragMoved = false
  509. lastSelection = null
  510. pendingSelectionEnd = false
  511. instance?.cleanUp()
  512. setCurrent(undefined)
  513. sharedVirtualizer?.release()
  514. sharedVirtualizer = undefined
  515. })
  516. return <div data-component="diff" style={styleVariables} ref={container} />
  517. }