code.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  1. import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
  2. import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
  3. import { useLocal, type TextSelection } from "@/context/local"
  4. import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
  5. import { useShiki } from "@opencode-ai/ui"
  6. type DefinedSelection = Exclude<TextSelection, undefined>
  7. interface Props extends ComponentProps<"div"> {
  8. code: string
  9. path: string
  10. }
  11. export function Code(props: Props) {
  12. const ctx = useLocal()
  13. const highlighter = useShiki()
  14. const [local, others] = splitProps(props, ["class", "classList", "code", "path"])
  15. const lang = createMemo(() => {
  16. const ext = getFileExtension(local.path)
  17. if (ext in bundledLanguages) return ext
  18. return "text"
  19. })
  20. let container: HTMLDivElement | undefined
  21. let isProgrammaticSelection = false
  22. const ranges = createMemo<DefinedSelection[]>(() => {
  23. const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }>
  24. const result: DefinedSelection[] = []
  25. for (const item of items) {
  26. if (item.path !== local.path) continue
  27. const selection = item.selection
  28. if (!selection) continue
  29. result.push(selection)
  30. }
  31. return result
  32. })
  33. const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => {
  34. const highlighted = new Set<number>()
  35. for (const selection of selections) {
  36. const startLine = selection.startLine
  37. const endLine = selection.endLine
  38. const start = Math.max(1, Math.min(startLine, endLine))
  39. const end = Math.max(start, Math.max(startLine, endLine))
  40. const count = end - start + 1
  41. if (count <= 0) continue
  42. const values = Array.from({ length: count }, (_, index) => start + index)
  43. for (const value of values) highlighted.add(value)
  44. }
  45. return {
  46. name: "line-number-highlight",
  47. line(node, index) {
  48. if (!highlighted.has(index)) return
  49. this.addClassToHast(node, "line-number-highlight")
  50. const children = node.children
  51. if (!Array.isArray(children)) return
  52. for (const child of children) {
  53. if (!child || typeof child !== "object") continue
  54. const element = child as { type?: string; properties?: { className?: string[] } }
  55. if (element.type !== "element") continue
  56. const className = element.properties?.className
  57. if (!Array.isArray(className)) continue
  58. const matches = className.includes("diff-oldln") || className.includes("diff-newln")
  59. if (!matches) continue
  60. if (className.includes("line-number-highlight")) continue
  61. className.push("line-number-highlight")
  62. }
  63. },
  64. }
  65. }
  66. const [html] = createResource(
  67. () => ranges(),
  68. async (activeRanges) => {
  69. if (!highlighter.getLoadedLanguages().includes(lang())) {
  70. await highlighter.loadLanguage(lang() as BundledLanguage)
  71. }
  72. return highlighter.codeToHtml(local.code || "", {
  73. lang: lang() && lang() in bundledLanguages ? lang() : "text",
  74. theme: "opencode",
  75. transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)],
  76. }) as string
  77. },
  78. )
  79. onMount(() => {
  80. if (!container) return
  81. let ticking = false
  82. const onScroll = () => {
  83. if (!container) return
  84. // if (ctx.file.active()?.path !== local.path) return
  85. if (ticking) return
  86. ticking = true
  87. requestAnimationFrame(() => {
  88. ticking = false
  89. ctx.file.scroll(local.path, container!.scrollTop)
  90. })
  91. }
  92. const onSelectionChange = () => {
  93. if (!container) return
  94. if (isProgrammaticSelection) return
  95. // if (ctx.file.active()?.path !== local.path) return
  96. const d = getSelectionInContainer(container)
  97. if (!d) return
  98. const p = ctx.file.node(local.path)?.selection
  99. if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return
  100. ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech })
  101. }
  102. const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
  103. const onKeyDown = (e: KeyboardEvent) => {
  104. // if (ctx.file.active()?.path !== local.path) return
  105. const ae = document.activeElement as HTMLElement | undefined
  106. const tag = (ae?.tagName || "").toLowerCase()
  107. const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
  108. if (inputFocused) return
  109. if (e.getModifierState(MOD) && e.key.toLowerCase() === "a") {
  110. e.preventDefault()
  111. if (!container) return
  112. const element = container.querySelector("code") as HTMLElement | undefined
  113. if (!element) return
  114. const lines = Array.from(element.querySelectorAll(".line"))
  115. if (!lines.length) return
  116. const r = document.createRange()
  117. const last = lines[lines.length - 1]
  118. r.selectNodeContents(last)
  119. const lastLen = r.toString().length
  120. ctx.file.select(local.path, { startLine: 1, startChar: 0, endLine: lines.length, endChar: lastLen })
  121. }
  122. }
  123. container.addEventListener("scroll", onScroll)
  124. document.addEventListener("selectionchange", onSelectionChange)
  125. document.addEventListener("keydown", onKeyDown)
  126. onCleanup(() => {
  127. container?.removeEventListener("scroll", onScroll)
  128. document.removeEventListener("selectionchange", onSelectionChange)
  129. document.removeEventListener("keydown", onKeyDown)
  130. })
  131. })
  132. // Restore scroll position from store when content is ready
  133. createEffect(() => {
  134. const content = html()
  135. if (!container || !content) return
  136. const top = ctx.file.node(local.path)?.scrollTop
  137. if (top !== undefined && container.scrollTop !== top) container.scrollTop = top
  138. })
  139. // Sync selection from store -> DOM
  140. createEffect(() => {
  141. const content = html()
  142. if (!container || !content) return
  143. // if (ctx.file.active()?.path !== local.path) return
  144. const codeEl = container.querySelector("code") as HTMLElement | undefined
  145. if (!codeEl) return
  146. const target = ctx.file.node(local.path)?.selection
  147. const current = getSelectionInContainer(container)
  148. const sel = window.getSelection()
  149. if (!sel) return
  150. if (!target) {
  151. if (current) {
  152. isProgrammaticSelection = true
  153. sel.removeAllRanges()
  154. queueMicrotask(() => {
  155. isProgrammaticSelection = false
  156. })
  157. }
  158. return
  159. }
  160. const matches = !!(
  161. current &&
  162. current.sl === target.startLine &&
  163. current.sch === target.startChar &&
  164. current.el === target.endLine &&
  165. current.ech === target.endChar
  166. )
  167. if (matches) return
  168. const lines = Array.from(codeEl.querySelectorAll(".line"))
  169. if (lines.length === 0) return
  170. let sIdx = Math.max(0, target.startLine - 1)
  171. let eIdx = Math.max(0, target.endLine - 1)
  172. let sChar = Math.max(0, target.startChar || 0)
  173. let eChar = Math.max(0, target.endChar || 0)
  174. if (sIdx > eIdx || (sIdx === eIdx && sChar > eChar)) {
  175. const ti = sIdx
  176. sIdx = eIdx
  177. eIdx = ti
  178. const tc = sChar
  179. sChar = eChar
  180. eChar = tc
  181. }
  182. if (eChar === 0 && eIdx > sIdx) {
  183. eIdx = eIdx - 1
  184. eChar = Number.POSITIVE_INFINITY
  185. }
  186. if (sIdx >= lines.length) return
  187. if (eIdx >= lines.length) eIdx = lines.length - 1
  188. const s = getNodeOffsetInLine(lines[sIdx], sChar) ?? { node: lines[sIdx], offset: 0 }
  189. const e = getNodeOffsetInLine(lines[eIdx], eChar) ?? { node: lines[eIdx], offset: lines[eIdx].childNodes.length }
  190. const range = document.createRange()
  191. range.setStart(s.node, s.offset)
  192. range.setEnd(e.node, e.offset)
  193. isProgrammaticSelection = true
  194. sel.removeAllRanges()
  195. sel.addRange(range)
  196. queueMicrotask(() => {
  197. isProgrammaticSelection = false
  198. })
  199. })
  200. // Build/toggle split layout and apply folding (both unified and split)
  201. createEffect(() => {
  202. const content = html()
  203. if (!container || !content) return
  204. const view = ctx.file.view(local.path)
  205. const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre"))
  206. if (pres.length === 0) return
  207. const originalPre = pres[0]
  208. const split = container.querySelector<HTMLElement>(".diff-split")
  209. if (view === "diff-split") {
  210. applySplitDiff(container)
  211. const next = container.querySelector<HTMLElement>(".diff-split")
  212. if (next) next.style.display = ""
  213. originalPre.style.display = "none"
  214. } else {
  215. if (split) split.style.display = "none"
  216. originalPre.style.display = ""
  217. }
  218. const expanded = ctx.file.folded(local.path)
  219. if (view === "diff-split") {
  220. const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code")
  221. const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code")
  222. if (left)
  223. applyDiffFolding(left, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "left" })
  224. if (right)
  225. applyDiffFolding(right, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "right" })
  226. } else {
  227. const code = container.querySelector<HTMLElement>("pre code")
  228. if (code)
  229. applyDiffFolding(code, 3, {
  230. expanded,
  231. onExpand: (key) => ctx.file.unfold(local.path, key),
  232. })
  233. }
  234. })
  235. // Highlight groups + scroll coupling
  236. const clearHighlights = () => {
  237. if (!container) return
  238. container.querySelectorAll<HTMLElement>(".diff-selected").forEach((el) => el.classList.remove("diff-selected"))
  239. }
  240. const applyHighlight = (idx: number, scroll?: boolean) => {
  241. if (!container) return
  242. const view = ctx.file.view(local.path)
  243. if (view === "raw") return
  244. clearHighlights()
  245. const nodes: HTMLElement[] = []
  246. if (view === "diff-split") {
  247. const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code")
  248. const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code")
  249. if (left)
  250. nodes.push(...Array.from(left.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="remove"]`)))
  251. if (right)
  252. nodes.push(...Array.from(right.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="add"]`)))
  253. } else {
  254. const code = container.querySelector<HTMLElement>("pre code")
  255. if (code) nodes.push(...Array.from(code.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"]`)))
  256. }
  257. for (const n of nodes) n.classList.add("diff-selected")
  258. if (scroll && nodes.length) nodes[0].scrollIntoView({ block: "center", behavior: "smooth" })
  259. }
  260. const countGroups = () => {
  261. if (!container) return 0
  262. const code = container.querySelector<HTMLElement>("pre code")
  263. if (!code) return 0
  264. const set = new Set<string>()
  265. for (const el of Array.from(code.querySelectorAll<HTMLElement>(".diff-line[data-chgrp]"))) {
  266. const v = el.getAttribute("data-chgrp")
  267. if (v != undefined) set.add(v)
  268. }
  269. return set.size
  270. }
  271. let lastIdx: number | undefined = undefined
  272. let lastView: string | undefined
  273. let lastContent: string | undefined
  274. let lastRawIdx: number | undefined = undefined
  275. createEffect(() => {
  276. const content = html()
  277. if (!container || !content) return
  278. const view = ctx.file.view(local.path)
  279. const raw = ctx.file.changeIndex(local.path)
  280. if (raw === undefined) return
  281. const total = countGroups()
  282. if (total <= 0) return
  283. const next = ((raw % total) + total) % total
  284. const navigated = lastRawIdx !== undefined && lastRawIdx !== raw
  285. if (next !== raw) {
  286. ctx.file.setChangeIndex(local.path, next)
  287. applyHighlight(next, true)
  288. } else {
  289. if (lastView !== view || lastContent !== content) applyHighlight(next)
  290. if ((lastIdx !== undefined && lastIdx !== next) || navigated) applyHighlight(next, true)
  291. }
  292. lastRawIdx = raw
  293. lastIdx = next
  294. lastView = view
  295. lastContent = content
  296. })
  297. return (
  298. <div
  299. ref={(el) => {
  300. container = el
  301. }}
  302. innerHTML={html()}
  303. class="
  304. font-mono text-xs tracking-wide overflow-y-auto h-full
  305. [&]:[counter-reset:line]
  306. [&_pre]:focus-visible:outline-none
  307. [&_pre]:overflow-x-auto [&_pre]:no-scrollbar
  308. [&_code]:min-w-full [&_code]:inline-block
  309. [&_.tab]:relative
  310. [&_.tab::before]:content['⇥']
  311. [&_.tab::before]:absolute
  312. [&_.tab::before]:opacity-0
  313. [&_.space]:relative
  314. [&_.space::before]:content-['·']
  315. [&_.space::before]:absolute
  316. [&_.space::before]:opacity-0
  317. [&_.line]:inline-block [&_.line]:w-full
  318. [&_.line]:hover:bg-background-element
  319. [&_.line::before]:sticky [&_.line::before]:left-0
  320. [&_.line::before]:w-12 [&_.line::before]:pr-4
  321. [&_.line::before]:z-10
  322. [&_.line::before]:bg-background-panel
  323. [&_.line::before]:text-text-muted/60
  324. [&_.line::before]:text-right [&_.line::before]:inline-block
  325. [&_.line::before]:select-none
  326. [&_.line::before]:[counter-increment:line]
  327. [&_.line::before]:content-[counter(line)]
  328. [&_.line-number-highlight]:bg-accent/20
  329. [&_.line-number-highlight::before]:bg-accent/40!
  330. [&_.line-number-highlight::before]:text-background-panel!
  331. [&_code.code-diff_.line::before]:content-['']
  332. [&_code.code-diff_.line::before]:w-0
  333. [&_code.code-diff_.line::before]:pr-0
  334. [&_.diff-split_code.code-diff::before]:w-10
  335. [&_.diff-split_.diff-newln]:left-0
  336. [&_.diff-oldln]:sticky [&_.diff-oldln]:left-0
  337. [&_.diff-oldln]:w-10 [&_.diff-oldln]:pr-2
  338. [&_.diff-oldln]:z-40
  339. [&_.diff-oldln]:text-text-muted/60
  340. [&_.diff-oldln]:text-right [&_.diff-oldln]:inline-block
  341. [&_.diff-oldln]:select-none
  342. [&_.diff-oldln]:bg-background-panel
  343. [&_.diff-newln]:sticky [&_.diff-newln]:left-10
  344. [&_.diff-newln]:w-10 [&_.diff-newln]:pr-2
  345. [&_.diff-newln]:z-40
  346. [&_.diff-newln]:text-text-muted/60
  347. [&_.diff-newln]:text-right [&_.diff-newln]:inline-block
  348. [&_.diff-newln]:select-none
  349. [&_.diff-newln]:bg-background-panel
  350. [&_.diff-add]:bg-success/20!
  351. [&_.diff-add.diff-selected]:bg-success/50!
  352. [&_.diff-add_.diff-oldln]:bg-success!
  353. [&_.diff-add_.diff-oldln]:text-background-panel!
  354. [&_.diff-add_.diff-newln]:bg-success!
  355. [&_.diff-add_.diff-newln]:text-background-panel!
  356. [&_.diff-remove]:bg-error/20!
  357. [&_.diff-remove.diff-selected]:bg-error/50!
  358. [&_.diff-remove_.diff-newln]:bg-error!
  359. [&_.diff-remove_.diff-newln]:text-background-panel!
  360. [&_.diff-remove_.diff-oldln]:bg-error!
  361. [&_.diff-remove_.diff-oldln]:text-background-panel!
  362. [&_.diff-sign]:inline-block [&_.diff-sign]:px-2 [&_.diff-sign]:select-none
  363. [&_.diff-blank]:bg-background-element
  364. [&_.diff-blank_.diff-oldln]:bg-background-element
  365. [&_.diff-blank_.diff-newln]:bg-background-element
  366. [&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
  367. [&_.diff-collapsed]:select-none
  368. [&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
  369. [&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
  370. [&_.diff-collapsed]:text-xs
  371. [&_.diff-collapsed_.diff-oldln]:bg-info!
  372. [&_.diff-collapsed_.diff-newln]:bg-info!
  373. "
  374. classList={{
  375. ...(local.classList || {}),
  376. [local.class ?? ""]: !!local.class,
  377. }}
  378. {...others}
  379. ></div>
  380. )
  381. }
  382. function transformerUnifiedDiff(): ShikiTransformer {
  383. const kinds = new Map<number, string>()
  384. const meta = new Map<number, { old?: number; new?: number; sign?: string }>()
  385. let isDiff = false
  386. return {
  387. name: "unified-diff",
  388. preprocess(input) {
  389. kinds.clear()
  390. meta.clear()
  391. isDiff = false
  392. const ls = input.split(/\r?\n/)
  393. const out: Array<string> = []
  394. let oldNo = 0
  395. let newNo = 0
  396. let inHunk = false
  397. for (let i = 0; i < ls.length; i++) {
  398. const s = ls[i]
  399. const m = s.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/)
  400. if (m) {
  401. isDiff = true
  402. inHunk = true
  403. oldNo = parseInt(m[1], 10)
  404. newNo = parseInt(m[3], 10)
  405. continue
  406. }
  407. if (
  408. /^diff --git /.test(s) ||
  409. /^Index: /.test(s) ||
  410. /^--- /.test(s) ||
  411. /^\+\+\+ /.test(s) ||
  412. /^[=]{3,}$/.test(s) ||
  413. /^\*{3,}$/.test(s) ||
  414. /^\\ No newline at end of file$/.test(s)
  415. ) {
  416. isDiff = true
  417. continue
  418. }
  419. if (!inHunk) {
  420. out.push(s)
  421. continue
  422. }
  423. if (/^\+/.test(s)) {
  424. out.push(s)
  425. const ln = out.length
  426. kinds.set(ln, "add")
  427. meta.set(ln, { new: newNo, sign: "+" })
  428. newNo++
  429. continue
  430. }
  431. if (/^-/.test(s)) {
  432. out.push(s)
  433. const ln = out.length
  434. kinds.set(ln, "remove")
  435. meta.set(ln, { old: oldNo, sign: "-" })
  436. oldNo++
  437. continue
  438. }
  439. if (/^ /.test(s)) {
  440. out.push(s)
  441. const ln = out.length
  442. kinds.set(ln, "context")
  443. meta.set(ln, { old: oldNo, new: newNo })
  444. oldNo++
  445. newNo++
  446. continue
  447. }
  448. // fallback in hunks
  449. out.push(s)
  450. }
  451. return out.join("\n").trimEnd()
  452. },
  453. code(node) {
  454. if (isDiff) this.addClassToHast(node, "code-diff")
  455. },
  456. pre(node) {
  457. if (isDiff) this.addClassToHast(node, "code-diff")
  458. },
  459. line(node, line) {
  460. if (!isDiff) return
  461. const kind = kinds.get(line)
  462. if (!kind) return
  463. const m = meta.get(line) || {}
  464. this.addClassToHast(node, "diff-line")
  465. this.addClassToHast(node, `diff-${kind}`)
  466. node.properties = node.properties || {}
  467. ;(node.properties as any)["data-diff"] = kind
  468. if (m.old != undefined) (node.properties as any)["data-old"] = String(m.old)
  469. if (m.new != undefined) (node.properties as any)["data-new"] = String(m.new)
  470. const oldSpan = {
  471. type: "element",
  472. tagName: "span",
  473. properties: { className: ["diff-oldln"] },
  474. children: [{ type: "text", value: m.old != undefined ? String(m.old) : " " }],
  475. }
  476. const newSpan = {
  477. type: "element",
  478. tagName: "span",
  479. properties: { className: ["diff-newln"] },
  480. children: [{ type: "text", value: m.new != undefined ? String(m.new) : " " }],
  481. }
  482. if (kind === "add" || kind === "remove" || kind === "context") {
  483. const first = (node.children && (node.children as any[])[0]) as any
  484. if (first && first.type === "element" && first.children && first.children.length > 0) {
  485. const t = first.children[0]
  486. if (t && t.type === "text" && typeof t.value === "string" && t.value.length > 0) {
  487. const ch = t.value[0]
  488. if (ch === "+" || ch === "-" || ch === " ") t.value = t.value.slice(1)
  489. }
  490. }
  491. }
  492. const signSpan = {
  493. type: "element",
  494. tagName: "span",
  495. properties: { className: ["diff-sign"] },
  496. children: [{ type: "text", value: (m as any).sign || " " }],
  497. }
  498. // @ts-expect-error hast typing across versions
  499. node.children = [oldSpan, newSpan, signSpan, ...(node.children || [])]
  500. },
  501. }
  502. }
  503. function transformerDiffGroups(): ShikiTransformer {
  504. let group = -1
  505. let inGroup = false
  506. return {
  507. name: "diff-groups",
  508. pre() {
  509. group = -1
  510. inGroup = false
  511. },
  512. line(node) {
  513. const props = (node.properties || {}) as any
  514. const kind = props["data-diff"] as string | undefined
  515. if (kind === "add" || kind === "remove") {
  516. if (!inGroup) {
  517. group += 1
  518. inGroup = true
  519. }
  520. ;(node.properties as any)["data-chgrp"] = String(group)
  521. } else {
  522. inGroup = false
  523. }
  524. },
  525. }
  526. }
  527. function applyDiffFolding(
  528. root: HTMLElement,
  529. context = 3,
  530. options?: { expanded?: string[]; onExpand?: (key: string) => void; side?: "left" | "right" },
  531. ) {
  532. if (!root.classList.contains("code-diff")) return
  533. // Cleanup: unwrap previous collapsed blocks and remove toggles
  534. const blocks = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed-block"))
  535. for (const block of blocks) {
  536. const p = block.parentNode
  537. if (!p) {
  538. block.remove()
  539. continue
  540. }
  541. while (block.firstChild) p.insertBefore(block.firstChild, block)
  542. block.remove()
  543. }
  544. const toggles = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed"))
  545. for (const t of toggles) t.remove()
  546. const lines = Array.from(root.querySelectorAll<HTMLElement>(".diff-line"))
  547. if (lines.length === 0) return
  548. const n = lines.length
  549. const isChange = lines.map((l) => l.dataset["diff"] === "add" || l.dataset["diff"] === "remove")
  550. const isContext = lines.map((l) => l.dataset["diff"] === "context")
  551. if (!isChange.some(Boolean)) return
  552. const visible = new Array(n).fill(false) as boolean[]
  553. for (let i = 0; i < n; i++) if (isChange[i]) visible[i] = true
  554. for (let i = 0; i < n; i++) {
  555. if (isChange[i]) {
  556. const s = Math.max(0, i - context)
  557. const e = Math.min(n - 1, i + context)
  558. for (let j = s; j <= e; j++) if (isContext[j]) visible[j] = true
  559. }
  560. }
  561. type Range = { start: number; end: number }
  562. const ranges: Range[] = []
  563. let i = 0
  564. while (i < n) {
  565. if (!visible[i] && isContext[i]) {
  566. let j = i
  567. while (j + 1 < n && !visible[j + 1] && isContext[j + 1]) j++
  568. ranges.push({ start: i, end: j })
  569. i = j + 1
  570. } else {
  571. i++
  572. }
  573. }
  574. for (const r of ranges) {
  575. const start = lines[r.start]
  576. const end = lines[r.end]
  577. const count = r.end - r.start + 1
  578. const minCollapse = 20
  579. if (count < minCollapse) {
  580. continue
  581. }
  582. // Wrap the entire collapsed chunk (including trailing newline) so it takes no space
  583. const block = document.createElement("span")
  584. block.className = "diff-collapsed-block"
  585. start.parentElement?.insertBefore(block, start)
  586. let cur: Node | undefined = start
  587. while (cur) {
  588. const next: Node | undefined = cur.nextSibling || undefined
  589. block.appendChild(cur)
  590. if (cur === end) {
  591. // Also move the newline after the last line into the block
  592. if (next && next.nodeType === Node.TEXT_NODE && (next.textContent || "").startsWith("\n")) {
  593. block.appendChild(next)
  594. }
  595. break
  596. }
  597. cur = next
  598. }
  599. block.style.display = "none"
  600. const row = document.createElement("span")
  601. row.className = "line diff-collapsed"
  602. row.setAttribute("data-kind", "collapsed")
  603. row.setAttribute("data-count", String(count))
  604. row.setAttribute("tabindex", "0")
  605. row.setAttribute("role", "button")
  606. const oldln = document.createElement("span")
  607. oldln.className = "diff-oldln"
  608. oldln.textContent = " "
  609. const newln = document.createElement("span")
  610. newln.className = "diff-newln"
  611. newln.textContent = " "
  612. const sign = document.createElement("span")
  613. sign.className = "diff-sign"
  614. sign.textContent = "…"
  615. const label = document.createElement("span")
  616. label.textContent = `show ${count} unchanged line${count > 1 ? "s" : ""}`
  617. const key = `o${start.dataset["old"] || ""}-${end.dataset["old"] || ""}:n${start.dataset["new"] || ""}-${end.dataset["new"] || ""}`
  618. const show = (record = true) => {
  619. if (record) options?.onExpand?.(key)
  620. const p = block.parentNode
  621. if (p) {
  622. while (block.firstChild) p.insertBefore(block.firstChild, block)
  623. block.remove()
  624. }
  625. row.remove()
  626. }
  627. row.addEventListener("click", () => show(true))
  628. row.addEventListener("keydown", (ev) => {
  629. if (ev.key === "Enter" || ev.key === " ") {
  630. ev.preventDefault()
  631. show(true)
  632. }
  633. })
  634. block.parentElement?.insertBefore(row, block)
  635. if (!options?.side || options.side === "left") row.appendChild(oldln)
  636. if (!options?.side || options.side === "right") row.appendChild(newln)
  637. row.appendChild(sign)
  638. row.appendChild(label)
  639. if (options?.expanded && options.expanded.includes(key)) {
  640. show(false)
  641. }
  642. }
  643. }
  644. function applySplitDiff(container: HTMLElement) {
  645. const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre"))
  646. if (pres.length === 0) return
  647. const originalPre = pres[0]
  648. const originalCode = originalPre.querySelector("code") as HTMLElement | undefined
  649. if (!originalCode || !originalCode.classList.contains("code-diff")) return
  650. // Rebuild split each time to match current content
  651. const existing = container.querySelector<HTMLElement>(".diff-split")
  652. if (existing) existing.remove()
  653. const grid = document.createElement("div")
  654. grid.className = "diff-split grid grid-cols-2 gap-x-6"
  655. const makeColumn = () => {
  656. const pre = document.createElement("pre")
  657. pre.className = originalPre.className
  658. const code = document.createElement("code")
  659. code.className = originalCode.className
  660. pre.appendChild(code)
  661. return { pre, code }
  662. }
  663. const left = makeColumn()
  664. const right = makeColumn()
  665. // Helpers
  666. const cloneSide = (line: HTMLElement, side: "old" | "new"): HTMLElement => {
  667. const clone = line.cloneNode(true) as HTMLElement
  668. const oldln = clone.querySelector(".diff-oldln")
  669. const newln = clone.querySelector(".diff-newln")
  670. if (side === "old") {
  671. if (newln) newln.remove()
  672. } else {
  673. if (oldln) oldln.remove()
  674. }
  675. return clone
  676. }
  677. const blankLine = (side: "old" | "new", kind: "add" | "remove"): HTMLElement => {
  678. const span = document.createElement("span")
  679. span.className = "line diff-line diff-blank"
  680. span.setAttribute("data-diff", kind)
  681. const ln = document.createElement("span")
  682. ln.className = side === "old" ? "diff-oldln" : "diff-newln"
  683. ln.textContent = " "
  684. span.appendChild(ln)
  685. return span
  686. }
  687. const lines = Array.from(originalCode.querySelectorAll<HTMLElement>(".diff-line"))
  688. let i = 0
  689. while (i < lines.length) {
  690. const cur = lines[i]
  691. const kind = cur.dataset["diff"]
  692. if (kind === "context") {
  693. left.code.appendChild(cloneSide(cur, "old"))
  694. left.code.appendChild(document.createTextNode("\n"))
  695. right.code.appendChild(cloneSide(cur, "new"))
  696. right.code.appendChild(document.createTextNode("\n"))
  697. i++
  698. continue
  699. }
  700. if (kind === "remove") {
  701. // Batch consecutive removes and following adds, then pair
  702. const removes: HTMLElement[] = []
  703. const adds: HTMLElement[] = []
  704. let j = i
  705. while (j < lines.length && lines[j].dataset["diff"] === "remove") {
  706. removes.push(lines[j])
  707. j++
  708. }
  709. let k = j
  710. while (k < lines.length && lines[k].dataset["diff"] === "add") {
  711. adds.push(lines[k])
  712. k++
  713. }
  714. const pairs = Math.min(removes.length, adds.length)
  715. for (let p = 0; p < pairs; p++) {
  716. left.code.appendChild(cloneSide(removes[p], "old"))
  717. left.code.appendChild(document.createTextNode("\n"))
  718. right.code.appendChild(cloneSide(adds[p], "new"))
  719. right.code.appendChild(document.createTextNode("\n"))
  720. }
  721. for (let p = pairs; p < removes.length; p++) {
  722. left.code.appendChild(cloneSide(removes[p], "old"))
  723. left.code.appendChild(document.createTextNode("\n"))
  724. right.code.appendChild(blankLine("new", "remove"))
  725. right.code.appendChild(document.createTextNode("\n"))
  726. }
  727. for (let p = pairs; p < adds.length; p++) {
  728. left.code.appendChild(blankLine("old", "add"))
  729. left.code.appendChild(document.createTextNode("\n"))
  730. right.code.appendChild(cloneSide(adds[p], "new"))
  731. right.code.appendChild(document.createTextNode("\n"))
  732. }
  733. i = k
  734. continue
  735. }
  736. if (kind === "add") {
  737. // Run of adds not preceded by removes
  738. const adds: HTMLElement[] = []
  739. let j = i
  740. while (j < lines.length && lines[j].dataset["diff"] === "add") {
  741. adds.push(lines[j])
  742. j++
  743. }
  744. for (let p = 0; p < adds.length; p++) {
  745. left.code.appendChild(blankLine("old", "add"))
  746. left.code.appendChild(document.createTextNode("\n"))
  747. right.code.appendChild(cloneSide(adds[p], "new"))
  748. right.code.appendChild(document.createTextNode("\n"))
  749. }
  750. i = j
  751. continue
  752. }
  753. // Any other kind: mirror as context
  754. left.code.appendChild(cloneSide(cur, "old"))
  755. left.code.appendChild(document.createTextNode("\n"))
  756. right.code.appendChild(cloneSide(cur, "new"))
  757. right.code.appendChild(document.createTextNode("\n"))
  758. i++
  759. }
  760. grid.appendChild(left.pre)
  761. grid.appendChild(right.pre)
  762. container.appendChild(grid)
  763. }