diff-ssr.tsx 10 KB

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