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> );};
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>
);
};