content-diff.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { parsePatch } from "diff"
  2. import { createMemo } from "solid-js"
  3. import { ContentCode } from "./content-code"
  4. import styles from "./content-diff.module.css"
  5. type DiffRow = {
  6. left: string
  7. right: string
  8. type: "added" | "removed" | "unchanged" | "modified"
  9. }
  10. interface Props {
  11. diff: string
  12. lang?: string
  13. }
  14. export function ContentDiff(props: Props) {
  15. const rows = createMemo(() => {
  16. const diffRows: DiffRow[] = []
  17. try {
  18. const patches = parsePatch(props.diff)
  19. for (const patch of patches) {
  20. for (const hunk of patch.hunks) {
  21. const lines = hunk.lines
  22. let i = 0
  23. while (i < lines.length) {
  24. const line = lines[i]
  25. const content = line.slice(1)
  26. const prefix = line[0]
  27. if (prefix === "-") {
  28. // Look ahead for consecutive additions to pair with removals
  29. const removals: string[] = [content]
  30. let j = i + 1
  31. // Collect all consecutive removals
  32. while (j < lines.length && lines[j][0] === "-") {
  33. removals.push(lines[j].slice(1))
  34. j++
  35. }
  36. // Collect all consecutive additions that follow
  37. const additions: string[] = []
  38. while (j < lines.length && lines[j][0] === "+") {
  39. additions.push(lines[j].slice(1))
  40. j++
  41. }
  42. // Pair removals with additions
  43. const maxLength = Math.max(removals.length, additions.length)
  44. for (let k = 0; k < maxLength; k++) {
  45. const hasLeft = k < removals.length
  46. const hasRight = k < additions.length
  47. if (hasLeft && hasRight) {
  48. // Replacement - left is removed, right is added
  49. diffRows.push({
  50. left: removals[k],
  51. right: additions[k],
  52. type: "modified",
  53. })
  54. } else if (hasLeft) {
  55. // Pure removal
  56. diffRows.push({
  57. left: removals[k],
  58. right: "",
  59. type: "removed",
  60. })
  61. } else if (hasRight) {
  62. // Pure addition - only create if we actually have content
  63. diffRows.push({
  64. left: "",
  65. right: additions[k],
  66. type: "added",
  67. })
  68. }
  69. }
  70. i = j
  71. } else if (prefix === "+") {
  72. // Standalone addition (not paired with removal)
  73. diffRows.push({
  74. left: "",
  75. right: content,
  76. type: "added",
  77. })
  78. i++
  79. } else if (prefix === " ") {
  80. diffRows.push({
  81. left: content === "" ? " " : content,
  82. right: content === "" ? " " : content,
  83. type: "unchanged",
  84. })
  85. i++
  86. } else {
  87. i++
  88. }
  89. }
  90. }
  91. }
  92. } catch (error) {
  93. console.error("Failed to parse patch:", error)
  94. return []
  95. }
  96. return diffRows
  97. })
  98. const mobileRows = createMemo(() => {
  99. const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = []
  100. const currentRows = rows()
  101. let i = 0
  102. while (i < currentRows.length) {
  103. const removedLines: string[] = []
  104. const addedLines: string[] = []
  105. // Collect consecutive modified/removed/added rows
  106. while (
  107. i < currentRows.length &&
  108. (currentRows[i].type === "modified" || currentRows[i].type === "removed" || currentRows[i].type === "added")
  109. ) {
  110. const row = currentRows[i]
  111. if (row.left && (row.type === "removed" || row.type === "modified")) {
  112. removedLines.push(row.left)
  113. }
  114. if (row.right && (row.type === "added" || row.type === "modified")) {
  115. addedLines.push(row.right)
  116. }
  117. i++
  118. }
  119. // Add grouped blocks
  120. if (removedLines.length > 0) {
  121. mobileBlocks.push({ type: "removed", lines: removedLines })
  122. }
  123. if (addedLines.length > 0) {
  124. mobileBlocks.push({ type: "added", lines: addedLines })
  125. }
  126. // Add unchanged rows as-is
  127. if (i < currentRows.length && currentRows[i].type === "unchanged") {
  128. mobileBlocks.push({
  129. type: "unchanged",
  130. lines: [currentRows[i].left],
  131. })
  132. i++
  133. }
  134. }
  135. return mobileBlocks
  136. })
  137. return (
  138. <div class={styles.root}>
  139. <div data-component="desktop">
  140. {rows().map((r) => (
  141. <div data-component="diff-row" data-type={r.type}>
  142. <div data-slot="before" data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}>
  143. <ContentCode code={r.left} flush lang={props.lang} />
  144. </div>
  145. <div data-slot="after" data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}>
  146. <ContentCode code={r.right} lang={props.lang} flush />
  147. </div>
  148. </div>
  149. ))}
  150. </div>
  151. <div data-component="mobile">
  152. {mobileRows().map((block) => (
  153. <div data-component="diff-block" data-type={block.type}>
  154. {block.lines.map((line) => (
  155. <div data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}>
  156. <ContentCode code={line} lang={props.lang} flush />
  157. </div>
  158. ))}
  159. </div>
  160. ))}
  161. </div>
  162. </div>
  163. )
  164. }
  165. // const testDiff = `--- combined_before.txt 2025-06-24 16:38:08
  166. // +++ combined_after.txt 2025-06-24 16:38:12
  167. // @@ -1,21 +1,25 @@
  168. // unchanged line
  169. // -deleted line
  170. // -old content
  171. // +added line
  172. // +new content
  173. //
  174. // -removed empty line below
  175. // +added empty line above
  176. //
  177. // - tab indented
  178. // -trailing spaces
  179. // -very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view
  180. // -unicode content: 🚀 ✨ 中文
  181. // -mixed content with tabs and spaces
  182. // + space indented
  183. // +no trailing spaces
  184. // +short line
  185. // +very long replacement line that will also wrap and test how the diff viewer handles long line additions after short line removals
  186. // +different unicode: 🎉 💻 日本語
  187. // +normalized content with consistent spacing
  188. // +newline to content
  189. //
  190. // -content to remove
  191. // -whitespace only:
  192. // -multiple
  193. // -consecutive
  194. // -deletions
  195. // -single deletion
  196. // +
  197. // +single addition
  198. // +first addition
  199. // +second addition
  200. // +third addition
  201. // line before addition
  202. // +first added line
  203. // +
  204. // +third added line
  205. // line after addition
  206. // final unchanged line`