codev / desktop /src /components /chat /DiffViewer.tsx
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
6.69 kB
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>
)
}