feat: add image preview support in Diff tab and improve diff view visuals
This commit is contained in:
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added image preview support in Diff tab (shows original/modified images instead of base64 code)
|
||||
- Improved diff view visuals and alligned style among different widgets
|
||||
|
||||
|
||||
## [1.2.2] - 2025-12-17
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# OpenChamber
|
||||
|
||||
[](https://github.com/btriapitsyn/openchamber/stargazers)
|
||||
[](https://github.com/btriapitsyn/openchamber/network/members)
|
||||
[](https://github.com/btriapitsyn/openchamber/releases/latest)
|
||||
[](https://opencode.ai)
|
||||
|
||||
Web and desktop interface for the [OpenCode](https://opencode.ai) AI coding agent. Works alongside the OpenCode TUI.
|
||||
|
||||
The OpenCode team is actively working on their own desktop app. I still decided to release this project as a fan-made alternative.
|
||||
@@ -114,3 +119,5 @@ Independent project, not affiliated with OpenCode team.
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 520 KiB |
Generated
+1
@@ -2851,6 +2851,7 @@ version = "1.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"fastrand",
|
||||
|
||||
@@ -49,6 +49,7 @@ tokio-util = { version = "0.7", features = ["io"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
base64 = "0.22.1"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
@@ -660,6 +660,49 @@ pub async fn get_git_diff(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif"];
|
||||
|
||||
fn is_image_file(path: &str) -> bool {
|
||||
if let Some(ext) = path.rsplit('.').next() {
|
||||
IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn get_image_mime_type(path: &str) -> &'static str {
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"webp" => "image/webp",
|
||||
"ico" => "image/x-icon",
|
||||
"bmp" => "image/bmp",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
// For binary output, we accept exit code 0 or check for actual content
|
||||
if output.status.success() || !output.stdout.is_empty() {
|
||||
Ok(output.stdout)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_git_file_diff(
|
||||
directory: String,
|
||||
@@ -667,23 +710,46 @@ pub async fn get_git_file_diff(
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<(String, String), String> {
|
||||
use tokio::fs;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
|
||||
let root = validate_git_path(&directory, state.settings())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let is_image = is_image_file(&path_str);
|
||||
let mime_type = if is_image { get_image_mime_type(&path_str) } else { "" };
|
||||
|
||||
// Original from HEAD
|
||||
let original_spec = format!("HEAD:{}", path_str);
|
||||
let original_args = vec!["show", original_spec.as_str()];
|
||||
let original = run_git_with_allowed_exit(&original_args, &root, &[0, 128])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let original = if is_image {
|
||||
// For images, get binary content and convert to data URL
|
||||
let original_spec = format!("HEAD:{}", path_str);
|
||||
match run_git_binary(&["show", &original_spec], &root).await {
|
||||
Ok(bytes) if !bytes.is_empty() => {
|
||||
format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
let original_spec = format!("HEAD:{}", path_str);
|
||||
let original_args = vec!["show", original_spec.as_str()];
|
||||
run_git_with_allowed_exit(&original_args, &root, &[0, 128])
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
// Modified from working tree (if file exists)
|
||||
let full_path = root.join(&path_str);
|
||||
let modified = if let Ok(metadata) = fs::metadata(&full_path).await {
|
||||
if metadata.is_file() {
|
||||
fs::read_to_string(&full_path).await.unwrap_or_default()
|
||||
if is_image {
|
||||
// For images, read as binary and convert to data URL
|
||||
match fs::read(&full_path).await {
|
||||
Ok(bytes) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
} else {
|
||||
fs::read_to_string(&full_path).await.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import simpleGit from 'simple-git';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const fsp = fs.promises;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function isGitRepository(directory) {
|
||||
if (!directory || !fs.existsSync(directory)) {
|
||||
@@ -305,18 +309,58 @@ export async function getDiff(directory, { path, staged = false, contextLines =
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
||||
|
||||
function isImageFile(filePath) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext || '');
|
||||
}
|
||||
|
||||
function getImageMimeType(filePath) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const mimeMap = {
|
||||
'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] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
|
||||
if (!directory || !filePath) {
|
||||
throw new Error('directory and path are required for getFileDiff');
|
||||
}
|
||||
|
||||
const git = simpleGit(directory);
|
||||
const isImage = isImageFile(filePath);
|
||||
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
||||
|
||||
let original = '';
|
||||
try {
|
||||
original = await git.show([`HEAD:${filePath}`]);
|
||||
if (isImage) {
|
||||
// For images, use git show with raw output and convert to base64
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
|
||||
cwd: directory,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB max
|
||||
});
|
||||
if (stdout && stdout.length > 0) {
|
||||
original = `data:${mimeType};base64,${stdout.toString('base64')}`;
|
||||
}
|
||||
} catch {
|
||||
original = '';
|
||||
}
|
||||
} else {
|
||||
original = await git.show([`HEAD:${filePath}`]);
|
||||
}
|
||||
} catch {
|
||||
|
||||
original = '';
|
||||
}
|
||||
|
||||
@@ -325,11 +369,16 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
||||
try {
|
||||
const stat = await fsp.stat(fullPath);
|
||||
if (stat.isFile()) {
|
||||
modified = await fsp.readFile(fullPath, 'utf8');
|
||||
if (isImage) {
|
||||
// For images, read as binary and convert to data URL
|
||||
const buffer = await fsp.readFile(fullPath);
|
||||
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
} else {
|
||||
modified = await fsp.readFile(fullPath, 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
|
||||
modified = '';
|
||||
} else {
|
||||
console.error('Failed to read modified file contents for diff:', error);
|
||||
|
||||
Reference in New Issue
Block a user