codev / desktop /src /components /workspace /WorkspaceCodeSurface.tsx
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame
7.3 kB
import { useEffect, useState } from 'react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { useTranslation } from '../../i18n'
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
export const workspacePrismTheme: PrismTheme = {
plain: {
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ 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: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
export function getFileExtension(name: string) {
const cleanName = name.split('/').pop() ?? name
const lastDot = cleanName.lastIndexOf('.')
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
return cleanName.slice(lastDot + 1).toLowerCase()
}
export function normalizePrismLanguage(language: string) {
const lower = language.toLowerCase()
const map: Record<string, string> = {
text: 'text',
typescript: 'typescript',
ts: 'typescript',
tsx: 'tsx',
javascript: 'javascript',
js: 'javascript',
jsx: 'jsx',
markdown: 'markdown',
md: 'markdown',
html: 'markup',
xml: 'markup',
shell: 'bash',
sh: 'bash',
zsh: 'bash',
diff: 'diff',
}
return map[lower] ?? lower
}
export function getLanguageFromPath(path: string) {
return normalizePrismLanguage(getFileExtension(path) || 'text')
}
export function InlineHighlightedCode({
value,
language,
}: {
value: string
language: string
}) {
return (
<Highlight
theme={workspacePrismTheme}
code={value}
language={normalizePrismLanguage(language)}
>
{({ tokens, getTokenProps }) => (
<>
{(tokens[0] ?? []).map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</>
)}
</Highlight>
)
}
export function WorkspaceDiffSurface({
value,
path,
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
}: {
value: string
path: string
className?: string
lineLimit?: number
}) {
const t = useTranslation()
const [showAllLines, setShowAllLines] = useState(false)
const lines = value.split('\n')
const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
const language = getLanguageFromPath(path)
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
useEffect(() => {
setShowAllLines(false)
}, [path, value])
return (
<div className={className}>
<div className="relative min-w-max py-2">
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
>
{visibleLines.map((line, index) => {
const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
const isHunk = line.startsWith('@@')
const isAdded = line.startsWith('+') && !line.startsWith('+++')
const isRemoved = line.startsWith('-') && !line.startsWith('---')
const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
const code = isCodeLine ? line.slice(1) : line
const prefix = isCodeLine ? line[0] : ' '
return (
<div
key={index}
className={`grid min-w-full w-max grid-cols-[48px_18px_max-content] gap-2 px-3 ${
isAdded
? 'bg-[var(--color-diff-added-bg)]'
: isRemoved
? 'bg-[var(--color-diff-removed-bg)]'
: isHunk
? 'bg-[var(--color-diff-highlight-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
{index + 1}
</span>
<span
className={`select-none text-center ${
isAdded
? 'text-[var(--color-diff-added-text)]'
: isRemoved
? 'text-[var(--color-diff-removed-text)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{prefix}
</span>
<span
className={`whitespace-pre pr-6 ${
isFileHeader
? 'font-semibold text-[var(--color-text-secondary)]'
: isHunk
? 'font-semibold text-[var(--color-warning)]'
: ''
}`}
>
{isCodeLine && !usePlainLargePreview ? (
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
) : (
code || ' '
)}
</span>
</div>
)
})}
</pre>
{lines.length > lineLimit && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
<span>
{showAllLines
? t('workspace.previewAllLines', { total: lines.length })
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
</span>
<button
type="button"
onClick={() => setShowAllLines((current) => !current)}
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>
</div>
)
}