diff --git a/Cargo.lock b/Cargo.lock index 00add978..f46e0bd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -777,6 +777,7 @@ dependencies = [ name = "git-diff" version = "0.1.0" dependencies = [ + "base64", "git2", "serde", "tempfile", diff --git a/apps/differ/src-tauri/Cargo.lock b/apps/differ/src-tauri/Cargo.lock index 33efe954..286d3030 100644 --- a/apps/differ/src-tauri/Cargo.lock +++ b/apps/differ/src-tauri/Cargo.lock @@ -1055,6 +1055,7 @@ dependencies = [ name = "git-diff" version = "0.1.0" dependencies = [ + "base64 0.22.1", "git2", "serde", "thiserror 2.0.18", diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index dad1744d..c5ea59b9 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -1856,6 +1856,7 @@ dependencies = [ name = "git-diff" version = "0.1.0" dependencies = [ + "base64 0.22.1", "git2", "serde", "thiserror 2.0.18", diff --git a/apps/staged/src-tauri/src/blox.rs b/apps/staged/src-tauri/src/blox.rs index ab8ac646..157e32f1 100644 --- a/apps/staged/src-tauri/src/blox.rs +++ b/apps/staged/src-tauri/src/blox.rs @@ -49,6 +49,14 @@ pub fn ws_exec(name: &str, args: &[&str]) -> Result { blox_cli::ws_exec(name, args) } +/// Execute a command inside a Blox workspace, returning raw bytes. +/// +/// Like `ws_exec` but returns the raw stdout bytes without UTF-8 validation. +/// Use this when the command may produce binary output (e.g. `git show` on image files). +pub fn ws_exec_bytes(name: &str, args: &[&str]) -> Result, BloxError> { + blox_cli::ws_exec_bytes(name, args) +} + /// Quick authentication check — runs `sq blox ws list` and inspects the result. /// /// Returns `Ok(())` if the user appears to be authenticated, or diff --git a/apps/staged/src-tauri/src/branches.rs b/apps/staged/src-tauri/src/branches.rs index 6afd3a9e..5886cf3e 100644 --- a/apps/staged/src-tauri/src/branches.rs +++ b/apps/staged/src-tauri/src/branches.rs @@ -184,6 +184,23 @@ pub(crate) fn run_workspace_git( blox::ws_exec(workspace_name, &borrowed) } +pub(crate) fn run_workspace_git_bytes( + workspace_name: &str, + repo_subpath: Option<&str>, + git_args: &[&str], +) -> Result, blox::BloxError> { + let mut owned = Vec::::new(); + owned.push("git".to_string()); + if let Some(subpath) = repo_subpath.map(str::trim).filter(|s| !s.is_empty()) { + let resolved = resolve_workspace_repo_path(workspace_name, subpath)?; + owned.push("-C".to_string()); + owned.push(resolved); + } + owned.extend(git_args.iter().map(|arg| (*arg).to_string())); + let borrowed = owned.iter().map(String::as_str).collect::>(); + blox::ws_exec_bytes(workspace_name, &borrowed) +} + async fn run_blox_blocking(op: F) -> Result where T: Send + 'static, diff --git a/apps/staged/src-tauri/src/diff_commands.rs b/apps/staged/src-tauri/src/diff_commands.rs index 4f260bab..8664d8a5 100644 --- a/apps/staged/src-tauri/src/diff_commands.rs +++ b/apps/staged/src-tauri/src/diff_commands.rs @@ -61,6 +61,15 @@ fn run_remote_git(ctx: &BranchDiffContext, args: &[&str]) -> Result Result, String> { + let workspace = ctx + .workspace_name + .as_deref() + .ok_or("Missing remote workspace context")?; + branches::run_workspace_git_bytes(workspace, ctx.repo_subpath.as_deref(), args) + .map_err(|e| e.to_string()) +} + /// Build a DiffSpec for a branch diff. /// /// - Branch scope with no commit_sha: merge-base(base, tip)..tip @@ -241,15 +250,49 @@ fn parse_unified_hunks(diff_text: &str) -> Vec { hunks } -fn file_content_from_text(text: &str) -> git::FileContent { - if text.as_bytes()[..text.len().min(8192)].contains(&0) { - return git::FileContent::Binary; +fn file_content_from_bytes(bytes: &[u8], path: &str) -> git::FileContent { + let check_len = bytes.len().min(8192); + if bytes[..check_len].contains(&0) { + return file_content_binary_or_image(bytes, path); } + let text = String::from_utf8_lossy(bytes); git::FileContent::Text { lines: text.lines().map(|line| line.to_string()).collect(), } } +/// For binary content in the remote path, try to produce an ImageBase64 variant. +fn file_content_binary_or_image(bytes: &[u8], path: &str) -> git::FileContent { + if bytes.len() > git::IMAGE_PREVIEW_MAX_BYTES { + return git::FileContent::Binary; + } + + let file_path = std::path::Path::new(path); + let ext = file_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + + let mime = match ext.as_deref() { + Some("png") => Some("image/png"), + Some("jpg" | "jpeg") => Some("image/jpeg"), + Some("gif") => Some("image/gif"), + Some("webp") => Some("image/webp"), + _ => None, + }; + + if let Some(mime) = mime { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + git::FileContent::ImageBase64 { + mime_type: mime.to_string(), + data, + } + } else { + git::FileContent::Binary + } +} + fn is_missing_object_error(msg: &str) -> bool { let lower = msg.to_lowercase(); lower.contains("not a valid object name") @@ -259,11 +302,6 @@ fn is_missing_object_error(msg: &str) -> bool { || lower.contains("path '") } -fn is_utf8_parse_error(msg: &str) -> bool { - msg.to_lowercase() - .contains("invalid utf-8 in sq blox output") -} - fn load_remote_file_at_ref( ctx: &BranchDiffContext, ref_name: &str, @@ -277,15 +315,14 @@ fn load_remote_file_at_ref( Err(e) => return Err(e), } - match run_remote_git(ctx, &["show", &spec]) { - Ok(content) => Ok(Some(git::File { - path: path.to_string(), - content: file_content_from_text(&content), - })), - Err(e) if is_utf8_parse_error(&e) => Ok(Some(git::File { - path: path.to_string(), - content: git::FileContent::Binary, - })), + match run_remote_git_bytes(ctx, &["show", &spec]) { + Ok(bytes) => { + let content = file_content_from_bytes(&bytes, path); + Ok(Some(git::File { + path: path.to_string(), + content, + })) + } Err(e) => Err(e), } } diff --git a/crates/blox-cli/src/lib.rs b/crates/blox-cli/src/lib.rs index 0e778d55..321d574b 100644 --- a/crates/blox-cli/src/lib.rs +++ b/crates/blox-cli/src/lib.rs @@ -194,8 +194,8 @@ fn is_auth_error(stderr: &str) -> bool { || lower.contains("401") } -/// Run `sq blox ` and return stdout as a string. -fn run(args: &[&str], timeout: Duration) -> Result { +/// Run `sq blox ` and return stdout as raw bytes. +fn run_bytes(args: &[&str], timeout: Duration) -> Result, BloxError> { let sq = sq_binary()?; let mut full_args = vec!["blox"]; @@ -255,7 +255,13 @@ fn run(args: &[&str], timeout: Duration) -> Result { return Err(BloxError::CommandFailed(stderr.into_owned())); } - String::from_utf8(stdout) + Ok(stdout) +} + +/// Run `sq blox ` and return stdout as a string. +fn run(args: &[&str], timeout: Duration) -> Result { + let bytes = run_bytes(args, timeout)?; + String::from_utf8(bytes) .map_err(|e| BloxError::ParseError(format!("invalid UTF-8 in sq blox output: {e}"))) } @@ -338,6 +344,16 @@ pub fn ws_exec(name: &str, args: &[&str]) -> Result { run(&full_args, EXEC_TIMEOUT) } +/// Execute a command inside a Blox workspace, returning raw bytes. +/// +/// Like `ws_exec` but returns the raw stdout bytes without UTF-8 validation. +/// Use this when the command may produce binary output (e.g. `git show` on image files). +pub fn ws_exec_bytes(name: &str, args: &[&str]) -> Result, BloxError> { + let mut full_args = vec!["ws", "exec", name, "--"]; + full_args.extend_from_slice(args); + run_bytes(&full_args, EXEC_TIMEOUT) +} + /// Quick authentication check — runs `sq blox ws list` and inspects the result. /// /// Returns `Ok(())` if the user appears to be authenticated, or diff --git a/crates/git-diff/Cargo.toml b/crates/git-diff/Cargo.toml index bf744749..c87891d4 100644 --- a/crates/git-diff/Cargo.toml +++ b/crates/git-diff/Cargo.toml @@ -8,6 +8,7 @@ description = "Git diff computation: file listing, content diffing, and alignmen serde = { version = "1.0", features = ["derive"] } thiserror = "2.0" git2 = { version = "0.20", features = ["vendored-openssl"] } +base64 = "0.22" [dev-dependencies] tempfile = "3.0" diff --git a/crates/git-diff/src/diff.rs b/crates/git-diff/src/diff.rs index 5f192ad0..8b780704 100644 --- a/crates/git-diff/src/diff.rs +++ b/crates/git-diff/src/diff.rs @@ -420,7 +420,7 @@ fn load_file_from_tree( None => return Ok(None), // Not a file (maybe a submodule) }; - let content = bytes_to_content(blob.content()); + let content = bytes_to_content(blob.content(), path); Ok(Some(File { path: path.to_string_lossy().to_string(), @@ -443,7 +443,7 @@ fn load_file_from_index(repo: &Repository, path: &Path) -> Result, .find_blob(entry.id) .map_err(|e| GitError::CommandFailed(format!("Cannot load blob from index: {e}")))?; - let content = bytes_to_content(blob.content()); + let content = bytes_to_content(blob.content(), path); Ok(Some(File { path: path.to_string_lossy().to_string(), @@ -472,16 +472,19 @@ fn load_file_from_workdir(repo: &Repository, path: &Path) -> Result Ok(Some(File { path: path.to_string_lossy().to_string(), - content: bytes_to_content(&bytes), + content: bytes_to_content(&bytes, path), })) } -/// Convert raw bytes to FileContent, detecting binary -fn bytes_to_content(bytes: &[u8]) -> FileContent { +/// Convert raw bytes to FileContent, detecting binary and images. +/// +/// When binary content is detected and the file path has a known image extension, +/// the bytes are base64-encoded and returned as `ImageBase64` (up to 5 MB). +fn bytes_to_content(bytes: &[u8], path: &Path) -> FileContent { // Check for binary: look for null bytes in first 8KB let check_len = bytes.len().min(8192); if bytes[..check_len].contains(&0) { - return FileContent::Binary; + return bytes_to_binary_or_image(bytes, path); } // Parse as UTF-8 (lossy for display) @@ -490,6 +493,36 @@ fn bytes_to_content(bytes: &[u8]) -> FileContent { FileContent::Text { lines } } +/// For binary content, check if it's a previewable image and base64-encode it. +fn bytes_to_binary_or_image(bytes: &[u8], path: &Path) -> FileContent { + if bytes.len() > IMAGE_PREVIEW_MAX_BYTES { + return FileContent::Binary; + } + + if let Some(mime) = image_mime_for_path(path) { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + FileContent::ImageBase64 { + mime_type: mime.to_string(), + data, + } + } else { + FileContent::Binary + } +} + +/// Return the MIME type if the path has a known image extension. +fn image_mime_for_path(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + match ext.as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + } +} + /// Get hunks for a single file using libgit2 fn get_hunks_libgit2( repo: &Repository, diff --git a/crates/git-diff/src/files.rs b/crates/git-diff/src/files.rs index e20cce4c..c28ce2c3 100644 --- a/crates/git-diff/src/files.rs +++ b/crates/git-diff/src/files.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::cli::{self, GitError}; -use crate::types::{File, FileContent, WORKDIR}; +use crate::types::{File, FileContent, IMAGE_PREVIEW_MAX_BYTES, WORKDIR}; /// Search for files matching a query in the repository at a given ref. /// @@ -149,7 +149,7 @@ pub fn get_file_at_ref(repo: &Path, ref_name: &str, path: &str) -> Result Result bool { data[..check_len].contains(&0) } +/// For binary content, check if it's a previewable image and base64-encode it. +fn bytes_to_image_or_binary(bytes: &[u8], path: &str) -> FileContent { + if bytes.len() > IMAGE_PREVIEW_MAX_BYTES { + return FileContent::Binary; + } + + let file_path = std::path::Path::new(path); + let ext = file_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + + let mime = match ext.as_deref() { + Some("png") => Some("image/png"), + Some("jpg" | "jpeg") => Some("image/jpeg"), + Some("gif") => Some("image/gif"), + Some("webp") => Some("image/webp"), + _ => None, + }; + + if let Some(mime) = mime { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + FileContent::ImageBase64 { + mime_type: mime.to_string(), + data, + } + } else { + FileContent::Binary + } +} + /// Convert text to FileContent with lines fn text_to_content(text: &str) -> FileContent { let lines: Vec = text.lines().map(|s| s.to_string()).collect(); diff --git a/crates/git-diff/src/types.rs b/crates/git-diff/src/types.rs index bcff90ae..8b067c68 100644 --- a/crates/git-diff/src/types.rs +++ b/crates/git-diff/src/types.rs @@ -104,12 +104,26 @@ impl Span { } } -/// Content of a file - either text lines or binary marker +/// Maximum file size (in bytes) for image preview. Files larger than this +/// fall back to the generic binary notice to avoid sending huge payloads. +pub const IMAGE_PREVIEW_MAX_BYTES: usize = 5 * 1024 * 1024; // 5 MB + +/// Image extensions eligible for inline base64 preview. +pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; + +/// Content of a file - either text lines, binary marker, or base64-encoded image #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum FileContent { - Text { lines: Vec }, + Text { + lines: Vec, + }, Binary, + ImageBase64 { + #[serde(rename = "mimeType")] + mime_type: String, + data: String, + }, } /// A file with its path and content diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte index 035beb64..e247880c 100644 --- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte +++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte @@ -38,8 +38,10 @@ getLanguageFromDiff, getFilePath, isBinaryDiff, + isImageDiff, getTextLines, } from '../utils/diffUtils'; + import ImageDiffViewer from './ImageDiffViewer.svelte'; import type { SearchState, FileSearchResult } from '../state/searchState.svelte'; import type { SearchMatch, MatchLocation } from '../utils/diffSearch'; import { @@ -237,6 +239,7 @@ let isDeletedFile = $derived(diff !== null && !isEmptyDiff && diff.after === null); let isTwoPaneMode = $derived(!isNewFile && !isDeletedFile); let isBinary = $derived(diff !== null && isBinaryDiff(diff)); + let isImage = $derived(diff !== null && isImageDiff(diff)); // Extract lines from the diff let beforeLines = $derived(diff ? getTextLines(diff, 'before') : []); @@ -1659,6 +1662,15 @@

File not found in this diff

+ {:else if isImage} + {:else if isBinary}

Binary file - cannot display diff

diff --git a/packages/diff-viewer/src/lib/components/ImageDiffViewer.svelte b/packages/diff-viewer/src/lib/components/ImageDiffViewer.svelte new file mode 100644 index 00000000..fa119c66 --- /dev/null +++ b/packages/diff-viewer/src/lib/components/ImageDiffViewer.svelte @@ -0,0 +1,476 @@ + + + +
+ {#if beforeSrc && afterSrc} +
+ + + +
+ {/if} + +
+ {#if mode === 'classic'} +
+
+ {#if beforeSrc} + Before + {:else} +
No previous version
+ {/if} +
+
+ {#if afterSrc} + After + {:else} +
File deleted
+ {/if} +
+
+ {:else if mode === 'highlight'} +
+ {#if !beforeSrc || !afterSrc} +
+ {#if beforeSrc} + Before (no comparison available) + {:else if afterSrc} + After (no comparison available) + {/if} +

No comparison available

+
+ {:else} + + {/if} +
+ {:else if mode === 'slider'} +
+ {#if sharedWidth > 0 && sharedHeight > 0} +
+ {#if afterSrc} + After + {/if} + {#if beforeSrc} + Before + {/if} +
+
+
+
+ {/if} +
+ {/if} +
+
+ + diff --git a/packages/diff-viewer/src/lib/components/index.ts b/packages/diff-viewer/src/lib/components/index.ts index 4e1a13ec..237a0b6f 100644 --- a/packages/diff-viewer/src/lib/components/index.ts +++ b/packages/diff-viewer/src/lib/components/index.ts @@ -1,4 +1,5 @@ export { default as DiffViewer } from './DiffViewer.svelte'; +export { default as ImageDiffViewer } from './ImageDiffViewer.svelte'; export { default as CommentEditor } from './CommentEditor.svelte'; export { default as AnnotationOverlay } from './AnnotationOverlay.svelte'; export { default as BeforeAnnotationOverlay } from './BeforeAnnotationOverlay.svelte'; diff --git a/packages/diff-viewer/src/lib/state/searchState.svelte.ts b/packages/diff-viewer/src/lib/state/searchState.svelte.ts index f5b734a7..935fa58f 100644 --- a/packages/diff-viewer/src/lib/state/searchState.svelte.ts +++ b/packages/diff-viewer/src/lib/state/searchState.svelte.ts @@ -65,7 +65,7 @@ function getTextLines(diff: FileDiff, side: 'before' | 'after'): string[] { if (!file) return []; const content = file.content; - if (content.type === 'Binary') return []; + if (content.type !== 'Text') return []; return content.lines; } diff --git a/packages/diff-viewer/src/lib/types.ts b/packages/diff-viewer/src/lib/types.ts index a1651826..adfec950 100644 --- a/packages/diff-viewer/src/lib/types.ts +++ b/packages/diff-viewer/src/lib/types.ts @@ -15,8 +15,11 @@ export interface Span { end: number; } -/** Content of a file — either text lines or binary marker. */ -export type FileContent = { type: 'Text'; lines: string[] } | { type: 'Binary' }; +/** Content of a file — text lines, binary marker, or base64-encoded image. */ +export type FileContent = + | { type: 'Text'; lines: string[] } + | { type: 'Binary' } + | { type: 'ImageBase64'; mimeType: string; data: string }; /** A file with its path and content. */ export interface File { diff --git a/packages/diff-viewer/src/lib/utils/diffUtils.ts b/packages/diff-viewer/src/lib/utils/diffUtils.ts index 4135117e..ac01d463 100644 --- a/packages/diff-viewer/src/lib/utils/diffUtils.ts +++ b/packages/diff-viewer/src/lib/utils/diffUtils.ts @@ -78,7 +78,7 @@ export function getLanguageFromDiff( } /** - * Check if a diff represents a binary file. + * Check if a diff represents a binary file (excludes images). */ export function isBinaryDiff(diff: FileDiff): boolean { const beforeBinary = diff.before?.content.type === 'Binary'; @@ -87,11 +87,20 @@ export function isBinaryDiff(diff: FileDiff): boolean { } /** - * Get text lines from a file, or empty array if binary/null. + * Check if a diff represents an image file. + */ +export function isImageDiff(diff: FileDiff): boolean { + const beforeImage = diff.before?.content.type === 'ImageBase64'; + const afterImage = diff.after?.content.type === 'ImageBase64'; + return beforeImage || afterImage; +} + +/** + * Get text lines from a file, or empty array if binary/image/null. */ export function getTextLines(diff: FileDiff, side: 'before' | 'after'): string[] { const file = side === 'before' ? diff.before : diff.after; - if (!file || file.content.type === 'Binary') return []; + if (!file || file.content.type !== 'Text') return []; return file.content.lines; } diff --git a/packages/diff-viewer/src/lib/utils/index.ts b/packages/diff-viewer/src/lib/utils/index.ts index 1c322f76..f662b619 100644 --- a/packages/diff-viewer/src/lib/utils/index.ts +++ b/packages/diff-viewer/src/lib/utils/index.ts @@ -4,6 +4,7 @@ export { getLineBoundary, getLanguageFromDiff, isBinaryDiff, + isImageDiff, getTextLines, referenceFileAsDiff, } from './diffUtils';