file.tsx 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202
  1. import { sampledChecksum } from "@kirincode-ai/core/util/encode"
  2. import {
  3. areFilesEqual,
  4. areOptionsEqual,
  5. DEFAULT_VIRTUAL_FILE_METRICS,
  6. type DiffLineAnnotation,
  7. type FileContents,
  8. type FileDiffMetadata,
  9. File as PierreFile,
  10. type FileDiffOptions,
  11. FileDiff,
  12. type FileOptions,
  13. type LineAnnotation,
  14. type SelectedLineRange,
  15. type VirtualFileMetrics,
  16. VirtualizedFile,
  17. VirtualizedFileDiff,
  18. Virtualizer,
  19. } from "@pierre/diffs"
  20. import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
  21. import { createMediaQuery } from "@solid-primitives/media"
  22. import { makeEventListener } from "@solid-primitives/event-listener"
  23. import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
  24. import { createDefaultOptions, styleVariables } from "../pierre"
  25. import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
  26. import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
  27. import { createFileFind } from "../pierre/file-find"
  28. import {
  29. applyViewerScheme,
  30. clearReadyWatcher,
  31. createReadyWatcher,
  32. getViewerHost,
  33. getViewerRoot,
  34. notifyShadowReady,
  35. observeViewerScheme,
  36. } from "../pierre/file-runtime"
  37. import {
  38. findCodeSelectionSide,
  39. findDiffLineNumber,
  40. findElement,
  41. findFileLineNumber,
  42. readShadowLineSelection,
  43. } from "../pierre/file-selection"
  44. import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
  45. import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
  46. import { getWorkerPool } from "../pierre/worker"
  47. import { FileMedia, type FileMediaOptions } from "./file-media"
  48. import { FileSearchBar } from "./file-search"
  49. const VIRTUALIZE_BYTES = 500_000
  50. const codeMetrics = {
  51. ...DEFAULT_VIRTUAL_FILE_METRICS,
  52. lineHeight: 24,
  53. spacing: 0,
  54. } satisfies Partial<VirtualFileMetrics>
  55. type SharedProps<T> = {
  56. annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
  57. selectedLines?: SelectedLineRange | null
  58. commentedLines?: SelectedLineRange[]
  59. onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
  60. onRendered?: () => void
  61. class?: string
  62. classList?: ComponentProps<"div">["classList"]
  63. media?: FileMediaOptions
  64. search?: FileSearchControl
  65. }
  66. export type FileSearchHandle = {
  67. focus: () => void
  68. }
  69. export type FileSearchControl = {
  70. register: (handle: FileSearchHandle | null) => void
  71. }
  72. export type TextFileProps<T = {}> = FileOptions<T> &
  73. SharedProps<T> & {
  74. mode: "text"
  75. file: FileContents
  76. annotations?: LineAnnotation<T>[]
  77. preloadedDiff?: PreloadMultiFileDiffResult<T>
  78. }
  79. type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
  80. type DiffBaseProps<T> = FileDiffOptions<T> &
  81. SharedProps<T> & {
  82. mode: "diff"
  83. annotations?: DiffLineAnnotation<T>[]
  84. preloadedDiff?: DiffPreload<T>
  85. virtualize?: boolean
  86. }
  87. type DiffPairProps<T> = DiffBaseProps<T> & {
  88. before: FileContents
  89. after: FileContents
  90. fileDiff?: undefined
  91. }
  92. type DiffPatchProps<T> = DiffBaseProps<T> & {
  93. fileDiff: FileDiffMetadata
  94. before?: undefined
  95. after?: undefined
  96. }
  97. export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
  98. export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
  99. const sharedKeys = [
  100. "mode",
  101. "media",
  102. "class",
  103. "classList",
  104. "annotations",
  105. "selectedLines",
  106. "commentedLines",
  107. "search",
  108. "onLineSelected",
  109. "onLineSelectionEnd",
  110. "onLineNumberSelectionEnd",
  111. "onRendered",
  112. "preloadedDiff",
  113. ] as const
  114. const textKeys = ["file", ...sharedKeys] as const
  115. const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
  116. // ---------------------------------------------------------------------------
  117. // Shared viewer hook
  118. // ---------------------------------------------------------------------------
  119. type MouseHit = {
  120. line: number | undefined
  121. numberColumn: boolean
  122. side?: DiffSelectionSide
  123. }
  124. type ViewerConfig = {
  125. enableLineSelection: () => boolean
  126. selectedLines: () => SelectedLineRange | null | undefined
  127. commentedLines: () => SelectedLineRange[]
  128. onLineSelectionEnd: (range: SelectedLineRange | null) => void
  129. // mode-specific callbacks
  130. lineFromMouseEvent: (event: MouseEvent) => MouseHit
  131. setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
  132. updateSelection: (preserveTextSelection: boolean) => void
  133. buildDragSelection: () => SelectedLineRange | undefined
  134. buildClickSelection: () => SelectedLineRange | undefined
  135. onDragStart: (hit: MouseHit) => void
  136. onDragMove: (hit: MouseHit) => void
  137. onDragReset: () => void
  138. markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
  139. }
  140. function useFileViewer(config: ViewerConfig) {
  141. let wrapper!: HTMLDivElement
  142. let container!: HTMLDivElement
  143. let overlay!: HTMLDivElement
  144. let selectionFrame: number | undefined
  145. let dragFrame: number | undefined
  146. let dragStart: number | undefined
  147. let dragEnd: number | undefined
  148. let dragMoved = false
  149. let lastSelection: SelectedLineRange | null = null
  150. let pendingSelectionEnd = false
  151. const ready = createReadyWatcher()
  152. const bridge = createLineNumberSelectionBridge()
  153. const [rendered, setRendered] = createSignal(0)
  154. const getRoot = () => getViewerRoot(container)
  155. const getHost = () => getViewerHost(container)
  156. const find = createFileFind({
  157. wrapper: () => wrapper,
  158. overlay: () => overlay,
  159. getRoot,
  160. })
  161. // -- selection scheduling --
  162. const scheduleSelectionUpdate = () => {
  163. if (selectionFrame !== undefined) return
  164. selectionFrame = requestAnimationFrame(() => {
  165. selectionFrame = undefined
  166. const finishing = pendingSelectionEnd
  167. config.updateSelection(finishing)
  168. if (!pendingSelectionEnd) return
  169. pendingSelectionEnd = false
  170. config.onLineSelectionEnd(lastSelection)
  171. })
  172. }
  173. const scheduleDragUpdate = () => {
  174. if (dragFrame !== undefined) return
  175. dragFrame = requestAnimationFrame(() => {
  176. dragFrame = undefined
  177. const selected = config.buildDragSelection()
  178. if (selected) config.setSelectedLines(selected)
  179. })
  180. }
  181. // -- mouse handlers --
  182. const handleMouseDown = (event: MouseEvent) => {
  183. if (!config.enableLineSelection()) return
  184. if (event.button !== 0) return
  185. const hit = config.lineFromMouseEvent(event)
  186. if (hit.numberColumn) {
  187. bridge.begin(true, hit.line)
  188. return
  189. }
  190. if (hit.line === undefined) return
  191. bridge.begin(false, hit.line)
  192. dragStart = hit.line
  193. dragEnd = hit.line
  194. dragMoved = false
  195. config.onDragStart(hit)
  196. }
  197. const handleMouseMove = (event: MouseEvent) => {
  198. if (!config.enableLineSelection()) return
  199. const hit = config.lineFromMouseEvent(event)
  200. if (bridge.track(event.buttons, hit.line)) return
  201. if (dragStart === undefined) return
  202. if ((event.buttons & 1) === 0) {
  203. dragStart = undefined
  204. dragEnd = undefined
  205. dragMoved = false
  206. config.onDragReset()
  207. bridge.finish()
  208. return
  209. }
  210. if (hit.line === undefined) return
  211. dragEnd = hit.line
  212. dragMoved = true
  213. config.onDragMove(hit)
  214. scheduleDragUpdate()
  215. }
  216. const handleMouseUp = () => {
  217. if (!config.enableLineSelection()) return
  218. if (bridge.finish() === "numbers") return
  219. if (dragStart === undefined) return
  220. if (!dragMoved) {
  221. pendingSelectionEnd = false
  222. const selected = config.buildClickSelection()
  223. if (selected) config.setSelectedLines(selected)
  224. config.onLineSelectionEnd(lastSelection)
  225. dragStart = undefined
  226. dragEnd = undefined
  227. dragMoved = false
  228. config.onDragReset()
  229. return
  230. }
  231. pendingSelectionEnd = true
  232. scheduleDragUpdate()
  233. scheduleSelectionUpdate()
  234. dragStart = undefined
  235. dragEnd = undefined
  236. dragMoved = false
  237. config.onDragReset()
  238. }
  239. const handleSelectionChange = () => {
  240. if (!config.enableLineSelection()) return
  241. if (dragStart === undefined) return
  242. const selection = window.getSelection()
  243. if (!selection || selection.isCollapsed) return
  244. scheduleSelectionUpdate()
  245. }
  246. // -- shared effects --
  247. onMount(() => {
  248. onCleanup(observeViewerScheme(getHost))
  249. })
  250. createEffect(() => {
  251. rendered()
  252. const ranges = config.commentedLines()
  253. requestAnimationFrame(() => {
  254. const root = getRoot()
  255. if (!root) return
  256. config.markCommented(root, ranges)
  257. })
  258. })
  259. createEffect(() => {
  260. config.setSelectedLines(config.selectedLines() ?? null)
  261. })
  262. createEffect(() => {
  263. if (!config.enableLineSelection()) return
  264. makeEventListener(container, "mousedown", handleMouseDown)
  265. makeEventListener(container, "mousemove", handleMouseMove)
  266. makeEventListener(window, "mouseup", handleMouseUp)
  267. makeEventListener(document, "selectionchange", handleSelectionChange)
  268. })
  269. onCleanup(() => {
  270. clearReadyWatcher(ready)
  271. if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
  272. if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
  273. selectionFrame = undefined
  274. dragFrame = undefined
  275. dragStart = undefined
  276. dragEnd = undefined
  277. dragMoved = false
  278. bridge.reset()
  279. lastSelection = null
  280. pendingSelectionEnd = false
  281. })
  282. return {
  283. get wrapper() {
  284. return wrapper
  285. },
  286. set wrapper(v: HTMLDivElement) {
  287. wrapper = v
  288. },
  289. get container() {
  290. return container
  291. },
  292. set container(v: HTMLDivElement) {
  293. container = v
  294. },
  295. get overlay() {
  296. return overlay
  297. },
  298. set overlay(v: HTMLDivElement) {
  299. overlay = v
  300. },
  301. get dragStart() {
  302. return dragStart
  303. },
  304. get dragEnd() {
  305. return dragEnd
  306. },
  307. get lastSelection() {
  308. return lastSelection
  309. },
  310. set lastSelection(v: SelectedLineRange | null) {
  311. lastSelection = v
  312. },
  313. ready,
  314. bridge,
  315. rendered,
  316. setRendered,
  317. getRoot,
  318. getHost,
  319. find,
  320. scheduleSelectionUpdate,
  321. }
  322. }
  323. type Viewer = ReturnType<typeof useFileViewer>
  324. type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
  325. type ModeConfig = {
  326. enableLineSelection: () => boolean
  327. selectedLines: () => SelectedLineRange | null | undefined
  328. commentedLines: () => SelectedLineRange[] | undefined
  329. onLineSelectionEnd: (range: SelectedLineRange | null) => void
  330. }
  331. type RenderTarget = {
  332. cleanUp: () => void
  333. }
  334. type AnnotationTarget<A> = {
  335. setLineAnnotations: (annotations: A[]) => void
  336. rerender: () => void
  337. }
  338. type VirtualStrategy = {
  339. get: () => Virtualizer | undefined
  340. cleanup: () => void
  341. }
  342. function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
  343. return useFileViewer({
  344. enableLineSelection: config.enableLineSelection,
  345. selectedLines: config.selectedLines,
  346. commentedLines: () => config.commentedLines() ?? [],
  347. onLineSelectionEnd: config.onLineSelectionEnd,
  348. ...adapter,
  349. })
  350. }
  351. function useSearchHandle(opts: {
  352. search: () => FileSearchControl | undefined
  353. find: ReturnType<typeof createFileFind>
  354. }) {
  355. createEffect(() => {
  356. const search = opts.search()
  357. if (!search) return
  358. const handle = {
  359. focus: () => opts.find.focus(),
  360. } satisfies FileSearchHandle
  361. search.register(handle)
  362. onCleanup(() => search.register(null))
  363. })
  364. }
  365. function createLineCallbacks(opts: {
  366. viewer: Viewer
  367. normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
  368. onLineSelected?: (range: SelectedLineRange | null) => void
  369. onLineSelectionEnd?: (range: SelectedLineRange | null) => void
  370. onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
  371. }) {
  372. const select = (range: SelectedLineRange | null) => {
  373. if (!opts.normalize) return range
  374. const next = opts.normalize(range)
  375. if (next !== undefined) return next
  376. return range
  377. }
  378. return {
  379. onLineSelected: (range: SelectedLineRange | null) => {
  380. const next = select(range)
  381. opts.viewer.lastSelection = next
  382. opts.onLineSelected?.(next)
  383. },
  384. onLineSelectionEnd: (range: SelectedLineRange | null) => {
  385. const next = select(range)
  386. opts.viewer.lastSelection = next
  387. opts.onLineSelectionEnd?.(next)
  388. if (!opts.viewer.bridge.consume(next)) return
  389. requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
  390. },
  391. }
  392. }
  393. function useAnnotationRerender<A>(opts: {
  394. viewer: Viewer
  395. current: () => AnnotationTarget<A> | undefined
  396. annotations: () => A[]
  397. }) {
  398. const applied = new WeakSet<AnnotationTarget<A>>()
  399. createEffect(() => {
  400. opts.viewer.rendered()
  401. const active = opts.current()
  402. if (!active) return
  403. const annotations = opts.annotations()
  404. // renderViewer always draws with empty annotations, so skip the extra rerender
  405. // when this instance has nothing applied and nothing to apply.
  406. if (annotations.length === 0 && !applied.has(active)) return
  407. if (annotations.length === 0) applied.delete(active)
  408. else applied.add(active)
  409. active.setLineAnnotations(annotations)
  410. active.rerender()
  411. requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
  412. })
  413. }
  414. function notifyRendered(opts: {
  415. viewer: Viewer
  416. isReady: (root: ShadowRoot) => boolean
  417. settleFrames?: number
  418. onReady: () => void
  419. }) {
  420. notifyShadowReady({
  421. state: opts.viewer.ready,
  422. container: opts.viewer.container,
  423. getRoot: opts.viewer.getRoot,
  424. isReady: opts.isReady,
  425. settleFrames: opts.settleFrames,
  426. onReady: opts.onReady,
  427. })
  428. }
  429. function renderViewer<I extends RenderTarget>(opts: {
  430. viewer: Viewer
  431. current: I | undefined
  432. reset?: boolean
  433. create: () => I
  434. update?: (value: I) => void
  435. assign: (value: I) => void
  436. draw: (value: I) => void
  437. onReady: () => void
  438. }) {
  439. clearReadyWatcher(opts.viewer.ready)
  440. const reset = opts.reset === true && opts.current !== undefined
  441. if (reset) opts.current?.cleanUp()
  442. const next = reset || !opts.current ? opts.create() : opts.current
  443. if (reset || !opts.current) {
  444. opts.viewer.container.innerHTML = ""
  445. opts.assign(next)
  446. } else {
  447. opts.update?.(next)
  448. }
  449. opts.draw(next)
  450. applyViewerScheme(opts.viewer.getHost())
  451. opts.viewer.setRendered((value) => value + 1)
  452. opts.onReady()
  453. }
  454. function preserve(viewer: Viewer) {
  455. const root = scrollParent(viewer.wrapper)
  456. if (!root) return () => {}
  457. const high = viewer.container.getBoundingClientRect().height
  458. if (!high) return () => {}
  459. const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
  460. const prev = viewer.container.style.minHeight
  461. viewer.container.style.minHeight = `${Math.ceil(high)}px`
  462. let done = false
  463. return () => {
  464. if (done) return
  465. done = true
  466. viewer.container.style.minHeight = prev
  467. const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
  468. const delta = next - top
  469. if (delta) root.scrollTop += delta
  470. }
  471. }
  472. function scrollParent(el: HTMLElement): HTMLElement | undefined {
  473. let parent = el.parentElement
  474. while (parent) {
  475. const style = getComputedStyle(parent)
  476. if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
  477. parent = parent.parentElement
  478. }
  479. }
  480. function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
  481. let virtualizer: Virtualizer | undefined
  482. let root: Document | HTMLElement | undefined
  483. const release = () => {
  484. virtualizer?.cleanUp()
  485. virtualizer = undefined
  486. root = undefined
  487. }
  488. return {
  489. get: () => {
  490. if (!enabled()) {
  491. release()
  492. return
  493. }
  494. if (typeof document === "undefined") return
  495. const wrapper = host()
  496. if (!wrapper) return
  497. const next = scrollParent(wrapper) ?? document
  498. if (virtualizer && root === next) return virtualizer
  499. release()
  500. virtualizer = new Virtualizer()
  501. root = next
  502. virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
  503. return virtualizer
  504. },
  505. cleanup: release,
  506. }
  507. }
  508. function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
  509. let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
  510. const release = () => {
  511. shared?.release()
  512. shared = undefined
  513. }
  514. return {
  515. get: () => {
  516. if (!enabled()) {
  517. release()
  518. return
  519. }
  520. if (shared) return shared.virtualizer
  521. const container = host()
  522. if (!container) return
  523. const result = acquireVirtualizer(container)
  524. if (!result) return
  525. shared = result
  526. return result.virtualizer
  527. },
  528. cleanup: release,
  529. }
  530. }
  531. function parseLine(node: HTMLElement) {
  532. if (!node.dataset.line) return
  533. const value = parseInt(node.dataset.line, 10)
  534. if (Number.isNaN(value)) return
  535. return value
  536. }
  537. function mouseHit(
  538. event: MouseEvent,
  539. line: (node: HTMLElement) => number | undefined,
  540. side?: (node: HTMLElement) => DiffSelectionSide | undefined,
  541. ): MouseHit {
  542. const path = event.composedPath()
  543. let numberColumn = false
  544. let value: number | undefined
  545. let branch: DiffSelectionSide | undefined
  546. for (const item of path) {
  547. if (!(item instanceof HTMLElement)) continue
  548. numberColumn = numberColumn || item.dataset.columnNumber != null
  549. if (value === undefined) value = line(item)
  550. if (branch === undefined && side) branch = side(item)
  551. if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
  552. }
  553. return {
  554. line: value,
  555. numberColumn,
  556. side: branch,
  557. }
  558. }
  559. function diffMouseSide(node: HTMLElement) {
  560. const type = node.dataset.lineType
  561. if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
  562. if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
  563. if (node.dataset.code == null) return
  564. return node.hasAttribute("data-deletions") ? "deletions" : "additions"
  565. }
  566. function diffSelectionSide(node: Node | null) {
  567. const el = findElement(node)
  568. if (!el) return
  569. return findDiffSide(el)
  570. }
  571. // ---------------------------------------------------------------------------
  572. // Shared JSX shell
  573. // ---------------------------------------------------------------------------
  574. function ViewerShell(props: {
  575. mode: "text" | "diff"
  576. viewer: ReturnType<typeof useFileViewer>
  577. class: string | undefined
  578. classList: ComponentProps<"div">["classList"] | undefined
  579. }) {
  580. return (
  581. <div
  582. data-component="file"
  583. data-mode={props.mode}
  584. style={styleVariables}
  585. class="relative outline-none"
  586. classList={{
  587. ...props.classList,
  588. [props.class ?? ""]: !!props.class,
  589. }}
  590. ref={(el) => (props.viewer.wrapper = el)}
  591. tabIndex={0}
  592. onPointerDown={props.viewer.find.onPointerDown}
  593. onFocus={props.viewer.find.onFocus}
  594. >
  595. <Show when={props.viewer.find.open()}>
  596. <FileSearchBar
  597. pos={props.viewer.find.pos}
  598. query={props.viewer.find.query}
  599. count={props.viewer.find.count}
  600. index={props.viewer.find.index}
  601. setInput={props.viewer.find.setInput}
  602. onInput={props.viewer.find.setQuery}
  603. onKeyDown={props.viewer.find.onInputKeyDown}
  604. onClose={props.viewer.find.close}
  605. onPrev={() => props.viewer.find.next(-1)}
  606. onNext={() => props.viewer.find.next(1)}
  607. />
  608. </Show>
  609. <div ref={(el) => (props.viewer.container = el)} />
  610. <div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
  611. </div>
  612. )
  613. }
  614. // ---------------------------------------------------------------------------
  615. // TextViewer
  616. // ---------------------------------------------------------------------------
  617. function TextViewer<T>(props: TextFileProps<T>) {
  618. let instance: PierreFile<T> | VirtualizedFile<T> | undefined
  619. let viewer!: Viewer
  620. const [local, others] = splitProps(props, textKeys)
  621. const text = () => {
  622. const value = local.file.contents as unknown
  623. if (typeof value === "string") return value
  624. if (Array.isArray(value)) return value.join("\n")
  625. if (value == null) return ""
  626. // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
  627. return String(value)
  628. }
  629. const lineCount = () => {
  630. const value = text()
  631. const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
  632. return Math.max(1, total)
  633. }
  634. const bytes = createMemo(() => {
  635. const value = local.file.contents as unknown
  636. if (typeof value === "string") return value.length
  637. if (Array.isArray(value)) {
  638. return value.reduce(
  639. // oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
  640. (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
  641. 0,
  642. )
  643. }
  644. if (value == null) return 0
  645. // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
  646. return String(value).length
  647. })
  648. const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
  649. const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
  650. const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
  651. const applySelection = (range: SelectedLineRange | null) => {
  652. const current = instance
  653. if (!current) return false
  654. if (virtual()) {
  655. current.setSelectedLines(range)
  656. return true
  657. }
  658. const root = viewer.getRoot()
  659. if (!root) return false
  660. const total = lineCount()
  661. if (root.querySelectorAll("[data-line]").length < total) return false
  662. if (!range) {
  663. current.setSelectedLines(null)
  664. return true
  665. }
  666. const start = Math.min(range.start, range.end)
  667. const end = Math.max(range.start, range.end)
  668. if (start < 1 || end > total) {
  669. current.setSelectedLines(null)
  670. return true
  671. }
  672. if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
  673. current.setSelectedLines(null)
  674. return true
  675. }
  676. const normalized = (() => {
  677. if (range.endSide != null) return { start: range.start, end: range.end }
  678. if (range.side !== "deletions") return range
  679. if (root.querySelector("[data-deletions]") != null) return range
  680. return { start: range.start, end: range.end }
  681. })()
  682. current.setSelectedLines(normalized)
  683. return true
  684. }
  685. const setSelectedLines = (range: SelectedLineRange | null) => {
  686. viewer.lastSelection = range
  687. applySelection(range)
  688. }
  689. const adapter: ModeAdapter = {
  690. lineFromMouseEvent,
  691. setSelectedLines,
  692. updateSelection: (preserveTextSelection) => {
  693. const root = viewer.getRoot()
  694. if (!root) return
  695. const selected = readShadowLineSelection({
  696. root,
  697. lineForNode: findFileLineNumber,
  698. sideForNode: findCodeSelectionSide,
  699. preserveTextSelection,
  700. })
  701. if (!selected) return
  702. setSelectedLines(selected.range)
  703. if (!preserveTextSelection || !selected.text) return
  704. restoreShadowTextSelection(root, selected.text)
  705. },
  706. buildDragSelection: () => {
  707. if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
  708. return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
  709. },
  710. buildClickSelection: () => {
  711. if (viewer.dragStart === undefined) return
  712. return { start: viewer.dragStart, end: viewer.dragStart }
  713. },
  714. onDragStart: () => {},
  715. onDragMove: () => {},
  716. onDragReset: () => {},
  717. markCommented: markCommentedFileLines,
  718. }
  719. viewer = useModeViewer(
  720. {
  721. enableLineSelection: () => props.enableLineSelection === true,
  722. selectedLines: () => local.selectedLines,
  723. commentedLines: () => local.commentedLines,
  724. onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
  725. },
  726. adapter,
  727. )
  728. const lineCallbacks = createLineCallbacks({
  729. viewer,
  730. onLineSelected: (range) => local.onLineSelected?.(range),
  731. onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
  732. onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
  733. })
  734. const options = createMemo(() => ({
  735. ...createDefaultOptions<T>("unified"),
  736. ...others,
  737. ...lineCallbacks,
  738. }))
  739. const notify = () => {
  740. notifyRendered({
  741. viewer,
  742. isReady: (root) => {
  743. if (virtual()) return root.querySelector("[data-line]") != null
  744. return root.querySelectorAll("[data-line]").length >= lineCount()
  745. },
  746. onReady: () => {
  747. applySelection(viewer.lastSelection)
  748. viewer.find.refresh({ reset: true })
  749. local.onRendered?.()
  750. },
  751. })
  752. }
  753. useSearchHandle({
  754. search: () => local.search,
  755. find: viewer.find,
  756. })
  757. // -- render instance --
  758. createEffect(() => {
  759. const opts = options()
  760. const workerPool = getWorkerPool("unified")
  761. const virtualizer = virtuals.get()
  762. renderViewer({
  763. viewer,
  764. current: instance,
  765. reset: instance !== undefined,
  766. create: () =>
  767. virtualizer
  768. ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
  769. : new PierreFile<T>(opts, workerPool),
  770. assign: (value) => {
  771. instance = value
  772. },
  773. draw: (value) => {
  774. const contents = text()
  775. value.render({
  776. file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
  777. lineAnnotations: [],
  778. containerWrapper: viewer.container,
  779. })
  780. },
  781. onReady: notify,
  782. })
  783. })
  784. useAnnotationRerender<LineAnnotation<T>>({
  785. viewer,
  786. current: () => instance,
  787. annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
  788. })
  789. // -- cleanup --
  790. onCleanup(() => {
  791. instance?.cleanUp()
  792. instance = undefined
  793. virtuals.cleanup()
  794. })
  795. return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
  796. }
  797. // ---------------------------------------------------------------------------
  798. // DiffViewer
  799. // ---------------------------------------------------------------------------
  800. function DiffViewer<T>(props: DiffFileProps<T>) {
  801. let instance: FileDiff<T> | undefined
  802. let instanceVirtualizer: Virtualizer | undefined
  803. let instanceWorkerPool: ReturnType<typeof getWorkerPool>
  804. let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
  805. let instanceFileDiff: FileDiffMetadata | undefined
  806. let instanceBefore: FileContents | undefined
  807. let instanceAfter: FileContents | undefined
  808. let dragSide: DiffSelectionSide | undefined
  809. let dragEndSide: DiffSelectionSide | undefined
  810. let viewer!: Viewer
  811. const [local, others] = splitProps(props, diffKeys)
  812. const mobile = createMediaQuery("(max-width: 640px)")
  813. const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
  814. const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
  815. const active = instance
  816. if (!active) return
  817. const fixed = fixDiffSelection(viewer.getRoot(), range)
  818. if (fixed === undefined) {
  819. viewer.lastSelection = range
  820. return
  821. }
  822. viewer.lastSelection = fixed
  823. active.setSelectedLines(fixed)
  824. restoreShadowTextSelection(preserve?.root, preserve?.text)
  825. }
  826. const adapter: ModeAdapter = {
  827. lineFromMouseEvent,
  828. setSelectedLines,
  829. updateSelection: (preserveTextSelection) => {
  830. const root = viewer.getRoot()
  831. if (!root) return
  832. const selected = readShadowLineSelection({
  833. root,
  834. lineForNode: findDiffLineNumber,
  835. sideForNode: diffSelectionSide,
  836. preserveTextSelection,
  837. })
  838. if (!selected) return
  839. if (selected.text) {
  840. setSelectedLines(selected.range, { root, text: selected.text })
  841. return
  842. }
  843. setSelectedLines(selected.range)
  844. },
  845. buildDragSelection: () => {
  846. if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
  847. const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
  848. if (dragSide) selected.side = dragSide
  849. if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
  850. return selected
  851. },
  852. buildClickSelection: () => {
  853. if (viewer.dragStart === undefined) return
  854. const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
  855. if (dragSide) selected.side = dragSide
  856. return selected
  857. },
  858. onDragStart: (hit) => {
  859. dragSide = hit.side
  860. dragEndSide = hit.side
  861. },
  862. onDragMove: (hit) => {
  863. dragEndSide = hit.side
  864. },
  865. onDragReset: () => {
  866. dragSide = undefined
  867. dragEndSide = undefined
  868. },
  869. markCommented: markCommentedDiffLines,
  870. }
  871. viewer = useModeViewer(
  872. {
  873. enableLineSelection: () => props.enableLineSelection === true,
  874. selectedLines: () => local.selectedLines,
  875. commentedLines: () => local.commentedLines,
  876. onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
  877. },
  878. adapter,
  879. )
  880. const virtuals = createSharedVirtualStrategy(
  881. () => viewer.container,
  882. () => local.virtualize !== false,
  883. )
  884. const large = createMemo(() => {
  885. if (local.fileDiff) {
  886. const before = local.fileDiff.deletionLines.join("")
  887. const after = local.fileDiff.additionLines.join("")
  888. return Math.max(before.length, after.length) > 500_000
  889. }
  890. const before = typeof local.before?.contents === "string" ? local.before.contents : ""
  891. const after = typeof local.after?.contents === "string" ? local.after.contents : ""
  892. return Math.max(before.length, after.length) > 500_000
  893. })
  894. const largeOptions = {
  895. lineDiffType: "none",
  896. maxLineDiffLength: 0,
  897. tokenizeMaxLineLength: 1,
  898. } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
  899. const lineCallbacks = createLineCallbacks({
  900. viewer,
  901. normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
  902. onLineSelected: (range) => local.onLineSelected?.(range),
  903. onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
  904. onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
  905. })
  906. const options = createMemo<FileDiffOptions<T>>(() => {
  907. const base = {
  908. ...createDefaultOptions(props.diffStyle),
  909. ...others,
  910. ...lineCallbacks,
  911. }
  912. const perf = large() ? { ...base, ...largeOptions } : base
  913. if (!mobile()) return perf
  914. return { ...perf, disableLineNumbers: true }
  915. })
  916. const notify = (done?: VoidFunction) => {
  917. notifyRendered({
  918. viewer,
  919. isReady: (root) => root.querySelector("[data-line]") != null,
  920. settleFrames: 1,
  921. onReady: () => {
  922. done?.()
  923. setSelectedLines(viewer.lastSelection)
  924. viewer.find.refresh({ reset: true })
  925. local.onRendered?.()
  926. },
  927. })
  928. }
  929. useSearchHandle({
  930. search: () => local.search,
  931. find: viewer.find,
  932. })
  933. // -- render instance --
  934. createEffect(() => {
  935. const opts = options()
  936. const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
  937. const virtualizer = virtuals.get()
  938. const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
  939. const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
  940. const done = preserve(viewer)
  941. onCleanup(done)
  942. const cacheKey = (contents: string) => {
  943. if (!large()) return sampledChecksum(contents, contents.length)
  944. return sampledChecksum(contents)
  945. }
  946. const before = local.before
  947. ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }
  948. : undefined
  949. const after = local.after
  950. ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }
  951. : undefined
  952. const targetChanged =
  953. local.fileDiff !== undefined
  954. ? instanceFileDiff !== local.fileDiff
  955. : instanceFileDiff !== undefined ||
  956. before === undefined ||
  957. after === undefined ||
  958. instanceBefore === undefined ||
  959. instanceAfter === undefined ||
  960. !areFilesEqual(instanceBefore, before) ||
  961. !areFilesEqual(instanceAfter, after)
  962. // Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
  963. // Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
  964. const reset =
  965. instance !== undefined &&
  966. (instanceVirtualizer !== virtualizer ||
  967. instanceWorkerPool !== workerPool ||
  968. (virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
  969. const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
  970. renderViewer({
  971. viewer,
  972. current: instance,
  973. reset,
  974. create: () =>
  975. virtualizer
  976. ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
  977. : new FileDiff<T>(opts, workerPool),
  978. update: (value) => value.setOptions(opts),
  979. assign: (value) => {
  980. instance = value
  981. instanceVirtualizer = virtualizer
  982. instanceWorkerPool = workerPool
  983. instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
  984. instanceFileDiff = local.fileDiff
  985. instanceBefore = before
  986. instanceAfter = after
  987. },
  988. draw: (value) => {
  989. if (local.fileDiff) {
  990. value.render({
  991. fileDiff: local.fileDiff,
  992. forceRender,
  993. lineAnnotations: [],
  994. containerWrapper: viewer.container,
  995. })
  996. return
  997. }
  998. if (!before || !after) return
  999. value.render({
  1000. oldFile: before,
  1001. newFile: after,
  1002. forceRender,
  1003. lineAnnotations: [],
  1004. containerWrapper: viewer.container,
  1005. })
  1006. },
  1007. onReady: () => notify(done),
  1008. })
  1009. })
  1010. useAnnotationRerender<DiffLineAnnotation<T>>({
  1011. viewer,
  1012. current: () => instance,
  1013. annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
  1014. })
  1015. // -- cleanup --
  1016. onCleanup(() => {
  1017. instance?.cleanUp()
  1018. instance = undefined
  1019. instanceVirtualizer = undefined
  1020. instanceWorkerPool = undefined
  1021. instanceVirtualHunkSeparators = undefined
  1022. instanceFileDiff = undefined
  1023. instanceBefore = undefined
  1024. instanceAfter = undefined
  1025. virtuals.cleanup()
  1026. dragSide = undefined
  1027. dragEndSide = undefined
  1028. })
  1029. return <ViewerShell mode="diff" viewer={viewer} class={local.class} classList={local.classList} />
  1030. }
  1031. // ---------------------------------------------------------------------------
  1032. // Public API
  1033. // ---------------------------------------------------------------------------
  1034. export function File<T>(props: FileProps<T>) {
  1035. if (props.mode === "text") {
  1036. return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
  1037. }
  1038. return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
  1039. }