mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
import * as React from 'react'
|
||
import { useShikiHighlighter } from 'react-shiki'
|
||
import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki'
|
||
|
||
import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
/**
|
||
* Renders a unified diff for a tool's file edit. Two paths share one parse:
|
||
* - `SyntaxDiff` highlights the change *content* in the file's language via
|
||
* Shiki, then a per-line transformer paints the add/remove tint on top.
|
||
* - `DiffLines` is the color-only fallback (no language, over budget, or while
|
||
* Shiki loads).
|
||
* Both drop git file-headers + `@@` hunk noise and the `+/-` gutter so changes
|
||
* read by color + a 2px gutter accent, the way Cursor does.
|
||
*/
|
||
type DiffKind = 'add' | 'context' | 'remove'
|
||
|
||
export interface DiffLine {
|
||
kind: DiffKind
|
||
text: string
|
||
/** 1-based line number in the old/new file (absent on the "other" side of an
|
||
* add/remove, and on hunk-separator blanks). Only used when line numbers are
|
||
* shown (the preview's full diff). */
|
||
newNo?: number
|
||
oldNo?: number
|
||
}
|
||
|
||
interface ParsedHunk {
|
||
lines: Array<{ kind: DiffKind; text: string }>
|
||
newStart: number
|
||
oldStart: number
|
||
}
|
||
|
||
// Tint + 2px gutter accent per change kind. Text color is included for the
|
||
// plain renderer; the Shiki path omits it so syntax colors win, layering only
|
||
// the background + border.
|
||
const DIFF_KIND_TINT: Record<DiffKind, string> = {
|
||
add: 'border-emerald-500 bg-emerald-500/12',
|
||
context: 'border-transparent',
|
||
remove: 'border-rose-500 bg-rose-500/12'
|
||
}
|
||
|
||
const DIFF_KIND_TEXT: Record<DiffKind, string> = {
|
||
add: 'text-emerald-800 dark:text-emerald-200',
|
||
context: '',
|
||
remove: 'text-rose-800 dark:text-rose-200'
|
||
}
|
||
|
||
const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px'
|
||
const PREVIEW_DIFF_LINE_BASE = 'block h-5 min-w-max whitespace-pre px-2.5 leading-5'
|
||
const PREVIEW_CHUNK_LINES = 200
|
||
const PREVIEW_LINE_PX = 20
|
||
const PREVIEW_OVERSCAN_LINES = 400
|
||
|
||
// Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the
|
||
// card edges (rounded corners clip via the card's overflow); compact height
|
||
// with internal scroll like a code block.
|
||
// `overscroll-y-auto` so reaching the box's top/bottom hands the wheel back to
|
||
// the page (no scroll-trap); `overscroll-x-contain` keeps a trackpad's sideways
|
||
// overscroll on long code lines from firing browser back/forward navigation.
|
||
const DIFF_BOX_CLASS =
|
||
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-x-contain overscroll-y-auto font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
|
||
|
||
function diffKind(line: string): DiffKind {
|
||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||
return 'add'
|
||
}
|
||
|
||
if (line.startsWith('-') && !line.startsWith('---')) {
|
||
return 'remove'
|
||
}
|
||
|
||
return 'context'
|
||
}
|
||
|
||
// Drop the leading +/-/space gutter so changes read by color alone, keeping the
|
||
// rest of the indentation intact.
|
||
function stripDiffMarker(line: string): string {
|
||
if (diffKind(line) !== 'context' || line.startsWith(' ')) {
|
||
return line.slice(1)
|
||
}
|
||
|
||
return line
|
||
}
|
||
|
||
// Git-style unified diffs arrive with a file-header preamble — `diff --git`,
|
||
// `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path`
|
||
// arrow line. That preamble just repeats the path (which the tool row already
|
||
// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip
|
||
// the leading header zone up to the first hunk.
|
||
const DIFF_HEADER_PREFIXES = [
|
||
'diff --git',
|
||
'index ',
|
||
'--- ',
|
||
'+++ ',
|
||
'similarity ',
|
||
'rename ',
|
||
'new file',
|
||
'deleted file'
|
||
]
|
||
|
||
function isArrowHeaderLine(line: string): boolean {
|
||
const trimmed = line.trim()
|
||
|
||
return trimmed.includes('→') && /^\S.*→\s*\S+$/.test(trimmed) && !/^[+\-@]/.test(trimmed)
|
||
}
|
||
|
||
/** Exported for tests. */
|
||
export function stripDiffFileHeaders(diff: string): string {
|
||
const lines = diff.split('\n')
|
||
let start = 0
|
||
|
||
for (; start < lines.length; start += 1) {
|
||
const line = lines[start]
|
||
|
||
if (line.startsWith('@@')) {
|
||
break
|
||
}
|
||
|
||
if (line.trim() === '' || isArrowHeaderLine(line) || DIFF_HEADER_PREFIXES.some(prefix => line.startsWith(prefix))) {
|
||
continue
|
||
}
|
||
|
||
break
|
||
}
|
||
|
||
return lines.slice(start).join('\n')
|
||
}
|
||
|
||
function parseHunks(diff: string): ParsedHunk[] {
|
||
const hunks: ParsedHunk[] = []
|
||
let active: null | ParsedHunk = null
|
||
|
||
for (const line of stripDiffFileHeaders(diff).split('\n')) {
|
||
if (line.startsWith('@@')) {
|
||
const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
||
|
||
if (!match) {
|
||
active = null
|
||
|
||
continue
|
||
}
|
||
|
||
active = { oldStart: Number(match[1]), newStart: Number(match[2]), lines: [] }
|
||
hunks.push(active)
|
||
|
||
continue
|
||
}
|
||
|
||
if (!active || line.startsWith('\\')) {
|
||
continue
|
||
}
|
||
|
||
active.lines.push({ kind: diffKind(line), text: stripDiffMarker(line) })
|
||
}
|
||
|
||
return hunks
|
||
}
|
||
|
||
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
|
||
// separator kept between hunks), markers stripped, kind recorded. Old/new line
|
||
// numbers are tracked from each `@@ -a,b +c,d @@` header so a caller that wants
|
||
// a gutter (the preview) can render them; the blank separator carries none.
|
||
function parseDiff(diff: string): DiffLine[] {
|
||
const hunks = parseHunks(diff)
|
||
|
||
if (hunks.length === 0) {
|
||
// Fallback for unexpected non-hunk payloads.
|
||
return stripDiffFileHeaders(diff)
|
||
.split('\n')
|
||
.map(line => ({ kind: diffKind(line), text: stripDiffMarker(line) }))
|
||
}
|
||
|
||
const out: DiffLine[] = []
|
||
let emitted = false
|
||
let oldNo = 1
|
||
let newNo = 1
|
||
|
||
for (const hunk of hunks) {
|
||
oldNo = hunk.oldStart
|
||
newNo = hunk.newStart
|
||
|
||
if (emitted) {
|
||
out.push({ kind: 'context', text: '' })
|
||
}
|
||
|
||
for (const line of hunk.lines) {
|
||
const entry: DiffLine = { kind: line.kind, text: line.text }
|
||
|
||
if (line.kind === 'add') {
|
||
entry.newNo = newNo++
|
||
} else if (line.kind === 'remove') {
|
||
entry.oldNo = oldNo++
|
||
} else {
|
||
entry.oldNo = oldNo++
|
||
entry.newNo = newNo++
|
||
}
|
||
|
||
out.push(entry)
|
||
emitted = true
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// Build a full-file diff view anchored to the CURRENT file text. Every current
|
||
// line is emitted from `fullText` with its real new-file line number; hunks only
|
||
// mark those rows as added and insert deleted rows between them. That keeps the
|
||
// preview's SOURCE and DIFF views on the same line map even when git returns
|
||
// compact hunks or removed-only rows.
|
||
function parseFullFileDiff(diff: string, fullText: string): DiffLine[] {
|
||
const hunks = parseHunks(diff)
|
||
const fullLines = fullText.split('\n')
|
||
|
||
if (hunks.length === 0) {
|
||
return fullLines.map((text, index) => ({ kind: 'context', newNo: index + 1, oldNo: index + 1, text }))
|
||
}
|
||
|
||
const added = new Set<number>()
|
||
const oldNoByNewNo = new Map<number, number>()
|
||
const removalsByNewNo = new Map<number, DiffLine[]>()
|
||
const out: DiffLine[] = []
|
||
|
||
for (const hunk of hunks) {
|
||
let oldNo = hunk.oldStart
|
||
let newNo = hunk.newStart
|
||
|
||
for (const line of hunk.lines) {
|
||
if (line.kind === 'add') {
|
||
added.add(newNo)
|
||
newNo += 1
|
||
} else if (line.kind === 'remove') {
|
||
const anchor = Math.max(1, Math.min(newNo, fullLines.length + 1))
|
||
const bucket = removalsByNewNo.get(anchor) ?? []
|
||
|
||
bucket.push({ kind: 'remove', oldNo, text: line.text })
|
||
removalsByNewNo.set(anchor, bucket)
|
||
oldNo += 1
|
||
} else {
|
||
oldNoByNewNo.set(newNo, oldNo)
|
||
oldNo += 1
|
||
newNo += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let index = 0; index < fullLines.length; index += 1) {
|
||
const newNo = index + 1
|
||
const removals = removalsByNewNo.get(newNo)
|
||
|
||
if (removals) {
|
||
out.push(...removals)
|
||
}
|
||
|
||
out.push({
|
||
kind: added.has(newNo) ? 'add' : 'context',
|
||
newNo,
|
||
oldNo: oldNoByNewNo.get(newNo),
|
||
text: fullLines[index] ?? ''
|
||
})
|
||
}
|
||
|
||
const trailingRemovals = removalsByNewNo.get(fullLines.length + 1)
|
||
|
||
if (trailingRemovals) {
|
||
out.push(...trailingRemovals)
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||
return (
|
||
<>
|
||
{lines.map((line, index) => (
|
||
<span
|
||
className={cn(DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind], !syntax && DIFF_KIND_TEXT[line.kind])}
|
||
key={`${index}-${line.text}`}
|
||
>
|
||
{line.text || ' '}
|
||
</span>
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// shiki FontStyle is a bitmask: Italic=1, Bold=2, Underline=4.
|
||
function tokenStyle({ bgColor, color, fontStyle = 0 }: ThemedToken): React.CSSProperties | undefined {
|
||
if (!color && !bgColor && !fontStyle) {
|
||
return undefined
|
||
}
|
||
|
||
return {
|
||
backgroundColor: bgColor,
|
||
color,
|
||
fontStyle: fontStyle & 1 ? 'italic' : undefined,
|
||
fontWeight: fontStyle & 2 ? 700 : undefined,
|
||
textDecorationLine: fontStyle & 4 ? 'underline' : undefined
|
||
}
|
||
}
|
||
|
||
function useThemeName() {
|
||
const current = () => (document.documentElement.classList.contains('dark') ? SHIKI_THEME.dark : SHIKI_THEME.light)
|
||
const [theme, setTheme] = React.useState(current)
|
||
|
||
React.useEffect(() => {
|
||
const observer = new MutationObserver(() => setTheme(current()))
|
||
|
||
observer.observe(document.documentElement, { attributeFilter: ['class'], attributes: true })
|
||
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
|
||
return theme
|
||
}
|
||
|
||
function PreviewDiffRows({
|
||
afterLines = 0,
|
||
beforeLines = 0,
|
||
chunks,
|
||
tokens
|
||
}: {
|
||
afterLines?: number
|
||
beforeLines?: number
|
||
chunks: Array<LineChunk<DiffLine>>
|
||
tokens?: ThemedToken[][] | null
|
||
}) {
|
||
return (
|
||
<>
|
||
{beforeLines > 0 && <div aria-hidden style={{ height: beforeLines * PREVIEW_LINE_PX }} />}
|
||
{chunks.map(chunk => (
|
||
<div className="block" key={chunk.start}>
|
||
{chunk.lines.map((line, offset) => {
|
||
const index = chunk.start + offset
|
||
const rowTokens = tokens?.[index] ?? []
|
||
|
||
return (
|
||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||
{rowTokens.length > 0
|
||
? rowTokens.map((token, tokenIndex) => (
|
||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||
{token.content}
|
||
</span>
|
||
))
|
||
: line.text || ' '}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
{afterLines > 0 && <div aria-hidden style={{ height: afterLines * PREVIEW_LINE_PX }} />}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function TokenizedDiffBody({
|
||
afterLines,
|
||
beforeLines,
|
||
chunked = false,
|
||
chunks,
|
||
language,
|
||
lines
|
||
}: {
|
||
afterLines?: number
|
||
beforeLines?: number
|
||
chunked?: boolean
|
||
chunks?: Array<LineChunk<DiffLine>>
|
||
language: string
|
||
lines: DiffLine[]
|
||
}) {
|
||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||
const theme = useThemeName()
|
||
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(null)
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false
|
||
|
||
setTokens(null)
|
||
void codeToTokens(code, { lang: language as BundledLanguage, theme })
|
||
.then(result => {
|
||
if (!cancelled) {
|
||
setTokens(result.tokens)
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setTokens([])
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [code, language, theme])
|
||
|
||
if (!tokens) {
|
||
return chunked ? (
|
||
<PreviewDiffRows
|
||
afterLines={afterLines}
|
||
beforeLines={beforeLines}
|
||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||
/>
|
||
) : (
|
||
<DiffBody lines={lines} />
|
||
)
|
||
}
|
||
|
||
if (chunked) {
|
||
return (
|
||
<PreviewDiffRows
|
||
afterLines={afterLines}
|
||
beforeLines={beforeLines}
|
||
chunks={chunks ?? chunkLines(lines, PREVIEW_CHUNK_LINES)}
|
||
tokens={tokens}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{lines.map((line, index) => {
|
||
const rowTokens = tokens[index] ?? []
|
||
|
||
return (
|
||
<span className={cn(PREVIEW_DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind])} key={`${index}-${line.text}`}>
|
||
{rowTokens.length > 0
|
||
? rowTokens.map((token, tokenIndex) => (
|
||
<span key={`${tokenIndex}-${token.offset}`} style={tokenStyle(token)}>
|
||
{token.content}
|
||
</span>
|
||
))
|
||
: line.text || ' '}
|
||
</span>
|
||
)
|
||
})}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
|
||
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
|
||
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||
return {
|
||
line(node, line) {
|
||
const kind = kinds[line - 1] ?? 'context'
|
||
|
||
const existing = Array.isArray(node.properties.className)
|
||
? (node.properties.className as string[])
|
||
: node.properties.className
|
||
? [String(node.properties.className)]
|
||
: []
|
||
|
||
node.properties.className = [...existing, DIFF_LINE_BASE, DIFF_KIND_TINT[kind]]
|
||
}
|
||
}
|
||
}
|
||
|
||
function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
|
||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||
const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
|
||
|
||
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
|
||
defaultColor: 'light-dark()',
|
||
transformers
|
||
})
|
||
|
||
// Until Shiki resolves, show the plain colored diff so there's no flash.
|
||
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
|
||
}
|
||
|
||
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
||
text: string
|
||
}
|
||
|
||
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
||
const lines = React.useMemo(() => parseDiff(text), [text])
|
||
|
||
return (
|
||
<pre className={cn(DIFF_BOX_CLASS, className)} data-slot="diff-lines" {...props}>
|
||
<DiffBody lines={lines} />
|
||
</pre>
|
||
)
|
||
}
|
||
|
||
// Coalesce consecutive same-kind changed rows into runs, each placed by line
|
||
// fraction (no DOM measurement). Context rows produce no tick.
|
||
function overviewRuns(lines: DiffLine[]): { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] {
|
||
const total = lines.length || 1
|
||
const runs: { kind: 'add' | 'remove'; sizePct: number; startPct: number }[] = []
|
||
|
||
for (let i = 0; i < lines.length; ) {
|
||
const kind = lines[i].kind
|
||
|
||
if (kind === 'context') {
|
||
i += 1
|
||
|
||
continue
|
||
}
|
||
|
||
let j = i + 1
|
||
|
||
while (j < lines.length && lines[j].kind === kind) {
|
||
j += 1
|
||
}
|
||
|
||
runs.push({ kind, sizePct: ((j - i) / total) * 100, startPct: (i / total) * 100 })
|
||
i = j
|
||
}
|
||
|
||
return runs
|
||
}
|
||
|
||
// VS Code-style overview ruler: a thin strip pinned to the diff's right edge with
|
||
// a green/red tick per change, positioned by line fraction. Pinned to the
|
||
// viewport (not the scrolled content) by living as an absolute sibling of the
|
||
// scroller inside a relative wrapper — so no scroll listener or measurement.
|
||
function DiffOverviewRuler({ lines }: { lines: DiffLine[] }) {
|
||
const runs = React.useMemo(() => overviewRuns(lines), [lines])
|
||
|
||
if (runs.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div aria-hidden className="pointer-events-none absolute top-0 right-0 bottom-0 w-1.5 opacity-80">
|
||
{/* Cap the tick field to the diff's natural height (rows × line px) so a
|
||
short diff renders thin, line-aligned ticks instead of stretching a few
|
||
changes into gross full-height blocks. A long diff hits the 100% cap and
|
||
compresses into a true overview. */}
|
||
<div
|
||
className="relative w-full"
|
||
style={{ height: `min(100%, ${lines.length * PREVIEW_LINE_PX}px)` }}
|
||
>
|
||
{runs.map((run, index) => (
|
||
<div
|
||
className={cn('absolute inset-x-0', run.kind === 'add' ? 'bg-(--ui-green)' : 'bg-(--ui-red)')}
|
||
key={index}
|
||
style={{ height: `max(0.125rem, ${run.sizePct}%)`, top: `${run.startPct}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface FileDiffPanelProps {
|
||
/** Override the default (tool-card) box styling — the full-height preview
|
||
* cancels the bleed/clamp so the diff fills its pane. */
|
||
className?: string
|
||
diff: string
|
||
/** Current file text. When provided, the panel expands hunked diffs into a
|
||
* full-file view so unchanged lines are preserved between hunks. */
|
||
fullText?: string
|
||
path?: string
|
||
/** Render an old/new line-number gutter (the full preview diff). The compact
|
||
* tool-card + inline review diff leave this off. */
|
||
showLineNumbers?: boolean
|
||
}
|
||
|
||
export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) {
|
||
const lines = React.useMemo(
|
||
() => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)),
|
||
[diff, fullText]
|
||
)
|
||
|
||
const lineChunks = React.useMemo(() => chunkLines(lines, PREVIEW_CHUNK_LINES), [lines])
|
||
|
||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||
overscanRows: PREVIEW_OVERSCAN_LINES,
|
||
rowPx: PREVIEW_LINE_PX,
|
||
rowsPerChunk: PREVIEW_CHUNK_LINES,
|
||
totalRows: lines.length
|
||
})
|
||
|
||
const visibleLineChunks = lineChunks.slice(startChunk, endChunk + 1)
|
||
|
||
const language = shikiLanguageForFilename(path)
|
||
const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff)
|
||
|
||
// Full-file preview: we own the rows (tokens rendered inside) so blank lines
|
||
// can't collapse. Compact tool/review diffs let Shiki own the rows.
|
||
const body = !canHighlight ? (
|
||
showLineNumbers ? (
|
||
<PreviewDiffRows afterLines={afterRows} beforeLines={beforeRows} chunks={visibleLineChunks} />
|
||
) : (
|
||
<DiffBody lines={lines} />
|
||
)
|
||
) : fullText != null ? (
|
||
<TokenizedDiffBody
|
||
afterLines={afterRows}
|
||
beforeLines={beforeRows}
|
||
chunked={showLineNumbers}
|
||
chunks={visibleLineChunks}
|
||
language={language}
|
||
lines={lines}
|
||
/>
|
||
) : (
|
||
<SyntaxDiff language={language} lines={lines} />
|
||
)
|
||
|
||
if (!showLineNumbers) {
|
||
return (
|
||
<div className={cn(DIFF_BOX_CLASS, className)} data-slot="file-diff-panel">
|
||
{body}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// A single line-number gutter (VS Code's inline-diff style): each row shows its
|
||
// own file's number — the new number for context/adds, the old number for
|
||
// removals — with an overview ruler pinned to the right edge. The inner div
|
||
// owns the scroll so the ruler (an absolute sibling) stays viewport-fixed.
|
||
return (
|
||
<div className={cn(DIFF_BOX_CLASS, 'relative overflow-hidden', className)} data-slot="file-diff-panel">
|
||
<div className="absolute inset-0 overflow-auto pr-2.5" onScroll={onScroll} ref={scrollerRef}>
|
||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)]">
|
||
<div className="sticky left-0 z-1 select-none bg-(--ui-editor-surface-background) py-3 text-muted-foreground/55">
|
||
{beforeRows > 0 && (
|
||
<div aria-hidden style={{ height: beforeRows * PREVIEW_LINE_PX }} />
|
||
)}
|
||
{visibleLineChunks.map(chunk => (
|
||
<div className="block" key={chunk.start}>
|
||
{chunk.lines.map((line, offset) => {
|
||
const index = chunk.start + offset
|
||
|
||
return (
|
||
<div
|
||
className="h-5 w-9 pr-2 text-right leading-5 tabular-nums"
|
||
key={`${index}-${line.oldNo}-${line.newNo}`}
|
||
>
|
||
{line.newNo ?? ''}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * PREVIEW_LINE_PX }} />}
|
||
</div>
|
||
<div className="min-w-0">{body}</div>
|
||
</div>
|
||
</div>
|
||
<DiffOverviewRuler lines={lines} />
|
||
</div>
|
||
)
|
||
}
|