feat: add image preview support in Diff tab and improve diff view visuals

This commit is contained in:
Bohdan Triapitsyn
2025-12-17 14:10:23 +02:00
parent 2d048624bc
commit 34353e1970
11 changed files with 309 additions and 13 deletions
@@ -4,7 +4,7 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { getToolMetadata, getLanguageFromExtension } from '@/lib/toolHelpers';
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk';
import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -445,6 +445,46 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
);
};
interface ImagePreviewProps {
content: string;
filePath: string;
displayPath: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = ({ content, filePath, displayPath }) => {
const mimeType = getImageMimeType(filePath);
const isSvg = filePath.toLowerCase().endsWith('.svg');
// For SVG, content might be raw XML, otherwise assume base64
const imageSrc = React.useMemo(() => {
if (isSvg && !content.startsWith('data:')) {
// Raw SVG content
return `data:image/svg+xml;base64,${btoa(content)}`;
}
if (content.startsWith('data:')) {
return content;
}
// Assume base64 encoded
return `data:${mimeType};base64,${content}`;
}, [content, mimeType, isSvg]);
return (
<div className="w-full min-w-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border border-border/10 rounded-lg mb-2">
{displayPath}
</div>
<div className="flex justify-center p-4 bg-muted/10 rounded-lg border border-border/10">
<img
src={imageSrc}
alt={displayPath}
className="max-w-full max-h-96 object-contain rounded"
style={{ imageRendering: 'auto' }}
/>
</div>
</div>
);
};
interface ToolExpandedContentProps {
part: ToolPartType;
state: ToolStateUnion;
@@ -489,6 +529,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
: null
: null;
const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent;
const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false;
const writeDisplayPath = shouldShowWriteInputPreview
? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file')
: null;
@@ -724,7 +765,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
renderResultContent()
) : (
<>
{shouldShowWriteInputPreview ? (
{shouldShowWriteInputPreview && isWriteImageFile ? (
<div className="my-1">
{renderScrollableBlock(
<ImagePreview
content={writeInputContent as string}
filePath={writeFilePath as string}
displayPath={writeDisplayPath ?? 'New file'}
/>
)}
</div>
) : shouldShowWriteInputPreview ? (
<div className="my-1">
{renderScrollableBlock(
<WriteInputPreview
+75 -1
View File
@@ -14,7 +14,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowDownSLine } from '@remixicon/react';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
@@ -109,6 +109,68 @@ const FileSelector = React.memo<FileSelectorProps>(({
);
});
// Image diff viewer for binary image files
interface ImageDiffViewerProps {
filePath: string;
diff: DiffData;
isVisible: boolean;
renderSideBySide: boolean;
}
const ImageDiffViewer = React.memo<ImageDiffViewerProps>(({
filePath,
diff,
isVisible,
renderSideBySide,
}) => {
const hasOriginal = diff.original.length > 0;
const hasModified = diff.modified.length > 0;
if (!isVisible) {
return <div className="absolute inset-0 hidden" />;
}
// Render side-by-side or stacked based on preference
const containerClass = renderSideBySide
? 'flex flex-row gap-6 items-start justify-center h-full'
: 'flex flex-col gap-4 items-center';
const imageContainerClass = renderSideBySide
? 'flex flex-col items-center gap-2 flex-1 min-w-0 h-full'
: 'flex flex-col items-center gap-2';
return (
<div className="absolute inset-0 overflow-auto p-4" style={{ contain: 'size layout' }}>
<div className={containerClass}>
{hasOriginal && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">Original</span>
<img
src={diff.original}
alt={`Original: ${filePath}`}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
</div>
)}
{hasModified && (
<div className={imageContainerClass}>
<span className="typography-meta text-muted-foreground font-medium">
{hasOriginal ? 'Modified' : 'New'}
</span>
<img
src={diff.modified}
alt={`Modified: ${filePath}`}
className={renderSideBySide ? "max-w-full max-h-[calc(100%-2rem)] object-contain" : "max-w-full object-contain"}
style={{ imageRendering: 'auto' }}
/>
</div>
)}
</div>
</div>
);
});
// Single diff viewer instance - stays mounted
interface SingleDiffViewerProps {
filePath: string;
@@ -130,6 +192,18 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
[filePath]
);
// Check if this is an image file
if (isImageFile(filePath)) {
return (
<ImageDiffViewer
filePath={filePath}
diff={diff}
isVisible={isVisible}
renderSideBySide={renderSideBySide}
/>
);
}
// Use display:none for hidden diffs to exclude from layout calculations during resize
// This is faster for resize than visibility:hidden which keeps elements in layout flow
if (!isVisible) {
@@ -47,6 +47,27 @@ const WEBKIT_SCROLL_FIX_CSS = `
height: 24px !important;
width: 24px !important;
}
[data-separator-multi-button] {
row-gap: 0 !important;
}
[data-expand-up] {
height: 12px !important;
min-height: 12px !important;
max-height: 12px !important;
margin: 0 !important;
margin-top: 3px !important;
padding: 0 !important;
border-radius: 4px 4px 0 0 !important;
}
[data-expand-down] {
height: 12px !important;
min-height: 12px !important;
max-height: 12px !important;
margin: 0 !important;
margin-top: -3px !important;
padding: 0 !important;
border-radius: 0 0 4px 4px !important;
}
`;
// Fast cache key - use length + samples instead of full hash
+23
View File
@@ -269,6 +269,29 @@ export function getLanguageFromExtension(filePath: string): string | null {
return languageMap[ext || ''] || null;
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
export function isImageFile(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase();
return IMAGE_EXTENSIONS.includes(ext || '');
}
export function getImageMimeType(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase();
const mimeMap: Record<string, string> = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ico': 'image/x-icon',
'bmp': 'image/bmp',
'avif': 'image/avif',
};
return mimeMap[ext || ''] || 'image/png';
}
export function formatToolInput(input: Record<string, unknown>, toolName: string): string {
if (!input) return '';