diff --git a/CHANGELOG.md b/CHANGELOG.md index 792e7946..15ceaa28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 89a01341..83c8726a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # OpenChamber +[![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=github&logoColor=black&labelColor=c0c0c0&color=c0c0c0)](https://github.com/btriapitsyn/openchamber/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/btriapitsyn/openchamber?style=flat&logo=github&logoColor=black&labelColor=c0c0c0&color=c0c0c0)](https://github.com/btriapitsyn/openchamber/network/members) +[![GitHub release](https://img.shields.io/github/v/release/btriapitsyn/openchamber?style=flat&logo=github&logoColor=black&labelColor=c0c0c0&color=c0c0c0)](https://github.com/btriapitsyn/openchamber/releases/latest) +[![Created with OpenCode](https://img.shields.io/badge/created_with-OpenCode-c0c0c0?style=flat&labelColor=ff5722)](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 + + diff --git a/docs/references/chat_example.png b/docs/references/chat_example.png index 43eaca6e..d7bd18b5 100644 Binary files a/docs/references/chat_example.png and b/docs/references/chat_example.png differ diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index e014d45d..fea4ca88 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2851,6 +2851,7 @@ version = "1.2.2" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "chrono", "dirs 5.0.1", "fastrand", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index c8865716..5b49ffa3 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index a824b4e5..79a5bdf1 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -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> { + 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() } diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b41c02a6..5826badd 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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 = ({ content, syntaxTh ); }; +interface ImagePreviewProps { + content: string; + filePath: string; + displayPath: string; +} + +const ImagePreview: React.FC = ({ 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 ( +
+
+ {displayPath} +
+
+ {displayPath} +
+
+ ); +}; + interface ToolExpandedContentProps { part: ToolPartType; state: ToolStateUnion; @@ -489,6 +529,7 @@ const ToolExpandedContent: React.FC = ({ : 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 = ({ renderResultContent() ) : ( <> - {shouldShowWriteInputPreview ? ( + {shouldShowWriteInputPreview && isWriteImageFile ? ( +
+ {renderScrollableBlock( + + )} +
+ ) : shouldShowWriteInputPreview ? (
{renderScrollableBlock( (({ ); }); +// Image diff viewer for binary image files +interface ImageDiffViewerProps { + filePath: string; + diff: DiffData; + isVisible: boolean; + renderSideBySide: boolean; +} + +const ImageDiffViewer = React.memo(({ + filePath, + diff, + isVisible, + renderSideBySide, +}) => { + const hasOriginal = diff.original.length > 0; + const hasModified = diff.modified.length > 0; + + if (!isVisible) { + return
; + } + + // 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 ( +
+
+ {hasOriginal && ( +
+ Original + {`Original: +
+ )} + {hasModified && ( +
+ + {hasOriginal ? 'Modified' : 'New'} + + {`Modified: +
+ )} +
+
+ ); +}); + // Single diff viewer instance - stays mounted interface SingleDiffViewerProps { filePath: string; @@ -130,6 +192,18 @@ const SingleDiffViewer = React.memo(({ [filePath] ); + // Check if this is an image file + if (isImageFile(filePath)) { + return ( + + ); + } + // 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) { diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 2dff1c2f..60dc9daa 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -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 diff --git a/packages/ui/src/lib/toolHelpers.ts b/packages/ui/src/lib/toolHelpers.ts index acad1f6a..89bc1129 100644 --- a/packages/ui/src/lib/toolHelpers.ts +++ b/packages/ui/src/lib/toolHelpers.ts @@ -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 = { + '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, toolName: string): string { if (!input) return ''; diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 9cdb4a3d..fba5556c 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -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);