diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 80b86ad0d98..4778d1831eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -13,11 +13,15 @@ import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' import { DIFF_EMBED_LANGUAGE } from '@/lib/diff/embed-language' +import { + looksLikeMermaid, + MermaidDiagram, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram' +import { MarkdownCodeBlock } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema' +import { detectLanguage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/detect-language' import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' -import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' -import { MarkdownCodeBlock } from './code-block-schema' -import { detectLanguage } from './detect-language' -import { useEditorEditable } from './use-editor-editable' +import { ToolbarButton } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' +import { useEditorEditable } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/use-editor-editable' /** Kept out of every rich-markdown surface's graph until a document actually holds a dashboard. */ const DashboardEmbed = lazy(() => @@ -53,9 +57,6 @@ export const LANGUAGE_OPTIONS = [ { value: 'yaml', label: 'YAML' }, ] as const -const CONTROL_CLASS = - 'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-hidden transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]' - /** * Code block view with hover controls (language picker, line-wrap, copy). When the block holds * Mermaid — tagged ```mermaid or {@link looksLikeMermaid auto-detected} — it renders as a diagram @@ -66,7 +67,7 @@ const CONTROL_CLASS = * dashboard panels the same way. */ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeViewProps) { - const [wrap, setWrap] = useState(false) + const [wrap, setWrap] = useState(() => node.attrs.language === DIFF_EMBED_LANGUAGE) const [menuOpen, setMenuOpen] = useState(false) const [editingInline, setEditingInline] = useState(false) const [peekSource, setPeekSource] = useState(false) @@ -144,15 +145,17 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
{isRendered && ( - + /> )} {!isRendered && (editable ? ( @@ -204,30 +203,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView {label} ))} - {!isRendered && editable && ( - + /> )} - + />
          as='code' />
@@ -252,7 +240,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
             
           ) : isDiff ? (
             
-              
+              
             
           ) : (
             
diff --git a/apps/sim/components/diff/diff-embed.tsx b/apps/sim/components/diff/diff-embed.tsx
index 8509a0fb5d5..7553780b77a 100644
--- a/apps/sim/components/diff/diff-embed.tsx
+++ b/apps/sim/components/diff/diff-embed.tsx
@@ -1,19 +1,13 @@
 'use client'
 
 import type { ComponentType, ReactNode } from 'react'
-import { cn } from '@sim/emcn'
+import { cn, OverflowText } from '@sim/emcn'
 import { ArrowUpRight, Database, File } from '@sim/emcn/icons'
 import { getErrorMessage } from '@sim/utils/errors'
 import Link from 'next/link'
 import { useParams } from 'next/navigation'
 import { DiffView } from '@/components/diff/diff-view'
-import {
-  type DiffLine,
-  type DiffSource,
-  parseUnifiedDiff,
-  sameSource,
-  type UnifiedDiff,
-} from '@/lib/diff/unified'
+import { type DiffSource, parseUnifiedDiff, sameSource, type UnifiedDiff } from '@/lib/diff/unified'
 import { workspaceResourcePath } from '@/lib/resources'
 import { useDocumentQuery } from '@/hooks/queries/kb/knowledge'
 import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files'
@@ -23,6 +17,7 @@ interface DiffEmbedProps {
   source: string
   /** True while an agent is still writing the document, so the fence may be incomplete. */
   isStreaming: boolean
+  wrapLines?: boolean
 }
 interface NoticeProps {
   children: ReactNode
@@ -42,25 +37,27 @@ interface CardHeading {
   icon?: ComponentType<{ className?: string }>
   href?: string
 }
-interface ExcerptCardProps {
+interface SourceHeadingProps {
   heading: CardHeading
-  diff: UnifiedDiff
-  side: 'old' | 'new'
-}
-interface InlineMarkdownProps {
-  text: string
-}
-interface ProseLineProps {
-  line: DiffLine
+  side?: 'Before' | 'After'
 }
 interface CodeDiffProps {
   diff: UnifiedDiff
   heading: CardHeading
+  wrapLines: boolean
+}
+interface DocumentComparisonProps {
+  diff: UnifiedDiff
+  oldHeading: CardHeading
+  newHeading: CardHeading
+  wrapLines: boolean
+}
+interface ChangeCountsProps {
+  diff: UnifiedDiff
 }
 
 const NOTICE_CLASS =
-  'rounded-lg bg-[var(--surface-5)] p-4 pr-16 text-caption dark:bg-[var(--surface-4)]'
-const MARK = 'rounded-[3px] px-1 text-[var(--text-primary)]'
+  'min-h-[56px] rounded-lg bg-[var(--surface-5)] p-4 pr-[140px] text-caption sm:pr-[120px] dark:bg-[var(--surface-4)]'
 
 function Notice({ children, tone = 'muted' }: NoticeProps) {
   return (
@@ -77,7 +74,7 @@ function Notice({ children, tone = 'muted' }: NoticeProps) {
   )
 }
 
-function changeCounts(diff: UnifiedDiff): string {
+function ChangeCounts({ diff }: ChangeCountsProps) {
   let added = 0
   let removed = 0
   for (const hunk of diff.hunks)
@@ -85,7 +82,22 @@ function changeCounts(diff: UnifiedDiff): string {
       if (line.type === 'add') added++
       else if (line.type === 'del') removed++
     }
-  return `+${added} −${removed}`
+  return (
+    
+ + +{added} + + + −{removed} + +
+ ) } function formatDate(value: string | Date | null | undefined): string | null { @@ -129,11 +141,11 @@ function useDiffSource( } } -function heading(resolved: ResolvedSource | null, fallback: string, detail: string): CardHeading { - if (!resolved) return { title: fallback, meta: detail } +function heading(resolved: ResolvedSource | null, fallback: string): CardHeading { + if (!resolved) return { title: fallback, meta: '' } return { title: resolved.title, - meta: [resolved.kindLabel, resolved.updated && `Updated ${resolved.updated}`, detail] + meta: [resolved.kindLabel, resolved.updated && `Updated ${resolved.updated}`] .filter(Boolean) .join(' · '), icon: resolved.icon, @@ -141,103 +153,55 @@ function heading(resolved: ResolvedSource | null, fallback: string, detail: stri } } -const INLINE_MARKDOWN = /(\*\*[^*]+\*\*|`[^`]+`)/ - -/** Renders the inline `**bold**` and `` `code` `` that source excerpts carry, as written. */ -function InlineMarkdown({ text }: InlineMarkdownProps) { - return ( - <> - {text.split(INLINE_MARKDOWN).map((part, index) => - part.startsWith('**') && part.endsWith('**') && part.length > 4 ? ( - - {part.slice(2, -2)} - - ) : part.startsWith('`') && part.endsWith('`') && part.length > 2 ? ( - - {part.slice(1, -1)} - - ) : ( - part - ) - )} - - ) -} - -/** Markdown headings read as headings; everything else is a paragraph of the source. */ -function ProseLine({ line }: ProseLineProps) { - const headingMatch = /^#{1,6}\s+(.*)$/.exec(line.text) - const text = headingMatch ? headingMatch[1] : line.text - const weight = headingMatch && 'font-medium' - if (line.type === 'context') - return ( -

- -

- ) +function SourceHeading({ heading, side }: SourceHeadingProps) { + const Icon = heading.icon ?? File return ( -

- - - -

+
+ +
+ {side && heading.title !== side && ( + {side} + )} +
+ + {heading.href && ( + + + + )} +
+ {heading.meta && ( + + )} +
+
) } -/** One side of a two-document comparison, in the shape of the page it quotes, its claim marked. */ -function ExcerptCard({ heading, diff, side }: ExcerptCardProps) { - const Icon = heading.icon - const skip = side === 'old' ? 'add' : side === 'new' ? 'del' : null +function DocumentComparison({ diff, oldHeading, newHeading, wrapLines }: DocumentComparisonProps) { return ( -
-
- {Icon && } -
-
- {heading.title} - {heading.href && ( - - - - )} -
- {heading.meta} -
-
-
- {diff.hunks.map((hunk, hunkIndex) => ( -
- {hunkIndex > 0 &&

⋯

} - {hunk.lines - .filter((line) => line.type !== skip && line.text.trim() !== '') - .map((line, index) => ( - - ))} -
- ))} +
+
+
+ +
+
+ +
-
+ + ) } -function CodeDiff({ diff, heading }: CodeDiffProps) { - const Icon = heading.icon +function CodeDiff({ diff, heading, wrapLines }: CodeDiffProps) { return ( -
-
- {Icon && } - {heading.title} - {heading.href && ( - - - - )} - {heading.meta} -
- +
+
+ + +
+
) } @@ -258,7 +222,7 @@ function parse( * with a link in each card header. An edit renders line by line; two different documents render * as side-by-side excerpts. A public share has no workspace session, so it shows no source names. */ -export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { +export function DiffEmbed({ source, isStreaming, wrapLines = true }: DiffEmbedProps) { const params = useParams() const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : null const parsed = parse(source) @@ -273,12 +237,20 @@ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { if (comparison) return ( -
- - -
+ ) return ( - + ) } diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index 9f84d92e858..8c3b593b2f7 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -1,59 +1,106 @@ 'use client' -import { Fragment, useState } from 'react' -import { cn } from '@sim/emcn' +import { Fragment, type ReactNode, useState } from 'react' +import { Chip, cn, scrollFadeAttributes, scrollFadeXClass, useScrollEdges } from '@sim/emcn' +import { ChevronsUpDown } from '@sim/emcn/icons' import { diffWordsWithSpace } from 'diff' import type { DiffHunk, DiffLine } from '@/lib/diff/unified' -/** Unchanged lines kept visible on each side of a collapsed run. */ const CONTEXT_EDGE = 3 -/** A run of unchanged lines collapses once it is longer than this. */ const COLLAPSE_AFTER = CONTEXT_EDGE * 2 + 2 -/** Word-level comparison is skipped past this many characters, so huge lines stay cheap. */ const WORD_DIFF_MAX_CHARS = 1000 +const INLINE_MARKDOWN = /(\*\*[^*]+\*\*|`[^`]+`)/ const ADD_ROW = - 'bg-[color-mix(in_srgb,var(--badge-success-bg)_35%,transparent)] text-[var(--text-primary)]' + 'relative bg-[color-mix(in_srgb,var(--badge-success-bg)_18%,transparent)] before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-[var(--badge-success-text)]' const DEL_ROW = - 'bg-[color-mix(in_srgb,var(--badge-error-bg)_35%,transparent)] text-[var(--text-primary)]' -const CONTEXT_ROW = 'text-[var(--text-body)]' -const ADD_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-success-bg)_90%,transparent)]' -const DEL_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-error-bg)_90%,transparent)]' -const GUTTER = 'select-none pl-3 text-right text-[var(--text-muted)] tabular-nums' -const MARKER = 'select-none pl-3' -const TEXT = 'whitespace-pre py-0.5 pr-3 pl-1' + 'relative bg-[color-mix(in_srgb,var(--badge-error-bg)_18%,transparent)] before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-[var(--badge-error-text)]' +const ADD_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-success-bg)_70%,transparent)]' +const DEL_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-error-bg)_70%,transparent)]' +const GUTTER = + 'select-none py-0.5 pr-2 pl-2 text-right text-[var(--text-muted)] text-caption tabular-nums' +const MARKER = 'select-none py-0.5 text-center text-caption' interface Segment { text: string changed: boolean } + +type VisibleItem = { line: DiffLine; key: number } | { collapsed: number; key: number } +type PairedRow = { old?: DiffLine; new?: DiffLine; key: number } +type PairedItem = PairedRow | { collapsed: number; key: number } +type ProseItem = + | { kind: 'heading'; hunk: DiffHunk; key: string } + | { kind: 'collapsed'; count: number; key: string } + | { kind: 'row'; item: PairedRow; segments: Map; key: string } + interface DiffViewProps { hunks: DiffHunk[] + wrapLines?: boolean + prose?: boolean } + interface LineTextProps { line: DiffLine segments: Segment[] | undefined + prose: boolean +} + +interface HighlightedTextProps { + text: string + offset: number + segments: Segment[] | undefined + type: DiffLine['type'] } + interface CollapsedRunProps { count: number onExpand: () => void + row?: number +} + +interface DiffCellProps extends Omit { + wrapLines: boolean +} +interface UnifiedRowProps extends Omit { + numbered: boolean + wrapLines: boolean +} +interface ProseColumnProps { + items: ProseItem[] + side: 'old' | 'new' + wrapLines: boolean +} +interface ProseRowsProps { + hunks: DiffHunk[] + wrapLines: boolean +} +interface HunkHeadingProps { + hunk: DiffHunk + row?: number +} +interface HunkRowsProps { + hunk: DiffHunk + index: number + numbered: boolean + wrapLines: boolean } -/** - * Pairs each run of removed lines with the added lines that follow it, so a reworded line shows - * which words changed. Unpaired lines keep whole-line highlighting. - */ +/** Pairs removed and added lines within each change block for bounded word-level highlighting. */ function wordSegments(lines: DiffLine[]): Map { const segments = new Map() for (let index = 0; index < lines.length; ) { - if (lines[index].type !== 'del') { + if (lines[index].type === 'context') { index++ continue } const dels: DiffLine[] = [] - while (lines[index]?.type === 'del') dels.push(lines[index++]) const adds: DiffLine[] = [] - while (lines[index]?.type === 'add') adds.push(lines[index++]) + while (index < lines.length && lines[index].type !== 'context') { + const line = lines[index++] + if (line.type === 'del') dels.push(line) + else adds.push(line) + } for (let pair = 0; pair < Math.min(dels.length, adds.length); pair++) { if (dels[pair].text.length + adds[pair].text.length > WORD_DIFF_MAX_CHARS) continue const parts = diffWordsWithSpace(dels[pair].text, adds[pair].text) @@ -74,64 +121,149 @@ function wordSegments(lines: DiffLine[]): Map { return segments } -function LineText({ line, segments }: LineTextProps) { - if (!segments) return <>{line.text || ' '} - const word = line.type === 'add' ? ADD_WORD : DEL_WORD +function HighlightedText({ text, offset, segments, type }: HighlightedTextProps) { + if (!segments) return <>{text || ' '} + let position = 0 + const children: ReactNode[] = [] + for (const [index, segment] of segments.entries()) { + const start = Math.max(offset, position) + const end = Math.min(offset + text.length, position + segment.text.length) + if (end > start) { + children.push( + + {text.slice(start - offset, end - offset)} + + ) + } + position += segment.text.length + } + return <>{children} +} + +function LineText({ line, segments, prose }: LineTextProps) { + const changeLabel = line.type === 'add' ? 'Added: ' : line.type === 'del' ? 'Removed: ' : null + if (!prose) + return ( + <> + {changeLabel} + + + ) + const prefix = /^#{1,6}\s+/.exec(line.text)?.[0] ?? '' + const text = line.text.slice(prefix.length) + let offset = prefix.length return ( - <> - {segments.map((segment, index) => ( - - {segment.text} +

+ {changeLabel} + {prefix && ( + + - ))} - + )} + {text.split(INLINE_MARKDOWN).map((part, index) => { + const bold = part.startsWith('**') && part.endsWith('**') && part.length > 4 + const code = part.startsWith('`') && part.endsWith('`') && part.length > 2 + const inset = bold ? 2 : code ? 1 : 0 + const content = ( + + ) + offset += part.length + return bold ? ( + + {content} + + ) : code ? ( + + {content} + + ) : ( + {content} + ) + })} +

) } -function CollapsedRun({ count, onExpand }: CollapsedRunProps) { +function CollapsedRun({ count, onExpand, row }: CollapsedRunProps) { return ( - + + Show {count} unchanged {count === 1 ? 'line' : 'lines'} + +
) } -/** Splits a hunk into visible lines and collapsed runs of unchanged lines. */ -function visibleItems(lines: DiffLine[], isExpanded: (start: number) => boolean) { - const items: Array<{ line: DiffLine } | { collapsed: number; key: number }> = [] +function visibleItems(lines: DiffLine[], isExpanded: (start: number) => boolean): VisibleItem[] { + const items: VisibleItem[] = [] for (let index = 0; index < lines.length; ) { if (lines[index].type !== 'context') { - items.push({ line: lines[index++] }) + items.push({ line: lines[index], key: index++ }) continue } const start = index while (lines[index]?.type === 'context') index++ - const run = lines.slice(start, index) - const leading = start === 0 - const trailing = index === lines.length - const keepBefore = leading ? 0 : CONTEXT_EDGE - const keepAfter = trailing ? 0 : CONTEXT_EDGE - if ( - isExpanded(start) || - run.length <= COLLAPSE_AFTER || - run.length <= keepBefore + keepAfter + 1 - ) { - for (const line of run) items.push({ line }) + const keepBefore = start === 0 ? 0 : CONTEXT_EDGE + const keepAfter = index === lines.length ? 0 : CONTEXT_EDGE + if (isExpanded(start) || index - start <= COLLAPSE_AFTER) { + for (let cursor = start; cursor < index; cursor++) + items.push({ line: lines[cursor], key: cursor }) continue } - for (const line of run.slice(0, keepBefore)) items.push({ line }) - items.push({ collapsed: run.length - keepBefore - keepAfter, key: start }) - for (const line of run.slice(run.length - keepAfter)) items.push({ line }) + for (let cursor = start; cursor < start + keepBefore; cursor++) + items.push({ line: lines[cursor], key: cursor }) + items.push({ collapsed: index - start - keepBefore - keepAfter, key: start }) + for (let cursor = index - keepAfter; cursor < index; cursor++) + items.push({ line: lines[cursor], key: cursor }) } return items } +function pairedItems(items: VisibleItem[]): PairedItem[] { + const paired: PairedItem[] = [] + for (let index = 0; index < items.length; ) { + const item = items[index] + if ('collapsed' in item) { + paired.push(item) + index++ + continue + } + if (item.line.type === 'context') { + paired.push({ old: item.line, new: item.line, key: item.key }) + index++ + continue + } + const removed: DiffLine[] = [] + const added: DiffLine[] = [] + const key = item.key + while (index < items.length) { + const next = items[index] + if (!('line' in next) || next.line.type === 'context') break + if (next.line.type === 'del') removed.push(next.line) + else added.push(next.line) + index++ + } + for (let pair = 0; pair < Math.max(removed.length, added.length); pair++) { + paired.push({ old: removed[pair], new: added[pair], key: key + pair }) + } + } + return paired +} + function rowClass(line: DiffLine | undefined) { - return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : line && CONTEXT_ROW + return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : undefined } function markerClass(line: DiffLine | undefined) { @@ -143,60 +275,218 @@ function markerClass(line: DiffLine | undefined) { } function marker(line: DiffLine | undefined) { - return line?.type === 'add' ? '+' : line?.type === 'del' ? '-' : ' ' + return line?.type === 'add' ? '+' : line?.type === 'del' ? '−' : ' ' +} + +function DiffCell({ line, segments, wrapLines }: DiffCellProps) { + return ( +
+ + {marker(line)} + +
+ +
+
+ ) +} + +function UnifiedRow({ line, segments, numbered, wrapLines }: UnifiedRowProps) { + return ( +
+ {numbered && ( + + {line.oldLine ?? ''} + + )} + {numbered && ( + + {line.newLine ?? ''} + + )} + + {marker(line)} + + + + +
+ ) +} + +function ProseColumn({ items, side, wrapLines }: ProseColumnProps) { + const [scrollElement, setScrollElement] = useState(null) + const scrollEdges = useScrollEdges(scrollElement, { axis: 'x' }) + return ( +
+
+
+ {items.map((entry) => { + const line = entry.kind === 'row' ? entry.item[side] : undefined + return line && entry.kind === 'row' ? ( + + ) : ( +
+ ) + })} +
+
+
+ ) +} + +function HunkHeading({ hunk, row }: HunkHeadingProps) { + return ( +
+ {hunk.file && {hunk.file} } + {hunk.heading || (hunk.file ? '' : '⋯')} +
+ ) } -/** - * A unified diff rendered with word-level highlights, collapsed unchanged runs, and line numbers - * when the hunk headers carry them. - */ -export function DiffView({ hunks }: DiffViewProps) { - /** Expanded runs, keyed `hunk:start` so each collapsed run opens on its own. */ +function ProseRows({ hunks, wrapLines }: ProseRowsProps) { const [expanded, setExpanded] = useState>(() => new Set()) + const items: ProseItem[] = [] + for (const [index, hunk] of hunks.entries()) { + if (hunk.file || hunk.heading || index > 0) + items.push({ kind: 'heading', hunk, key: `${index}:heading` }) + const segments = wordSegments(hunk.lines) + const visible = visibleItems(hunk.lines, (start) => expanded.has(`${index}:c${start}`)) + for (const item of pairedItems(visible)) { + items.push( + 'collapsed' in item + ? { kind: 'collapsed', count: item.collapsed, key: `${index}:c${item.key}` } + : { kind: 'row', item, segments, key: `${index}:${item.key}` } + ) + } + } + if (items.length === 0) return null + return ( +
+ + + {items.map((entry, index) => + entry.kind === 'heading' ? ( + + ) : entry.kind === 'collapsed' ? ( + setExpanded((current) => new Set(current).add(entry.key))} + /> + ) : null + )} +
+ ) +} + +function HunkRows({ hunk, index, numbered, wrapLines }: HunkRowsProps) { + const [expanded, setExpanded] = useState>(() => new Set()) + const segments = wordSegments(hunk.lines) + const items = visibleItems(hunk.lines, (start) => expanded.has(start)) + return ( + <> + {(hunk.file || hunk.heading || index > 0) && } + {items.map((item) => + 'collapsed' in item ? ( + setExpanded((current) => new Set(current).add(item.key))} + /> + ) : ( + + ) + )} + + ) +} + +/** Unified file edits and aligned prose comparisons share highlights and collapsible context. */ +export function DiffView({ hunks, wrapLines = true, prose = false }: DiffViewProps) { + const [scrollElement, setScrollElement] = useState(null) + const scrollEdges = useScrollEdges(scrollElement, { axis: 'x' }) + if (prose) return const numbered = hunks.some((hunk) => hunk.lines.some((line) => line.oldLine !== undefined || line.newLine !== undefined) ) - const columns = numbered ? 'grid-cols-[auto_auto_auto_1fr]' : 'grid-cols-[auto_1fr]' + const columns = numbered + ? 'grid-cols-[minmax(3.5ch,auto)_minmax(3.5ch,auto)_24px_minmax(0,1fr)]' + : 'grid-cols-[24px_minmax(0,1fr)]' return ( -
-
- {hunks.map((hunk, hunkIndex) => { - const segments = wordSegments(hunk.lines) - const items = visibleItems(hunk.lines, (start) => expanded.has(`${hunkIndex}:${start}`)) - return ( - - {(hunk.file || hunk.heading || hunkIndex > 0) && ( -
- {hunk.file && {hunk.file} } - {hunk.heading || (hunk.file ? '' : '⋯')} -
- )} - {items.map((item, index) => - 'line' in item ? ( -
- {numbered && {item.line.oldLine ?? ''}} - {numbered && {item.line.newLine ?? ''}} - {marker(item.line)} - - - -
- ) : ( - - setExpanded((current) => new Set(current).add(`${hunkIndex}:${item.key}`)) - } - /> - ) - )} -
- ) - })} +
+
+ {hunks.map((hunk, index) => ( + + ))}
)