diff --git a/frontend/assets/icons/GitpubDesktop-Icon.ico b/frontend/assets/icons/GitpubDesktop-Icon.ico
index f383dc6..0c73124 100644
Binary files a/frontend/assets/icons/GitpubDesktop-Icon.ico and b/frontend/assets/icons/GitpubDesktop-Icon.ico differ
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,
})
}