diff-ssr.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs"
  2. import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
  3. import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
  4. import { Dynamic, isServer } from "solid-js/web"
  5. import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
  6. import { useWorkerPool } from "../context/worker-pool"
  7. export type SSRDiffProps<T = {}> = DiffProps<T> & {
  8. preloadedDiff: PreloadMultiFileDiffResult<T>
  9. }
  10. export function Diff<T>(props: SSRDiffProps<T>) {
  11. let container!: HTMLDivElement
  12. let fileDiffRef!: HTMLElement
  13. const [local, others] = splitProps(props, [
  14. "before",
  15. "after",
  16. "class",
  17. "classList",
  18. "annotations",
  19. "selectedLines",
  20. "commentedLines",
  21. ])
  22. const workerPool = useWorkerPool(props.diffStyle)
  23. let fileDiffInstance: FileDiff<T> | undefined
  24. const cleanupFunctions: Array<() => void> = []
  25. const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
  26. const applyScheme = () => {
  27. const scheme = document.documentElement.dataset.colorScheme
  28. if (scheme === "dark" || scheme === "light") {
  29. fileDiffRef.dataset.colorScheme = scheme
  30. return
  31. }
  32. fileDiffRef.removeAttribute("data-color-scheme")
  33. }
  34. const lineIndex = (split: boolean, element: HTMLElement) => {
  35. const raw = element.dataset.lineIndex
  36. if (!raw) return
  37. const values = raw
  38. .split(",")
  39. .map((value) => parseInt(value, 10))
  40. .filter((value) => !Number.isNaN(value))
  41. if (values.length === 0) return
  42. if (!split) return values[0]
  43. if (values.length === 2) return values[1]
  44. return values[0]
  45. }
  46. const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
  47. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
  48. (node): node is HTMLElement => node instanceof HTMLElement,
  49. )
  50. if (nodes.length === 0) return
  51. const targetSide = side ?? "additions"
  52. for (const node of nodes) {
  53. if (findSide(node) === targetSide) return lineIndex(split, node)
  54. if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
  55. }
  56. }
  57. const fixSelection = (range: SelectedLineRange | null) => {
  58. if (!range) return range
  59. const root = getRoot()
  60. if (!root) return
  61. const diffs = root.querySelector("[data-diffs]")
  62. if (!(diffs instanceof HTMLElement)) return
  63. const split = diffs.dataset.type === "split"
  64. const start = rowIndex(root, split, range.start, range.side)
  65. const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
  66. if (start === undefined || end === undefined) {
  67. if (root.querySelector("[data-line], [data-alt-line]") == null) return
  68. return null
  69. }
  70. if (start <= end) return range
  71. const side = range.endSide ?? range.side
  72. const swapped: SelectedLineRange = {
  73. start: range.end,
  74. end: range.start,
  75. }
  76. if (side) swapped.side = side
  77. if (range.endSide && range.side) swapped.endSide = range.side
  78. return swapped
  79. }
  80. const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
  81. const diff = fileDiffInstance
  82. if (!diff) return
  83. const fixed = fixSelection(range)
  84. if (fixed === undefined) {
  85. if (attempt >= 120) return
  86. requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
  87. return
  88. }
  89. diff.setSelectedLines(fixed)
  90. }
  91. const findSide = (element: HTMLElement): "additions" | "deletions" => {
  92. const line = element.closest("[data-line], [data-alt-line]")
  93. if (line instanceof HTMLElement) {
  94. const type = line.dataset.lineType
  95. if (type === "change-deletion") return "deletions"
  96. if (type === "change-addition" || type === "change-additions") return "additions"
  97. }
  98. const code = element.closest("[data-code]")
  99. if (!(code instanceof HTMLElement)) return "additions"
  100. return code.hasAttribute("data-deletions") ? "deletions" : "additions"
  101. }
  102. const applyCommentedLines = (ranges: SelectedLineRange[]) => {
  103. const root = getRoot()
  104. if (!root) return
  105. const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
  106. for (const node of existing) {
  107. if (!(node instanceof HTMLElement)) continue
  108. node.removeAttribute("data-comment-selected")
  109. }
  110. const diffs = root.querySelector("[data-diffs]")
  111. if (!(diffs instanceof HTMLElement)) return
  112. const split = diffs.dataset.type === "split"
  113. const code = Array.from(diffs.querySelectorAll("[data-code]")).filter(
  114. (node): node is HTMLElement => node instanceof HTMLElement,
  115. )
  116. if (code.length === 0) return
  117. const lineIndex = (element: HTMLElement) => {
  118. const raw = element.dataset.lineIndex
  119. if (!raw) return
  120. const values = raw
  121. .split(",")
  122. .map((value) => parseInt(value, 10))
  123. .filter((value) => !Number.isNaN(value))
  124. if (values.length === 0) return
  125. if (!split) return values[0]
  126. if (values.length === 2) return values[1]
  127. return values[0]
  128. }
  129. const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
  130. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
  131. (node): node is HTMLElement => node instanceof HTMLElement,
  132. )
  133. if (nodes.length === 0) return
  134. const targetSide = side ?? "additions"
  135. for (const node of nodes) {
  136. if (findSide(node) === targetSide) return lineIndex(node)
  137. if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
  138. }
  139. }
  140. for (const range of ranges) {
  141. const start = rowIndex(range.start, range.side)
  142. if (start === undefined) continue
  143. const end = (() => {
  144. const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
  145. if (same) return start
  146. return rowIndex(range.end, range.endSide ?? range.side)
  147. })()
  148. if (end === undefined) continue
  149. const first = Math.min(start, end)
  150. const last = Math.max(start, end)
  151. for (const block of code) {
  152. for (const element of Array.from(block.children)) {
  153. if (!(element instanceof HTMLElement)) continue
  154. const idx = lineIndex(element)
  155. if (idx === undefined) continue
  156. if (idx > last) break
  157. if (idx < first) continue
  158. element.setAttribute("data-comment-selected", "")
  159. const next = element.nextSibling
  160. if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) {
  161. next.setAttribute("data-comment-selected", "")
  162. }
  163. }
  164. }
  165. }
  166. }
  167. onMount(() => {
  168. if (isServer || !props.preloadedDiff) return
  169. applyScheme()
  170. if (typeof MutationObserver !== "undefined") {
  171. const root = document.documentElement
  172. const monitor = new MutationObserver(() => applyScheme())
  173. monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
  174. onCleanup(() => monitor.disconnect())
  175. }
  176. fileDiffInstance = new FileDiff<T>(
  177. {
  178. ...createDefaultOptions(props.diffStyle),
  179. ...others,
  180. ...props.preloadedDiff,
  181. },
  182. workerPool,
  183. )
  184. // @ts-expect-error - fileContainer is private but needed for SSR hydration
  185. fileDiffInstance.fileContainer = fileDiffRef
  186. fileDiffInstance.hydrate({
  187. oldFile: local.before,
  188. newFile: local.after,
  189. lineAnnotations: local.annotations,
  190. fileContainer: fileDiffRef,
  191. containerWrapper: container,
  192. })
  193. setSelectedLines(local.selectedLines ?? null)
  194. createEffect(() => {
  195. fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
  196. })
  197. createEffect(() => {
  198. setSelectedLines(local.selectedLines ?? null)
  199. })
  200. createEffect(() => {
  201. const ranges = local.commentedLines ?? []
  202. requestAnimationFrame(() => applyCommentedLines(ranges))
  203. })
  204. // Hydrate annotation slots with interactive SolidJS components
  205. // if (props.annotations.length > 0 && props.renderAnnotation != null) {
  206. // for (const annotation of props.annotations) {
  207. // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
  208. // const slotElement = fileDiffRef.querySelector(
  209. // `[slot="${slotName}"]`
  210. // ) as HTMLElement;
  211. //
  212. // if (slotElement != null) {
  213. // // Clear the static server-rendered content from the slot
  214. // slotElement.innerHTML = '';
  215. //
  216. // // Mount a fresh SolidJS component into this slot using render().
  217. // // This enables full SolidJS reactivity (signals, effects, etc.)
  218. // const dispose = render(
  219. // () => props.renderAnnotation!(annotation),
  220. // slotElement
  221. // );
  222. // cleanupFunctions.push(dispose);
  223. // }
  224. // }
  225. // }
  226. })
  227. onCleanup(() => {
  228. // Clean up FileDiff event handlers and dispose SolidJS components
  229. fileDiffInstance?.cleanUp()
  230. cleanupFunctions.forEach((dispose) => dispose())
  231. })
  232. return (
  233. <div data-component="diff" style={styleVariables} ref={container}>
  234. <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
  235. <Show when={isServer}>
  236. <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
  237. </Show>
  238. </Dynamic>
  239. </div>
  240. )
  241. }