file.tsx 32 KB

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