agent-ecosystem/src/renderer/components/chat/viewers/CodeBlockViewer.tsx

244 lines
6.8 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { getBaseName } from '@renderer/utils/pathUtils';
import { createLogger } from '@shared/utils/logger';
import { Check, Copy, FileCode } from 'lucide-react';
const logger = createLogger('Component:CodeBlockViewer');
import { highlightLine } from './syntaxHighlighter';
// =============================================================================
// Types
// =============================================================================
interface CodeBlockViewerProps {
fileName: string; // e.g., "src/components/Header.tsx"
content: string; // The actual file content
language?: string; // Inferred from file extension if not provided
startLine?: number; // If partial read, starting line
endLine?: number; // If partial read, ending line
maxHeight?: string; // CSS max-height class (default: "max-h-96")
}
// =============================================================================
// Language Detection
// =============================================================================
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
// JavaScript/TypeScript
'.ts': 'typescript',
'.tsx': 'tsx',
'.js': 'javascript',
'.jsx': 'jsx',
'.mjs': 'javascript',
'.cjs': 'javascript',
// Python
'.py': 'python',
'.pyw': 'python',
'.pyx': 'python',
// Web
'.html': 'html',
'.htm': 'html',
'.css': 'css',
'.scss': 'scss',
'.sass': 'sass',
'.less': 'less',
// Data formats
'.json': 'json',
'.jsonl': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.xml': 'xml',
// Shell
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'zsh',
'.fish': 'fish',
// Systems
'.rs': 'rust',
'.go': 'go',
'.c': 'c',
'.h': 'c',
'.cpp': 'cpp',
'.cc': 'cpp',
'.hpp': 'hpp',
'.java': 'java',
'.kt': 'kotlin',
'.swift': 'swift',
// Config
'.env': 'env',
'.gitignore': 'gitignore',
'.dockerignore': 'dockerignore',
'.md': 'markdown',
'.mdx': 'mdx',
// Other
'.sql': 'sql',
'.graphql': 'graphql',
'.gql': 'graphql',
'.vue': 'vue',
'.svelte': 'svelte',
'.rb': 'ruby',
'.php': 'php',
'.lua': 'lua',
'.r': 'r',
'.R': 'r',
};
/**
* Infer language from file name/extension.
*/
function inferLanguage(fileName: string): string {
// Check for dotfiles with specific names
const baseName = getBaseName(fileName);
if (baseName === 'Dockerfile') return 'dockerfile';
if (baseName === 'Makefile') return 'makefile';
if (baseName.startsWith('.env')) return 'env';
// Extract extension
const extMatch = /(\.[^./]+)$/.exec(fileName);
if (extMatch) {
const ext = extMatch[1].toLowerCase();
return EXTENSION_LANGUAGE_MAP[ext] ?? 'text';
}
return 'text';
}
// =============================================================================
// Component
// =============================================================================
export const CodeBlockViewer: React.FC<CodeBlockViewerProps> = ({
fileName,
content,
language,
startLine = 1,
endLine,
maxHeight = 'max-h-96',
}): React.JSX.Element => {
const [isCopied, setIsCopied] = useState(false);
// Infer language from file extension if not provided
const detectedLanguage = language ?? inferLanguage(fileName);
// Split content into lines
const lines = useMemo(() => content.split('\n'), [content]);
const totalLines = lines.length;
// Calculate the actual line range for display
const actualEndLine = endLine ?? startLine + totalLines - 1;
// Handle copy
const handleCopy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch {
logger.error('Failed to copy to clipboard');
}
};
// Extract just the filename for display
const displayFileName = getBaseName(fileName) || fileName;
return (
<div
className="overflow-hidden rounded-lg shadow-sm"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-3 py-2"
style={{
backgroundColor: 'var(--code-header-bg)',
borderBottom: '1px solid var(--code-border)',
}}
>
<div className="flex min-w-0 items-center gap-2">
<FileCode className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
<span
className="truncate font-mono text-sm"
title={fileName}
style={{ color: 'var(--code-filename)' }}
>
{displayFileName}
</span>
{(startLine > 1 || endLine) && (
<span className="shrink-0 text-xs" style={{ color: 'var(--color-text-muted)' }}>
(lines {startLine}-{actualEndLine})
</span>
)}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-xs"
style={{
backgroundColor: 'var(--tag-bg)',
color: 'var(--tag-text)',
border: '1px solid var(--tag-border)',
}}
>
{detectedLanguage}
</span>
</div>
{/* Copy button */}
<button
onClick={handleCopy}
className="rounded p-1 transition-colors hover:opacity-80"
title="Copy to clipboard"
style={{ backgroundColor: 'transparent' }}
>
{isCopied ? (
<Check className="size-4" style={{ color: 'var(--badge-success-bg)' }} />
) : (
<Copy className="size-4" style={{ color: 'var(--color-text-muted)' }} />
)}
</button>
</div>
{/* Code content */}
<div className={`overflow-auto ${maxHeight}`}>
<pre className="m-0 bg-transparent p-0">
<code className="block font-mono text-xs leading-relaxed">
{lines.map((line, index) => {
const lineNumber = startLine + index;
return (
<div key={index} className="flex hover:bg-[var(--color-surface-overlay)]">
{/* Line number */}
<span
className="w-12 shrink-0 select-none px-3 py-0.5 text-right"
style={{
color: 'var(--code-line-number)',
borderRight: '1px solid var(--code-border)',
}}
>
{lineNumber}
</span>
{/* Code line */}
<span
className="flex-1 whitespace-pre px-4 py-0.5"
style={{ color: 'var(--color-text)' }}
>
{highlightLine(line, detectedLanguage)}
</span>
</div>
);
})}
</code>
</pre>
</div>
</div>
);
};