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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user