Files
tf_code/packages/ui/src/components/file.tsx
2026-02-26 18:23:04 -06:00

1177 lines
33 KiB
TypeScript

import { sampledChecksum } from "@opencode-ai/util/encode"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type ExpansionDirections,
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 PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media"
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, type FileFindReveal } 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 FileSearchReveal = FileFindReveal
export type FileSearchHandle = {
focus: () => void
setQuery: (value: string) => void
clear: () => void
reveal: (hit: FileSearchReveal) => boolean
expand: (hit: FileSearchReveal) => boolean
refresh: () => void
}
export type FileSearchControl = {
shortcuts?: "global" | "disabled"
showBar?: boolean
disableVirtualization?: boolean
register: (handle: FileSearchHandle | null) => void
}
export type TextFileProps<T = {}> = FileOptions<T> &
SharedProps<T> & {
mode: "text"
file: FileContents
annotations?: LineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
}
export type DiffFileProps<T = {}> = FileDiffOptions<T> &
SharedProps<T> & {
mode: "diff"
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<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 = ["before", "after", ...sharedKeys] as const
function expansionForHit(diff: FileDiffMetadata, hit: FileSearchReveal) {
if (diff.isPartial || diff.hunks.length === 0) return
const side =
hit.side === "deletions"
? {
start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionStart,
count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionCount,
}
: {
start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionStart,
count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionCount,
}
for (let i = 0; i < diff.hunks.length; i++) {
const hunk = diff.hunks[i]
const start = side.start(hunk)
if (hit.line < start) {
return {
index: i,
direction: i === 0 ? "down" : "both",
} satisfies { index: number; direction: ExpansionDirections }
}
const end = start + Math.max(side.count(hunk) - 1, -1)
if (hit.line <= end) return
}
return {
index: diff.hunks.length,
direction: "up",
} satisfies { index: number; direction: ExpansionDirections }
}
// ---------------------------------------------------------------------------
// Shared viewer hook
// ---------------------------------------------------------------------------
type MouseHit = {
line: number | undefined
numberColumn: boolean
side?: DiffSelectionSide
}
type ViewerConfig = {
enableLineSelection: () => boolean
search: () => FileSearchControl | undefined
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,
shortcuts: config.search()?.shortcuts,
})
// -- 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
container.addEventListener("mousedown", handleMouseDown)
container.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
document.addEventListener("selectionchange", handleSelectionChange)
onCleanup(() => {
container.removeEventListener("mousedown", handleMouseDown)
container.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("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" | "search" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
>
type ModeConfig = {
enableLineSelection: () => boolean
search: () => FileSearchControl | undefined
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,
search: config.search,
selectedLines: config.selectedLines,
commentedLines: () => config.commentedLines() ?? [],
onLineSelectionEnd: config.onLineSelectionEnd,
...adapter,
})
}
function useSearchHandle(opts: {
search: () => FileSearchControl | undefined
find: ReturnType<typeof createFileFind>
expand?: (hit: FileSearchReveal) => boolean
}) {
createEffect(() => {
const search = opts.search()
if (!search) return
const handle = {
focus: () => {
opts.find.focus()
},
setQuery: (value: string) => {
opts.find.activate()
opts.find.setQuery(value, { scroll: false })
},
clear: () => {
opts.find.clear()
},
reveal: (hit: FileSearchReveal) => {
opts.find.activate()
return opts.find.reveal(hit)
},
expand: (hit: FileSearchReveal) => opts.expand?.(hit) ?? false,
refresh: () => {
opts.find.activate()
opts.find.refresh()
},
} 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 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, enabled: () => boolean): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
shared?.release()
shared = undefined
}
return {
get: () => {
if (!enabled()) {
release()
return
}
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>
search: FileSearchControl | undefined
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.search?.showBar ?? true) && 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 ""
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(
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
0,
)
}
if (value == null) return 0
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,
search: () => local.search,
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} search={local.search} 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,
search: () => local.search,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
},
adapter,
)
const virtuals = createSharedVirtualStrategy(
() => viewer.container,
() => local.search?.disableVirtualization !== true,
)
const large = createMemo(() => {
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 = () => {
notifyRendered({
viewer,
isReady: (root) => root.querySelector("[data-line]") != null,
settleFrames: 1,
onReady: () => {
setSelectedLines(viewer.lastSelection)
viewer.find.refresh({ reset: true })
local.onRendered?.()
},
})
}
useSearchHandle({
search: () => local.search,
find: viewer.find,
expand: (hit) => {
const active = instance as
| ((FileDiff<T> | VirtualizedFileDiff<T>) & {
fileDiff?: FileDiffMetadata
})
| undefined
if (!active?.fileDiff) return false
const next = expansionForHit(active.fileDiff, hit)
if (!next) return false
active.expandHunk(next.index, next.direction)
return true
},
})
// -- 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 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) => {
value.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
lineAnnotations: [],
containerWrapper: viewer.container,
})
},
onReady: notify,
})
})
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} search={local.search} 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)} />
}