Show inline image previews for binary files

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.
This commit is contained in:
Andrew Zambazos
2026-05-13 18:28:48 +12:00
parent 0f3f73be9c
commit 37fc1dc626
6 changed files with 245 additions and 7 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

After

Width:  |  Height:  |  Size: 407 KiB

+65 -1
View File
@@ -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 {
+28
View File
@@ -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 `<div class="diff-preview diff-binary-image-panel">
<div class="diff-image-preview-frame">
<img class="diff-image-preview" alt="" decoding="async" src="data:${mime};base64,${b64}" />
</div>
${caption ? `<pre class="diff-binary-caption">${caption}</pre>` : ""}
</div>`;
}
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) {
<span class="muted">${escapeHtml(meta)}</span>
</div>`;
if (file.previewBase64 && file.previewMime) {
const mime = escapeHtml(file.previewMime);
const b64 = file.previewBase64;
return `${header}<div class="viewer-image-preview-wrap"><img class="viewer-image-preview" alt="" decoding="async" src="data:${mime};base64,${b64}" /></div>`;
}
if (file.tooLarge) {
return header + `<div class="empty-state viewer-empty"><div>File too large to preview</div><div class="muted">Preview is limited to ${formatBytes(maxPreviewBytes)}.</div></div>`;
}
@@ -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();
+2
View File
@@ -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",
+2
View File
@@ -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"
+148 -6
View File
@@ -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<String>,
is_binary: bool,
too_large: bool,
preview_mime: Option<String>,
preview_base64: Option<String>,
}
#[derive(Serialize)]
@@ -101,6 +105,8 @@ struct GitFileDiff {
is_binary: bool,
is_deleted: bool,
is_untracked: bool,
preview_mime: Option<String>,
preview_base64: Option<String>,
}
#[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<String>, Option<String>) {
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<String>,
file_path: &str,
is_deleted: bool,
) -> (Option<String>, Option<String>) {
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::<u64>() 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<String, String> {
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,
})
}