diff options
| author | Adam <[email protected]> | 2026-02-26 18:23:04 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 18:23:04 -0600 |
| commit | fc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch) | |
| tree | cf23af294a00a10e55f230232585344c111f0bb9 /packages/ui/src/components/file.tsx | |
| parent | 9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff) | |
| download | opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip | |
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]>
Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/ui/src/components/file.tsx')
| -rw-r--r-- | packages/ui/src/components/file.tsx | 1176 |
1 files changed, 1176 insertions, 0 deletions
diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx new file mode 100644 index 000000000..f42fbb24d --- /dev/null +++ b/packages/ui/src/components/file.tsx @@ -0,0 +1,1176 @@ +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)} /> +} |
