From 37fc1dc626c332a732dc23e0379bd0e8f46d4a95 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos <62979495+Bobbybear007@users.noreply.github.com> Date: Wed, 13 May 2026 18:28:48 +1200 Subject: [PATCH] Show inline image previews for binary files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add inline image preview support for binary image files in diffs and the file viewer. Frontend: new CSS rules for diff/viewer image panels and macOS window radius, JS templates to render base64 image previews and wire previewMime/previewBase64 into rendering flow, plus applyPlatformChrome to set platform-specific chrome. Backend (Tauri): add base64 and ico deps and expose image preview data (mime + base64) on GitFileDiff and LocalRepoFile; implement mime detection, size-limited preview generation (5MB), .ico → PNG extraction, and integration into get_file_diff and local_repo_file to return previews when available. Also updates icon asset. --- frontend/assets/icons/GitpubDesktop-Icon.ico | Bin 417014 -> 417014 bytes frontend/css/components.css | 66 +++++++- frontend/js/app.js | 28 ++++ src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 154 ++++++++++++++++++- 6 files changed, 245 insertions(+), 7 deletions(-) diff --git a/frontend/assets/icons/GitpubDesktop-Icon.ico b/frontend/assets/icons/GitpubDesktop-Icon.ico index f383dc65babb3694e9d2de24b4771ced0b8a4c6c..0c731242c80d26f2667dbc77150f6b8152ab9b23 100644 GIT binary patch delta 143 zcmeyiQ}WwR$uI^6Mg~>}5MX3bU~rIUWB>|*0f?`mz``&MDAv#bk=Nj0WY}lV%AlYC z;ja*2V0h-v#vlNsLFzMD7#R8@*cjTk*|KimX3ORv-`*$72*gZ4%nZaV+xui$H58iD VM7O7jGU{+_KE$#@U}IDY3jlvt95?^~ delta 144 zcmeyiQ}WwR$uI^6Mg~>}0Ra%Lz>vYhzyK6tP*8yIR{+KL0mT{`AbbrDMus#|paLKO zsn<|oVd#@(g!3Kb85y?OvNCM%i(t!O+5XI(ZH++tA$!*ChwRxjINE2)GXgOa5HkZY S%l29FtSSnPVOzr(9pnL@793vy diff --git a/frontend/css/components.css b/frontend/css/components.css index 60a160e..95b3ab5 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -4,10 +4,13 @@ width: 100%; height: 100%; overflow: hidden; - border-radius: var(--radius-window); background: var(--bg-app); } +#app[data-platform="macos"] { + border-radius: var(--radius-window); +} + .layout { display: grid; grid-template-rows: 48px 1fr; @@ -998,6 +1001,67 @@ .diff-line-hunk { color: var(--accent); background: var(--accent-subtle); } .diff-line-meta { color: var(--text-muted); background: rgba(156, 166, 181, 0.06); } +.diff-binary-image-panel { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 200px; + padding: 12px; +} + +.diff-image-preview-frame { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 12px; + border-radius: var(--radius-md); + background: rgba(0, 0, 0, 0.12); +} + +.diff-image-preview { + min-width: 96px; + min-height: 96px; + max-width: 100%; + max-height: min(70vh, 720px); + object-fit: contain; + border-radius: var(--radius-md); +} + +.diff-binary-caption { + margin: 0; + padding: 12px; + font-family: ui-monospace, "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 11px; + line-height: 1.5; + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: rgba(0, 0, 0, 0.15); +} + +.viewer-image-preview-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-code); +} + +.viewer-image-preview { + min-width: 96px; + min-height: 96px; + max-width: 100%; + max-height: min(75vh, 800px); + object-fit: contain; + border-radius: var(--radius-md); +} + /* ── Workflow empty state ─────────────────────────────────────────────────── */ .workflow-empty-state { diff --git a/frontend/js/app.js b/frontend/js/app.js index c89bfef..a47d7ed 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -130,6 +130,10 @@ function currentPlatform() { return "linux"; } +function applyPlatformChrome() { + appRoot.dataset.platform = currentPlatform(); +} + function currentTauriWindow() { const tauriWindow = window.__TAURI__?.window; if (tauriWindow?.getCurrentWindow) { @@ -443,6 +447,18 @@ function groupedChangedFiles(files = []) { return order.map((status) => ({ status, files: groups.get(status) })).filter((group) => group.files.length); } +function diffBinaryImagePreviewTemplate(diffResult) { + const mime = escapeHtml(diffResult.previewMime || ""); + const b64 = diffResult.previewBase64 || ""; + const caption = diffResult.diff ? escapeHtml(diffResult.diff) : ""; + return `
+
+ +
+ ${caption ? `
${caption}
` : ""} +
`; +} + function diffTemplate(diffResult) { if (!diffResult) { return workflowEmptyStateTemplate({ @@ -452,6 +468,9 @@ function diffTemplate(diffResult) { actions: false, }); } + if (diffResult.previewBase64 && diffResult.previewMime) { + return diffBinaryImagePreviewTemplate(diffResult); + } if (diffResult.isBinary) { return workflowEmptyStateTemplate({ title: "Binary file", @@ -928,6 +947,11 @@ function filePreviewTemplate(file) { ${escapeHtml(meta)} `; + if (file.previewBase64 && file.previewMime) { + const mime = escapeHtml(file.previewMime); + const b64 = file.previewBase64; + return `${header}
`; + } if (file.tooLarge) { return header + `
File too large to preview
Preview is limited to ${formatBytes(maxPreviewBytes)}.
`; } @@ -1993,6 +2017,8 @@ async function openViewerFile(path) { content: file.content || "", isBinary: file.isBinary, tooLarge: file.tooLarge, + previewMime: file.previewMime, + previewBase64: file.previewBase64, }; } } catch (error) { @@ -2670,11 +2696,13 @@ function bindDashboardEvents() { function render() { applyTheme(); + applyPlatformChrome(); dashboardView(); } window.addEventListener("DOMContentLoaded", async () => { applyTheme(); + applyPlatformChrome(); await loadRepositories(); if (getState().selectedRepoPath) { await refreshRepoData(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0992aa8..f6ffbd4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1292,6 +1292,8 @@ dependencies = [ name = "gitpub-desktop" version = "0.1.0" dependencies = [ + "base64 0.22.1", + "ico", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ae8152..e433129 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +base64 = "0.22" +ico = "0.5" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } tauri-plugin-dialog = "2.7.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b544392..3580232 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; use reqwest::blocking::Client; use reqwest::header::{ACCEPT, AUTHORIZATION}; use serde::Serialize; use std::collections::HashSet; use std::env; use std::fs; +use std::io::Cursor; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -49,6 +51,8 @@ struct LocalRepoFile { content: Option, is_binary: bool, too_large: bool, + preview_mime: Option, + preview_base64: Option, } #[derive(Serialize)] @@ -101,6 +105,8 @@ struct GitFileDiff { is_binary: bool, is_deleted: bool, is_untracked: bool, + preview_mime: Option, + preview_base64: Option, } #[derive(Serialize)] @@ -620,6 +626,109 @@ fn is_binary_file(path: &Path) -> bool { .unwrap_or(false) } +/// Max decoded image size embedded into the UI as a data URL (base64 expands payload). +const MAX_IMAGE_PREVIEW_BYTES: u64 = 5 * 1024 * 1024; + +fn repository_relative_image_mime(path: &str) -> Option<&'static str> { + let lower = path.to_lowercase(); + let ext = Path::new(&lower).extension()?.to_str()?; + match ext { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "bmp" | "dib" => Some("image/bmp"), + "ico" => Some("image/x-icon"), + "avif" => Some("image/avif"), + "tif" | "tiff" => Some("image/tiff"), + "heic" | "heif" => Some("image/heif"), + "svg" => Some("image/svg+xml"), + _ => None, + } +} + +fn ico_preview_as_png(bytes: &[u8]) -> Option<(String, String)> { + let icon_dir = ico::IconDir::read(Cursor::new(bytes)).ok()?; + let best_entry = icon_dir + .entries() + .iter() + .max_by_key(|entry| (entry.width() * entry.height(), entry.bits_per_pixel()))?; + let image = best_entry.decode().ok()?; + let mut png = Vec::new(); + image.write_png(&mut png).ok()?; + Some(("image/png".to_string(), STANDARD.encode(png))) +} + +fn image_preview_pair(path: &str, bytes: &[u8]) -> (Option, Option) { + let Some(mime) = repository_relative_image_mime(path) else { + return (None, None); + }; + if bytes.len() as u64 > MAX_IMAGE_PREVIEW_BYTES { + return (None, None); + } + if Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("ico")) + { + if let Some((mime, encoded_png)) = ico_preview_as_png(bytes) { + return (Some(mime), Some(encoded_png)); + } + } + (Some(mime.to_string()), Some(STANDARD.encode(bytes))) +} + +fn diff_image_preview( + repo_path: &str, + git_path: Option, + file_path: &str, + is_deleted: bool, +) -> (Option, Option) { + if repository_relative_image_mime(file_path).is_none() { + return (None, None); + } + + if is_deleted { + let spec = format!("HEAD:{file_path}"); + let size_bytes = match run_git_output( + repo_path, + git_path.clone(), + vec!["cat-file".to_string(), "-s".to_string(), spec.clone()], + ) { + Ok(buf) => buf, + Err(_) => return (None, None), + }; + let size_str = String::from_utf8_lossy(&size_bytes); + let Ok(size) = size_str.trim().parse::() else { + return (None, None); + }; + if size > MAX_IMAGE_PREVIEW_BYTES { + return (None, None); + } + let bytes = match run_git_output( + repo_path, + git_path, + vec!["show".to_string(), "--no-ext-diff".to_string(), spec], + ) { + Ok(b) => b, + Err(_) => return (None, None), + }; + return image_preview_pair(file_path, &bytes); + } + + let absolute = PathBuf::from(repo_path).join(file_path); + let Ok(meta) = fs::metadata(&absolute) else { + return (None, None); + }; + if meta.len() > MAX_IMAGE_PREVIEW_BYTES { + return (None, None); + } + let Ok(bytes) = fs::read(&absolute) else { + return (None, None); + }; + image_preview_pair(file_path, &bytes) +} + fn normalize_repo_path(path: &str) -> Result { let trimmed = path.trim().trim_matches('/'); if trimmed.contains('\\') || trimmed.contains('\0') { @@ -1087,6 +1196,8 @@ fn get_file_diff( if is_untracked { let absolute_path = PathBuf::from(&repo_path).join(&file_path); if is_binary_file(&absolute_path) { + let bytes = fs::read(&absolute_path).unwrap_or_default(); + let (preview_mime, preview_base64) = image_preview_pair(&file_path, &bytes); return Ok(GitFileDiff { path: file_path, diff: "Binary file. Diff preview is not available for untracked binary files." @@ -1094,6 +1205,8 @@ fn get_file_diff( is_binary: true, is_deleted: false, is_untracked: true, + preview_mime, + preview_base64, }); } let content = fs::read_to_string(&absolute_path) @@ -1113,12 +1226,14 @@ fn get_file_diff( is_binary: false, is_deleted: false, is_untracked: true, + preview_mime: None, + preview_base64: None, }); } let output = run_git_command( Some(&repo_path), - git_path, + git_path.clone(), vec![ "diff".to_string(), "--no-ext-diff".to_string(), @@ -1131,6 +1246,8 @@ fn get_file_diff( diff = output.stderr; } let is_binary = diff.contains("Binary files") || diff.contains("GIT binary patch"); + let (preview_mime, preview_base64) = + diff_image_preview(repo_path.trim(), git_path, &file_path, is_deleted); Ok(GitFileDiff { path: file_path, diff: if diff.is_empty() { @@ -1141,6 +1258,8 @@ fn get_file_diff( is_binary, is_deleted, is_untracked: false, + preview_mime, + preview_base64, }) } @@ -1741,33 +1860,56 @@ fn local_repo_file( .map_err(|_| "Unable to determine file size.".to_string())?; if size > MAX_TEXT_PREVIEW_BYTES { + if size <= MAX_IMAGE_PREVIEW_BYTES && repository_relative_image_mime(&path).is_some() { + let raw = run_git_output( + repo_path.trim(), + git_path, + vec!["show".to_string(), "--no-ext-diff".to_string(), spec.clone()], + )?; + let (preview_mime, preview_base64) = image_preview_pair(&path, &raw); + return Ok(LocalRepoFile { + path, + size, + content: None, + is_binary: preview_base64.is_none(), + too_large: preview_base64.is_none(), + preview_mime, + preview_base64, + }); + } return Ok(LocalRepoFile { path, size, content: None, is_binary: false, too_large: true, + preview_mime: None, + preview_base64: None, }); } - let content = run_git_output( + let raw = run_git_output( repo_path.trim(), git_path, vec!["show".to_string(), "--no-ext-diff".to_string(), spec], )?; - let is_binary = content.contains(&0); - let content = if is_binary { + let (preview_mime, preview_base64) = image_preview_pair(&path, &raw); + let has_null = raw.contains(&0); + let content = if has_null { None } else { - String::from_utf8(content).ok() + String::from_utf8(raw).ok() }; + let is_binary = content.is_none(); Ok(LocalRepoFile { path, size, - is_binary: content.is_none(), + is_binary, too_large: false, content, + preview_mime, + preview_base64, }) }