| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132 |
- import { sampledChecksum } from "@opencode-ai/shared/util/encode"
- import {
- DEFAULT_VIRTUAL_FILE_METRICS,
- type DiffLineAnnotation,
- type FileContents,
- type FileDiffMetadata,
- File as PierreFile,
- type FileDiffOptions,
- FileDiff,
- type FileOptions,
- type LineAnnotation,
- type SelectedLineRange,
- type VirtualFileMetrics,
- VirtualizedFile,
- VirtualizedFileDiff,
- Virtualizer,
- } from "@pierre/diffs"
- import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
- import { createMediaQuery } from "@solid-primitives/media"
- import { makeEventListener } from "@solid-primitives/event-listener"
- import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
- import { createDefaultOptions, styleVariables } from "../pierre"
- import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
- import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
- import { createFileFind } from "../pierre/file-find"
- import {
- applyViewerScheme,
- clearReadyWatcher,
- createReadyWatcher,
- getViewerHost,
- getViewerRoot,
- notifyShadowReady,
- observeViewerScheme,
- } from "../pierre/file-runtime"
- import {
- findCodeSelectionSide,
- findDiffLineNumber,
- findElement,
- findFileLineNumber,
- readShadowLineSelection,
- } from "../pierre/file-selection"
- import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
- import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
- import { getWorkerPool } from "../pierre/worker"
- import { FileMedia, type FileMediaOptions } from "./file-media"
- import { FileSearchBar } from "./file-search"
- const VIRTUALIZE_BYTES = 500_000
- const codeMetrics = {
- ...DEFAULT_VIRTUAL_FILE_METRICS,
- lineHeight: 24,
- fileGap: 0,
- } satisfies Partial<VirtualFileMetrics>
- type SharedProps<T> = {
- annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
- selectedLines?: SelectedLineRange | null
- commentedLines?: SelectedLineRange[]
- onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
- onRendered?: () => void
- class?: string
- classList?: ComponentProps<"div">["classList"]
- media?: FileMediaOptions
- search?: FileSearchControl
- }
- export type FileSearchHandle = {
- focus: () => void
- }
- export type FileSearchControl = {
- register: (handle: FileSearchHandle | null) => void
- }
- export type TextFileProps<T = {}> = FileOptions<T> &
- SharedProps<T> & {
- mode: "text"
- file: FileContents
- annotations?: LineAnnotation<T>[]
- preloadedDiff?: PreloadMultiFileDiffResult<T>
- }
- type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
- type DiffBaseProps<T> = FileDiffOptions<T> &
- SharedProps<T> & {
- mode: "diff"
- annotations?: DiffLineAnnotation<T>[]
- preloadedDiff?: DiffPreload<T>
- }
- type DiffPairProps<T> = DiffBaseProps<T> & {
- before: FileContents
- after: FileContents
- fileDiff?: undefined
- }
- type DiffPatchProps<T> = DiffBaseProps<T> & {
- fileDiff: FileDiffMetadata
- before?: undefined
- after?: undefined
- }
- export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
- export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
- const sharedKeys = [
- "mode",
- "media",
- "class",
- "classList",
- "annotations",
- "selectedLines",
- "commentedLines",
- "search",
- "onLineSelected",
- "onLineSelectionEnd",
- "onLineNumberSelectionEnd",
- "onRendered",
- "preloadedDiff",
- ] as const
- const textKeys = ["file", ...sharedKeys] as const
- const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
- // ---------------------------------------------------------------------------
- // Shared viewer hook
- // ---------------------------------------------------------------------------
- type MouseHit = {
- line: number | undefined
- numberColumn: boolean
- side?: DiffSelectionSide
- }
- type ViewerConfig = {
- enableLineSelection: () => boolean
- selectedLines: () => SelectedLineRange | null | undefined
- commentedLines: () => SelectedLineRange[]
- onLineSelectionEnd: (range: SelectedLineRange | null) => void
- // mode-specific callbacks
- lineFromMouseEvent: (event: MouseEvent) => MouseHit
- setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
- updateSelection: (preserveTextSelection: boolean) => void
- buildDragSelection: () => SelectedLineRange | undefined
- buildClickSelection: () => SelectedLineRange | undefined
- onDragStart: (hit: MouseHit) => void
- onDragMove: (hit: MouseHit) => void
- onDragReset: () => void
- markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
- }
- function useFileViewer(config: ViewerConfig) {
- let wrapper!: HTMLDivElement
- let container!: HTMLDivElement
- let overlay!: HTMLDivElement
- let selectionFrame: number | undefined
- let dragFrame: number | undefined
- let dragStart: number | undefined
- let dragEnd: number | undefined
- let dragMoved = false
- let lastSelection: SelectedLineRange | null = null
- let pendingSelectionEnd = false
- const ready = createReadyWatcher()
- const bridge = createLineNumberSelectionBridge()
- const [rendered, setRendered] = createSignal(0)
- const getRoot = () => getViewerRoot(container)
- const getHost = () => getViewerHost(container)
- const find = createFileFind({
- wrapper: () => wrapper,
- overlay: () => overlay,
- getRoot,
- })
- // -- selection scheduling --
- const scheduleSelectionUpdate = () => {
- if (selectionFrame !== undefined) return
- selectionFrame = requestAnimationFrame(() => {
- selectionFrame = undefined
- const finishing = pendingSelectionEnd
- config.updateSelection(finishing)
- if (!pendingSelectionEnd) return
- pendingSelectionEnd = false
- config.onLineSelectionEnd(lastSelection)
- })
- }
- const scheduleDragUpdate = () => {
- if (dragFrame !== undefined) return
- dragFrame = requestAnimationFrame(() => {
- dragFrame = undefined
- const selected = config.buildDragSelection()
- if (selected) config.setSelectedLines(selected)
- })
- }
- // -- mouse handlers --
- const handleMouseDown = (event: MouseEvent) => {
- if (!config.enableLineSelection()) return
- if (event.button !== 0) return
- const hit = config.lineFromMouseEvent(event)
- if (hit.numberColumn) {
- bridge.begin(true, hit.line)
- return
- }
- if (hit.line === undefined) return
- bridge.begin(false, hit.line)
- dragStart = hit.line
- dragEnd = hit.line
- dragMoved = false
- config.onDragStart(hit)
- }
- const handleMouseMove = (event: MouseEvent) => {
- if (!config.enableLineSelection()) return
- const hit = config.lineFromMouseEvent(event)
- if (bridge.track(event.buttons, hit.line)) return
- if (dragStart === undefined) return
- if ((event.buttons & 1) === 0) {
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- config.onDragReset()
- bridge.finish()
- return
- }
- if (hit.line === undefined) return
- dragEnd = hit.line
- dragMoved = true
- config.onDragMove(hit)
- scheduleDragUpdate()
- }
- const handleMouseUp = () => {
- if (!config.enableLineSelection()) return
- if (bridge.finish() === "numbers") return
- if (dragStart === undefined) return
- if (!dragMoved) {
- pendingSelectionEnd = false
- const selected = config.buildClickSelection()
- if (selected) config.setSelectedLines(selected)
- config.onLineSelectionEnd(lastSelection)
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- config.onDragReset()
- return
- }
- pendingSelectionEnd = true
- scheduleDragUpdate()
- scheduleSelectionUpdate()
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- config.onDragReset()
- }
- const handleSelectionChange = () => {
- if (!config.enableLineSelection()) return
- if (dragStart === undefined) return
- const selection = window.getSelection()
- if (!selection || selection.isCollapsed) return
- scheduleSelectionUpdate()
- }
- // -- shared effects --
- onMount(() => {
- onCleanup(observeViewerScheme(getHost))
- })
- createEffect(() => {
- rendered()
- const ranges = config.commentedLines()
- requestAnimationFrame(() => {
- const root = getRoot()
- if (!root) return
- config.markCommented(root, ranges)
- })
- })
- createEffect(() => {
- config.setSelectedLines(config.selectedLines() ?? null)
- })
- createEffect(() => {
- if (!config.enableLineSelection()) return
- makeEventListener(container, "mousedown", handleMouseDown)
- makeEventListener(container, "mousemove", handleMouseMove)
- makeEventListener(window, "mouseup", handleMouseUp)
- makeEventListener(document, "selectionchange", handleSelectionChange)
- })
- onCleanup(() => {
- clearReadyWatcher(ready)
- if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
- if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
- selectionFrame = undefined
- dragFrame = undefined
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- bridge.reset()
- lastSelection = null
- pendingSelectionEnd = false
- })
- return {
- get wrapper() {
- return wrapper
- },
- set wrapper(v: HTMLDivElement) {
- wrapper = v
- },
- get container() {
- return container
- },
- set container(v: HTMLDivElement) {
- container = v
- },
- get overlay() {
- return overlay
- },
- set overlay(v: HTMLDivElement) {
- overlay = v
- },
- get dragStart() {
- return dragStart
- },
- get dragEnd() {
- return dragEnd
- },
- get lastSelection() {
- return lastSelection
- },
- set lastSelection(v: SelectedLineRange | null) {
- lastSelection = v
- },
- ready,
- bridge,
- rendered,
- setRendered,
- getRoot,
- getHost,
- find,
- scheduleSelectionUpdate,
- }
- }
- type Viewer = ReturnType<typeof useFileViewer>
- type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
- type ModeConfig = {
- enableLineSelection: () => boolean
- selectedLines: () => SelectedLineRange | null | undefined
- commentedLines: () => SelectedLineRange[] | undefined
- onLineSelectionEnd: (range: SelectedLineRange | null) => void
- }
- type RenderTarget = {
- cleanUp: () => void
- }
- type AnnotationTarget<A> = {
- setLineAnnotations: (annotations: A[]) => void
- rerender: () => void
- }
- type VirtualStrategy = {
- get: () => Virtualizer | undefined
- cleanup: () => void
- }
- function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
- return useFileViewer({
- enableLineSelection: config.enableLineSelection,
- selectedLines: config.selectedLines,
- commentedLines: () => config.commentedLines() ?? [],
- onLineSelectionEnd: config.onLineSelectionEnd,
- ...adapter,
- })
- }
- function useSearchHandle(opts: {
- search: () => FileSearchControl | undefined
- find: ReturnType<typeof createFileFind>
- }) {
- createEffect(() => {
- const search = opts.search()
- if (!search) return
- const handle = {
- focus: () => opts.find.focus(),
- } satisfies FileSearchHandle
- search.register(handle)
- onCleanup(() => search.register(null))
- })
- }
- function createLineCallbacks(opts: {
- viewer: Viewer
- normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
- onLineSelected?: (range: SelectedLineRange | null) => void
- onLineSelectionEnd?: (range: SelectedLineRange | null) => void
- onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
- }) {
- const select = (range: SelectedLineRange | null) => {
- if (!opts.normalize) return range
- const next = opts.normalize(range)
- if (next !== undefined) return next
- return range
- }
- return {
- onLineSelected: (range: SelectedLineRange | null) => {
- const next = select(range)
- opts.viewer.lastSelection = next
- opts.onLineSelected?.(next)
- },
- onLineSelectionEnd: (range: SelectedLineRange | null) => {
- const next = select(range)
- opts.viewer.lastSelection = next
- opts.onLineSelectionEnd?.(next)
- if (!opts.viewer.bridge.consume(next)) return
- requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
- },
- }
- }
- function useAnnotationRerender<A>(opts: {
- viewer: Viewer
- current: () => AnnotationTarget<A> | undefined
- annotations: () => A[]
- }) {
- createEffect(() => {
- opts.viewer.rendered()
- const active = opts.current()
- if (!active) return
- active.setLineAnnotations(opts.annotations())
- active.rerender()
- requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
- })
- }
- function notifyRendered(opts: {
- viewer: Viewer
- isReady: (root: ShadowRoot) => boolean
- settleFrames?: number
- onReady: () => void
- }) {
- notifyShadowReady({
- state: opts.viewer.ready,
- container: opts.viewer.container,
- getRoot: opts.viewer.getRoot,
- isReady: opts.isReady,
- settleFrames: opts.settleFrames,
- onReady: opts.onReady,
- })
- }
- function renderViewer<I extends RenderTarget>(opts: {
- viewer: Viewer
- current: I | undefined
- create: () => I
- assign: (value: I) => void
- draw: (value: I) => void
- onReady: () => void
- }) {
- clearReadyWatcher(opts.viewer.ready)
- opts.current?.cleanUp()
- const next = opts.create()
- opts.assign(next)
- opts.viewer.container.innerHTML = ""
- opts.draw(next)
- applyViewerScheme(opts.viewer.getHost())
- opts.viewer.setRendered((value) => value + 1)
- opts.onReady()
- }
- function preserve(viewer: Viewer) {
- const root = scrollParent(viewer.wrapper)
- if (!root) return () => {}
- const high = viewer.container.getBoundingClientRect().height
- if (!high) return () => {}
- const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
- const prev = viewer.container.style.minHeight
- viewer.container.style.minHeight = `${Math.ceil(high)}px`
- let done = false
- return () => {
- if (done) return
- done = true
- viewer.container.style.minHeight = prev
- const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
- const delta = next - top
- if (delta) root.scrollTop += delta
- }
- }
- function scrollParent(el: HTMLElement): HTMLElement | undefined {
- let parent = el.parentElement
- while (parent) {
- const style = getComputedStyle(parent)
- if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
- parent = parent.parentElement
- }
- }
- function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
- let virtualizer: Virtualizer | undefined
- let root: Document | HTMLElement | undefined
- const release = () => {
- virtualizer?.cleanUp()
- virtualizer = undefined
- root = undefined
- }
- return {
- get: () => {
- if (!enabled()) {
- release()
- return
- }
- if (typeof document === "undefined") return
- const wrapper = host()
- if (!wrapper) return
- const next = scrollParent(wrapper) ?? document
- if (virtualizer && root === next) return virtualizer
- release()
- virtualizer = new Virtualizer()
- root = next
- virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
- return virtualizer
- },
- cleanup: release,
- }
- }
- function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
- let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
- const release = () => {
- shared?.release()
- shared = undefined
- }
- return {
- get: () => {
- if (shared) return shared.virtualizer
- const container = host()
- if (!container) return
- const result = acquireVirtualizer(container)
- if (!result) return
- shared = result
- return result.virtualizer
- },
- cleanup: release,
- }
- }
- function parseLine(node: HTMLElement) {
- if (!node.dataset.line) return
- const value = parseInt(node.dataset.line, 10)
- if (Number.isNaN(value)) return
- return value
- }
- function mouseHit(
- event: MouseEvent,
- line: (node: HTMLElement) => number | undefined,
- side?: (node: HTMLElement) => DiffSelectionSide | undefined,
- ): MouseHit {
- const path = event.composedPath()
- let numberColumn = false
- let value: number | undefined
- let branch: DiffSelectionSide | undefined
- for (const item of path) {
- if (!(item instanceof HTMLElement)) continue
- numberColumn = numberColumn || item.dataset.columnNumber != null
- if (value === undefined) value = line(item)
- if (branch === undefined && side) branch = side(item)
- if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
- }
- return {
- line: value,
- numberColumn,
- side: branch,
- }
- }
- function diffMouseSide(node: HTMLElement) {
- const type = node.dataset.lineType
- if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
- if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
- if (node.dataset.code == null) return
- return node.hasAttribute("data-deletions") ? "deletions" : "additions"
- }
- function diffSelectionSide(node: Node | null) {
- const el = findElement(node)
- if (!el) return
- return findDiffSide(el)
- }
- // ---------------------------------------------------------------------------
- // Shared JSX shell
- // ---------------------------------------------------------------------------
- function ViewerShell(props: {
- mode: "text" | "diff"
- viewer: ReturnType<typeof useFileViewer>
- class: string | undefined
- classList: ComponentProps<"div">["classList"] | undefined
- }) {
- return (
- <div
- data-component="file"
- data-mode={props.mode}
- style={styleVariables}
- class="relative outline-none"
- classList={{
- ...props.classList,
- [props.class ?? ""]: !!props.class,
- }}
- ref={(el) => (props.viewer.wrapper = el)}
- tabIndex={0}
- onPointerDown={props.viewer.find.onPointerDown}
- onFocus={props.viewer.find.onFocus}
- >
- <Show when={props.viewer.find.open()}>
- <FileSearchBar
- pos={props.viewer.find.pos}
- query={props.viewer.find.query}
- count={props.viewer.find.count}
- index={props.viewer.find.index}
- setInput={props.viewer.find.setInput}
- onInput={props.viewer.find.setQuery}
- onKeyDown={props.viewer.find.onInputKeyDown}
- onClose={props.viewer.find.close}
- onPrev={() => props.viewer.find.next(-1)}
- onNext={() => props.viewer.find.next(1)}
- />
- </Show>
- <div ref={(el) => (props.viewer.container = el)} />
- <div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
- </div>
- )
- }
- // ---------------------------------------------------------------------------
- // TextViewer
- // ---------------------------------------------------------------------------
- function TextViewer<T>(props: TextFileProps<T>) {
- let instance: PierreFile<T> | VirtualizedFile<T> | undefined
- let viewer!: Viewer
- const [local, others] = splitProps(props, textKeys)
- const text = () => {
- const value = local.file.contents as unknown
- if (typeof value === "string") return value
- if (Array.isArray(value)) return value.join("\n")
- if (value == null) return ""
- // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
- return String(value)
- }
- const lineCount = () => {
- const value = text()
- const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
- return Math.max(1, total)
- }
- const bytes = createMemo(() => {
- const value = local.file.contents as unknown
- if (typeof value === "string") return value.length
- if (Array.isArray(value)) {
- return value.reduce(
- // oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
- (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
- 0,
- )
- }
- if (value == null) return 0
- // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
- return String(value).length
- })
- const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
- const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
- const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
- const applySelection = (range: SelectedLineRange | null) => {
- const current = instance
- if (!current) return false
- if (virtual()) {
- current.setSelectedLines(range)
- return true
- }
- const root = viewer.getRoot()
- if (!root) return false
- const total = lineCount()
- if (root.querySelectorAll("[data-line]").length < total) return false
- if (!range) {
- current.setSelectedLines(null)
- return true
- }
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
- if (start < 1 || end > total) {
- current.setSelectedLines(null)
- return true
- }
- if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
- current.setSelectedLines(null)
- return true
- }
- const normalized = (() => {
- if (range.endSide != null) return { start: range.start, end: range.end }
- if (range.side !== "deletions") return range
- if (root.querySelector("[data-deletions]") != null) return range
- return { start: range.start, end: range.end }
- })()
- current.setSelectedLines(normalized)
- return true
- }
- const setSelectedLines = (range: SelectedLineRange | null) => {
- viewer.lastSelection = range
- applySelection(range)
- }
- const adapter: ModeAdapter = {
- lineFromMouseEvent,
- setSelectedLines,
- updateSelection: (preserveTextSelection) => {
- const root = viewer.getRoot()
- if (!root) return
- const selected = readShadowLineSelection({
- root,
- lineForNode: findFileLineNumber,
- sideForNode: findCodeSelectionSide,
- preserveTextSelection,
- })
- if (!selected) return
- setSelectedLines(selected.range)
- if (!preserveTextSelection || !selected.text) return
- restoreShadowTextSelection(root, selected.text)
- },
- buildDragSelection: () => {
- if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
- return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
- },
- buildClickSelection: () => {
- if (viewer.dragStart === undefined) return
- return { start: viewer.dragStart, end: viewer.dragStart }
- },
- onDragStart: () => {},
- onDragMove: () => {},
- onDragReset: () => {},
- markCommented: markCommentedFileLines,
- }
- viewer = useModeViewer(
- {
- enableLineSelection: () => props.enableLineSelection === true,
- selectedLines: () => local.selectedLines,
- commentedLines: () => local.commentedLines,
- onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
- },
- adapter,
- )
- const lineCallbacks = createLineCallbacks({
- viewer,
- onLineSelected: (range) => local.onLineSelected?.(range),
- onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
- onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
- })
- const options = createMemo(() => ({
- ...createDefaultOptions<T>("unified"),
- ...others,
- ...lineCallbacks,
- }))
- const notify = () => {
- notifyRendered({
- viewer,
- isReady: (root) => {
- if (virtual()) return root.querySelector("[data-line]") != null
- return root.querySelectorAll("[data-line]").length >= lineCount()
- },
- onReady: () => {
- applySelection(viewer.lastSelection)
- viewer.find.refresh({ reset: true })
- local.onRendered?.()
- },
- })
- }
- useSearchHandle({
- search: () => local.search,
- find: viewer.find,
- })
- // -- render instance --
- createEffect(() => {
- const opts = options()
- const workerPool = getWorkerPool("unified")
- const isVirtual = virtual()
- const virtualizer = virtuals.get()
- renderViewer({
- viewer,
- current: instance,
- create: () =>
- isVirtual && virtualizer
- ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
- : new PierreFile<T>(opts, workerPool),
- assign: (value) => {
- instance = value
- },
- draw: (value) => {
- const contents = text()
- value.render({
- file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
- lineAnnotations: [],
- containerWrapper: viewer.container,
- })
- },
- onReady: notify,
- })
- })
- useAnnotationRerender<LineAnnotation<T>>({
- viewer,
- current: () => instance,
- annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
- })
- // -- cleanup --
- onCleanup(() => {
- instance?.cleanUp()
- instance = undefined
- virtuals.cleanup()
- })
- return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
- }
- // ---------------------------------------------------------------------------
- // DiffViewer
- // ---------------------------------------------------------------------------
- function DiffViewer<T>(props: DiffFileProps<T>) {
- let instance: FileDiff<T> | undefined
- let dragSide: DiffSelectionSide | undefined
- let dragEndSide: DiffSelectionSide | undefined
- let viewer!: Viewer
- const [local, others] = splitProps(props, diffKeys)
- const mobile = createMediaQuery("(max-width: 640px)")
- const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
- const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
- const active = instance
- if (!active) return
- const fixed = fixDiffSelection(viewer.getRoot(), range)
- if (fixed === undefined) {
- viewer.lastSelection = range
- return
- }
- viewer.lastSelection = fixed
- active.setSelectedLines(fixed)
- restoreShadowTextSelection(preserve?.root, preserve?.text)
- }
- const adapter: ModeAdapter = {
- lineFromMouseEvent,
- setSelectedLines,
- updateSelection: (preserveTextSelection) => {
- const root = viewer.getRoot()
- if (!root) return
- const selected = readShadowLineSelection({
- root,
- lineForNode: findDiffLineNumber,
- sideForNode: diffSelectionSide,
- preserveTextSelection,
- })
- if (!selected) return
- if (selected.text) {
- setSelectedLines(selected.range, { root, text: selected.text })
- return
- }
- setSelectedLines(selected.range)
- },
- buildDragSelection: () => {
- if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
- const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
- if (dragSide) selected.side = dragSide
- if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
- return selected
- },
- buildClickSelection: () => {
- if (viewer.dragStart === undefined) return
- const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
- if (dragSide) selected.side = dragSide
- return selected
- },
- onDragStart: (hit) => {
- dragSide = hit.side
- dragEndSide = hit.side
- },
- onDragMove: (hit) => {
- dragEndSide = hit.side
- },
- onDragReset: () => {
- dragSide = undefined
- dragEndSide = undefined
- },
- markCommented: markCommentedDiffLines,
- }
- viewer = useModeViewer(
- {
- enableLineSelection: () => props.enableLineSelection === true,
- selectedLines: () => local.selectedLines,
- commentedLines: () => local.commentedLines,
- onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
- },
- adapter,
- )
- const virtuals = createSharedVirtualStrategy(() => viewer.container)
- const large = createMemo(() => {
- if (local.fileDiff) {
- const before = local.fileDiff.deletionLines.join("")
- const after = local.fileDiff.additionLines.join("")
- return Math.max(before.length, after.length) > 500_000
- }
- const before = typeof local.before?.contents === "string" ? local.before.contents : ""
- const after = typeof local.after?.contents === "string" ? local.after.contents : ""
- return Math.max(before.length, after.length) > 500_000
- })
- const largeOptions = {
- lineDiffType: "none",
- maxLineDiffLength: 0,
- tokenizeMaxLineLength: 1,
- } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
- const lineCallbacks = createLineCallbacks({
- viewer,
- normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
- onLineSelected: (range) => local.onLineSelected?.(range),
- onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
- onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
- })
- const options = createMemo<FileDiffOptions<T>>(() => {
- const base = {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...lineCallbacks,
- }
- const perf = large() ? { ...base, ...largeOptions } : base
- if (!mobile()) return perf
- return { ...perf, disableLineNumbers: true }
- })
- const notify = (done?: VoidFunction) => {
- notifyRendered({
- viewer,
- isReady: (root) => root.querySelector("[data-line]") != null,
- settleFrames: 1,
- onReady: () => {
- done?.()
- setSelectedLines(viewer.lastSelection)
- viewer.find.refresh({ reset: true })
- local.onRendered?.()
- },
- })
- }
- useSearchHandle({
- search: () => local.search,
- find: viewer.find,
- })
- // -- render instance --
- createEffect(() => {
- const opts = options()
- const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
- const virtualizer = virtuals.get()
- const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
- const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
- const done = preserve(viewer)
- onCleanup(done)
- const cacheKey = (contents: string) => {
- if (!large()) return sampledChecksum(contents, contents.length)
- return sampledChecksum(contents)
- }
- renderViewer({
- viewer,
- current: instance,
- create: () =>
- virtualizer
- ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
- : new FileDiff<T>(opts, workerPool),
- assign: (value) => {
- instance = value
- },
- draw: (value) => {
- if (local.fileDiff) {
- value.render({
- fileDiff: local.fileDiff,
- lineAnnotations: [],
- containerWrapper: viewer.container,
- })
- return
- }
- if (!local.before || !local.after) return
- value.render({
- oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
- newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
- lineAnnotations: [],
- containerWrapper: viewer.container,
- })
- },
- onReady: () => notify(done),
- })
- })
- useAnnotationRerender<DiffLineAnnotation<T>>({
- viewer,
- current: () => instance,
- annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
- })
- // -- cleanup --
- onCleanup(() => {
- instance?.cleanUp()
- instance = undefined
- virtuals.cleanup()
- dragSide = undefined
- dragEndSide = undefined
- })
- return <ViewerShell mode="diff" viewer={viewer} class={local.class} classList={local.classList} />
- }
- // ---------------------------------------------------------------------------
- // Public API
- // ---------------------------------------------------------------------------
- export function File<T>(props: FileProps<T>) {
- if (props.mode === "text") {
- return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
- }
- return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
- }
|