Files
OpenQode/bin/ui/components/CodeCard.mjs

121 lines
4.7 KiB
JavaScript

/**
* CodeCard Component (SnippetBlock)
*
* Renders code blocks with a Discord-style header and Google-style friendly paths.
* Supports syntax highlighting via ink-markdown and smart collapsing.
*/
import React, { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import Markdown from '../../ink-markdown-esm.mjs';
import path from 'path';
const h = React.createElement;
export const CodeCard = ({ language, filename, content, width, isStreaming, project }) => {
const lineCount = content ? content.split('\n').length : 0;
const [isExpanded, setIsExpanded] = useState(false);
// Calculate safe content width accounting for spacing
const contentWidth = width ? width - 4 : 60; // Account for left gutter (2) and spacing (2)
// SMART PATH RESOLUTION
// Resolve the display path relative to the project root for a "Friendly" view
const displayPath = useMemo(() => {
if (!filename || filename === 'snippet.txt') return { dir: '', base: filename || 'snippet' };
// If we have a project root, try to resolve relative path
if (project && filename) {
try {
// If it's absolute, make it relative to project
if (path.isAbsolute(filename)) {
const rel = path.relative(project, filename);
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
return { dir: path.dirname(rel), base: path.basename(rel) };
}
}
// If it's already relative (likely from AI response like 'src/index.js')
// Check if it has directory limits
if (filename.includes('/') || filename.includes('\\')) {
return { dir: path.dirname(filename), base: path.basename(filename) };
}
} catch (e) { /* ignore path errors */ }
}
return { dir: '', base: filename };
}, [filename, project]);
// Determine if we should show the expand/collapse functionality
// Smart Streaming Tail: If streaming and very long, collapse middle to show progress
const STREAMING_MAX_LINES = 20;
const STATIC_MAX_LINES = 10;
// Always allow expansion if long enough
const isLong = lineCount > (isStreaming ? STREAMING_MAX_LINES : STATIC_MAX_LINES);
const renderContent = () => {
if (isExpanded || !isLong) {
return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language || ''}\n${content}\n\`\`\``);
}
const lines = content.split('\n');
// Collapsed Logic
let firstLines, lastLines, hiddenCount;
if (isStreaming) {
// Streaming Mode: Show Head + Active Tail
// This ensures user sees the code BEING written
firstLines = lines.slice(0, 5).join('\n');
lastLines = lines.slice(-10).join('\n'); // Show last 10 lines for context
hiddenCount = lineCount - 15;
} else {
// Static Mode: Show Head + Foot
firstLines = lines.slice(0, 5).join('\n');
lastLines = lines.slice(-3).join('\n');
hiddenCount = lineCount - 8;
}
const previewContent = `${firstLines}\n\n// ... (${hiddenCount} lines hidden) ...\n\n${lastLines}`;
return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language || ''}\n${previewContent}\n\`\`\``);
};
return h(Box, {
flexDirection: 'column',
width: width,
marginLeft: 2,
marginBottom: 1
},
// SMART HEADER with Friendly Path
h(Box, {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 0.5
},
h(Box, { flexDirection: 'row' },
displayPath.dir && displayPath.dir !== '.' ?
h(Text, { color: 'gray', dimColor: true }, `📂 ${displayPath.dir} / `) : null,
h(Text, { color: 'cyan', bold: true }, `📄 ${displayPath.base}`),
h(Text, { color: 'gray', dimColor: true }, ` (${language})`)
),
h(Text, { color: 'gray', dimColor: true }, `${lineCount} lines`)
),
// Content area - no borders
h(Box, {
borderStyle: 'single',
borderColor: 'gray',
padding: 1
},
renderContent()
),
// Expand/collapse control
isLong ? h(Box, {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 0.5
},
h(Text, { color: 'cyan', dimColor: true }, isExpanded ? '▼ collapse' : (isStreaming ? '▼ auto-scroll (expand to view all)' : '▶ expand'))
) : null
);
};