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
+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,
})
}