Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('ErrorBoundary', () => {
consoleErrorSpy.mockRestore()
})

it('shows retry and compact Doctor fallback when a child crashes', () => {
it('shows retry and compact Doctor fallback when a child crashes', async () => {
render(
<ErrorBoundary>
<CrashingChild />
Expand All @@ -43,7 +43,7 @@ describe('ErrorBoundary', () => {
expect(screen.getByText('Something went wrong.')).toBeInTheDocument()
expect(screen.getByText('The error was recorded in Diagnostics.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByTestId('doctor-panel')).toHaveTextContent('compact doctor')
expect(await screen.findByTestId('doctor-panel')).toHaveTextContent('compact doctor')
expect(reportReactError).toHaveBeenCalled()
})
})
4 changes: 2 additions & 2 deletions desktop/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import { t } from '../i18n'
import { reportReactError } from '../lib/diagnosticsCapture'
import { Button } from './shared/Button'
import { DoctorPanel } from './doctor/DoctorPanel'
import { LazyDoctorPanel } from './doctor/LazyDoctorPanel'

type Props = {
children: React.ReactNode
Expand Down Expand Up @@ -46,7 +46,7 @@ function ErrorBoundaryFallback() {
</Button>
</div>
<div className="mt-4 text-left">
<DoctorPanel compact />
<LazyDoctorPanel compact />
</div>
</div>
</div>
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/components/chat/DiffViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { DiffViewer } from './DiffViewer'

describe('DiffViewer', () => {
it('shows the file header at once and renders the diff body after it loads on first use', async () => {
const { container } = render(
<DiffViewer
filePath="src/greeting.ts"
oldString={'const greeting = "hello"\nexport default greeting'}
newString={'const greeting = "hello, world"\nexport default greeting'}
/>,
)

expect(screen.getByText('src/greeting.ts')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(screen.getByText('-1')).toBeInTheDocument()
expect(screen.getByRole('status', { name: 'Loading diff' })).toHaveStyle({ height: '60px' })

const body = container.querySelector('.max-h-\\[400px\\]') as HTMLElement
await within(body).findByText('world', { exact: false })
expect(screen.queryByRole('status', { name: 'Loading diff' })).not.toBeInTheDocument()
})
})
135 changes: 19 additions & 116 deletions desktop/src/components/chat/DiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,23 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { lazy, Suspense } from 'react'
import { CopyButton } from '../shared/CopyButton'

const DiffViewerBody = lazy(() => import('./DiffViewerBody'))

const DIFF_ROW_HEIGHT_PX = 20
const DIFF_MAX_HEIGHT_PX = 400

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'
}

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
const placeholderHeight = Math.min(DIFF_MAX_HEIGHT_PX, (oldLines.length + additions) * DIFF_ROW_HEIGHT_PX)

return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
Expand All @@ -138,16 +39,18 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
</div>

<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'}
/>
<Suspense
fallback={
<div
role="status"
aria-label="Loading diff"
className="bg-[var(--color-code-bg)]"
style={{ height: placeholderHeight }}
/>
}
>
<DiffViewerBody filePath={filePath} oldString={oldString} newString={newString} />
</Suspense>
</div>
</div>
)
Expand Down
127 changes: 127 additions & 0 deletions desktop/src/components/chat/DiffViewerBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'

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'
}

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 default function DiffViewerBody({ filePath, oldString, newString }: Props) {
const language = inferLanguage(filePath)

return (
<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'}
/>
)
}
Loading
Loading