File size: 6,690 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '../shared/CopyButton'
type Props = {
filePath: string
oldString: string
newString: string
}
function inferLanguage(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase()
const langMap: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', rs: 'rust', go: 'go', rb: 'ruby',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', css: 'css', html: 'markup', xml: 'markup',
sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash',
}
return langMap[ext ?? ''] || 'text'
}
/** Shared warm syntax theme — must stay in sync with CodeViewer */
const warmSyntaxTheme: PrismTheme = {
plain: {
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
{ types: ['regex'], style: { color: 'var(--color-primary-container)' } },
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
function highlightSyntax(str: string, language: string) {
return (
<Highlight theme={warmSyntaxTheme} code={str} language={language}>
{({ tokens, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<span key={i}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
))}
</>
)}
</Highlight>
)
}
const diffStyles = {
variables: {
light: {
diffViewerBackground: 'var(--color-code-bg)',
diffViewerColor: 'var(--color-code-fg)',
addedBackground: 'var(--color-diff-added-bg)',
addedColor: 'var(--color-code-fg)',
removedBackground: 'var(--color-diff-removed-bg)',
removedColor: 'var(--color-code-fg)',
wordAddedBackground: 'var(--color-diff-added-word)',
wordRemovedBackground: 'var(--color-diff-removed-word)',
addedGutterBackground: 'var(--color-diff-added-gutter)',
removedGutterBackground: 'var(--color-diff-removed-gutter)',
gutterBackground: 'var(--color-surface-container-low)',
gutterBackgroundDark: 'var(--color-surface-container)',
highlightBackground: 'var(--color-diff-highlight-bg)',
highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
codeFoldGutterBackground: 'var(--color-surface-container-high)',
codeFoldBackground: 'var(--color-surface-container-highest)',
emptyLineBackground: 'var(--color-surface-container-low)',
gutterColor: 'var(--color-text-tertiary)',
addedGutterColor: 'var(--color-diff-added-text)',
removedGutterColor: 'var(--color-diff-removed-text)',
codeFoldContentColor: 'var(--color-text-tertiary)',
diffViewerTitleBackground: 'var(--color-diff-title-bg)',
diffViewerTitleColor: 'var(--color-diff-title-color)',
diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
},
},
diffContainer: {
borderRadius: '0',
fontSize: '12px',
lineHeight: '1.45',
fontFamily: 'var(--font-mono)',
},
line: {
padding: '1px 0',
},
gutter: {
padding: '1px 8px',
minWidth: '40px',
fontSize: '11px',
},
wordDiff: {
padding: '1px 2px',
borderRadius: '2px',
},
}
export function DiffViewer({ filePath, oldString, newString }: Props) {
const language = inferLanguage(filePath)
const oldLines = oldString.split('\n')
const newLines = newString.split('\n')
const additions = newLines.filter((l, i) => l !== (oldLines[i] ?? null)).length
const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{filePath}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[var(--color-diff-added-bg)] px-2 py-0.5 text-[var(--color-diff-added-text)]">+{additions}</span>
<span className="rounded-full bg-[var(--color-diff-removed-bg)] px-2 py-0.5 text-[var(--color-diff-removed-text)]">-{deletions}</span>
</div>
</div>
<CopyButton
text={`--- ${filePath}\n+++ ${filePath}`}
label="Copy path"
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
/>
</div>
{/* Diff area */}
<div className="max-h-[400px] overflow-auto">
<ReactDiffViewer
oldValue={oldString}
newValue={newString}
splitView={false}
compareMethod={DiffMethod.WORDS}
renderContent={(str) => highlightSyntax(str, language)}
hideLineNumbers={false}
styles={diffStyles}
useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'}
/>
</div>
</div>
)
}
|