Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/differ/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions apps/staged/src-tauri/src/blox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ pub fn ws_exec(name: &str, args: &[&str]) -> Result<String, BloxError> {
blox_cli::ws_exec(name, args)
}

/// Execute a command inside a Blox workspace, returning raw bytes.
///
/// Like `ws_exec` but returns the raw stdout bytes without UTF-8 validation.
/// Use this when the command may produce binary output (e.g. `git show` on image files).
pub fn ws_exec_bytes(name: &str, args: &[&str]) -> Result<Vec<u8>, BloxError> {
blox_cli::ws_exec_bytes(name, args)
}

/// Quick authentication check — runs `sq blox ws list` and inspects the result.
///
/// Returns `Ok(())` if the user appears to be authenticated, or
Expand Down
17 changes: 17 additions & 0 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,23 @@ pub(crate) fn run_workspace_git(
blox::ws_exec(workspace_name, &borrowed)
}

pub(crate) fn run_workspace_git_bytes(
workspace_name: &str,
repo_subpath: Option<&str>,
git_args: &[&str],
) -> Result<Vec<u8>, blox::BloxError> {
let mut owned = Vec::<String>::new();
owned.push("git".to_string());
if let Some(subpath) = repo_subpath.map(str::trim).filter(|s| !s.is_empty()) {
let resolved = resolve_workspace_repo_path(workspace_name, subpath)?;
owned.push("-C".to_string());
owned.push(resolved);
}
owned.extend(git_args.iter().map(|arg| (*arg).to_string()));
let borrowed = owned.iter().map(String::as_str).collect::<Vec<_>>();
blox::ws_exec_bytes(workspace_name, &borrowed)
}

async fn run_blox_blocking<T, F>(op: F) -> Result<T, blox::BloxError>
where
T: Send + 'static,
Expand Down
71 changes: 54 additions & 17 deletions apps/staged/src-tauri/src/diff_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ fn run_remote_git(ctx: &BranchDiffContext, args: &[&str]) -> Result<String, Stri
.map_err(|e| e.to_string())
}

fn run_remote_git_bytes(ctx: &BranchDiffContext, args: &[&str]) -> Result<Vec<u8>, String> {
let workspace = ctx
.workspace_name
.as_deref()
.ok_or("Missing remote workspace context")?;
branches::run_workspace_git_bytes(workspace, ctx.repo_subpath.as_deref(), args)
.map_err(|e| e.to_string())
}

/// Build a DiffSpec for a branch diff.
///
/// - Branch scope with no commit_sha: merge-base(base, tip)..tip
Expand Down Expand Up @@ -241,15 +250,49 @@ fn parse_unified_hunks(diff_text: &str) -> Vec<RemoteHunk> {
hunks
}

fn file_content_from_text(text: &str) -> git::FileContent {
if text.as_bytes()[..text.len().min(8192)].contains(&0) {
return git::FileContent::Binary;
fn file_content_from_bytes(bytes: &[u8], path: &str) -> git::FileContent {
let check_len = bytes.len().min(8192);
if bytes[..check_len].contains(&0) {
return file_content_binary_or_image(bytes, path);
}
let text = String::from_utf8_lossy(bytes);
git::FileContent::Text {
lines: text.lines().map(|line| line.to_string()).collect(),
}
}

/// For binary content in the remote path, try to produce an ImageBase64 variant.
fn file_content_binary_or_image(bytes: &[u8], path: &str) -> git::FileContent {
if bytes.len() > git::IMAGE_PREVIEW_MAX_BYTES {
return git::FileContent::Binary;
}

let file_path = std::path::Path::new(path);
let ext = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());

let mime = match ext.as_deref() {
Some("png") => Some("image/png"),
Some("jpg" | "jpeg") => Some("image/jpeg"),
Some("gif") => Some("image/gif"),
Some("webp") => Some("image/webp"),
_ => None,
};

if let Some(mime) = mime {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
git::FileContent::ImageBase64 {
mime_type: mime.to_string(),
data,
}
} else {
git::FileContent::Binary
}
}

fn is_missing_object_error(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("not a valid object name")
Expand All @@ -259,11 +302,6 @@ fn is_missing_object_error(msg: &str) -> bool {
|| lower.contains("path '")
}

fn is_utf8_parse_error(msg: &str) -> bool {
msg.to_lowercase()
.contains("invalid utf-8 in sq blox output")
}

fn load_remote_file_at_ref(
ctx: &BranchDiffContext,
ref_name: &str,
Expand All @@ -277,15 +315,14 @@ fn load_remote_file_at_ref(
Err(e) => return Err(e),
}

match run_remote_git(ctx, &["show", &spec]) {
Ok(content) => Ok(Some(git::File {
path: path.to_string(),
content: file_content_from_text(&content),
})),
Err(e) if is_utf8_parse_error(&e) => Ok(Some(git::File {
path: path.to_string(),
content: git::FileContent::Binary,
})),
match run_remote_git_bytes(ctx, &["show", &spec]) {
Ok(bytes) => {
let content = file_content_from_bytes(&bytes, path);
Ok(Some(git::File {
path: path.to_string(),
content,
}))
}
Err(e) => Err(e),
}
}
Expand Down
22 changes: 19 additions & 3 deletions crates/blox-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ fn is_auth_error(stderr: &str) -> bool {
|| lower.contains("401")
}

/// Run `sq blox <args…>` and return stdout as a string.
fn run(args: &[&str], timeout: Duration) -> Result<String, BloxError> {
/// Run `sq blox <args…>` and return stdout as raw bytes.
fn run_bytes(args: &[&str], timeout: Duration) -> Result<Vec<u8>, BloxError> {
let sq = sq_binary()?;

let mut full_args = vec!["blox"];
Expand Down Expand Up @@ -255,7 +255,13 @@ fn run(args: &[&str], timeout: Duration) -> Result<String, BloxError> {
return Err(BloxError::CommandFailed(stderr.into_owned()));
}

String::from_utf8(stdout)
Ok(stdout)
}

/// Run `sq blox <args…>` and return stdout as a string.
fn run(args: &[&str], timeout: Duration) -> Result<String, BloxError> {
let bytes = run_bytes(args, timeout)?;
String::from_utf8(bytes)
.map_err(|e| BloxError::ParseError(format!("invalid UTF-8 in sq blox output: {e}")))
}

Expand Down Expand Up @@ -338,6 +344,16 @@ pub fn ws_exec(name: &str, args: &[&str]) -> Result<String, BloxError> {
run(&full_args, EXEC_TIMEOUT)
}

/// Execute a command inside a Blox workspace, returning raw bytes.
///
/// Like `ws_exec` but returns the raw stdout bytes without UTF-8 validation.
/// Use this when the command may produce binary output (e.g. `git show` on image files).
pub fn ws_exec_bytes(name: &str, args: &[&str]) -> Result<Vec<u8>, BloxError> {
let mut full_args = vec!["ws", "exec", name, "--"];
full_args.extend_from_slice(args);
run_bytes(&full_args, EXEC_TIMEOUT)
}

/// Quick authentication check — runs `sq blox ws list` and inspects the result.
///
/// Returns `Ok(())` if the user appears to be authenticated, or
Expand Down
1 change: 1 addition & 0 deletions crates/git-diff/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ description = "Git diff computation: file listing, content diffing, and alignmen
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"
git2 = { version = "0.20", features = ["vendored-openssl"] }
base64 = "0.22"

[dev-dependencies]
tempfile = "3.0"
45 changes: 39 additions & 6 deletions crates/git-diff/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ fn load_file_from_tree(
None => return Ok(None), // Not a file (maybe a submodule)
};

let content = bytes_to_content(blob.content());
let content = bytes_to_content(blob.content(), path);

Ok(Some(File {
path: path.to_string_lossy().to_string(),
Expand All @@ -443,7 +443,7 @@ fn load_file_from_index(repo: &Repository, path: &Path) -> Result<Option<File>,
.find_blob(entry.id)
.map_err(|e| GitError::CommandFailed(format!("Cannot load blob from index: {e}")))?;

let content = bytes_to_content(blob.content());
let content = bytes_to_content(blob.content(), path);

Ok(Some(File {
path: path.to_string_lossy().to_string(),
Expand Down Expand Up @@ -472,16 +472,19 @@ fn load_file_from_workdir(repo: &Repository, path: &Path) -> Result<Option<File>

Ok(Some(File {
path: path.to_string_lossy().to_string(),
content: bytes_to_content(&bytes),
content: bytes_to_content(&bytes, path),
}))
}

/// Convert raw bytes to FileContent, detecting binary
fn bytes_to_content(bytes: &[u8]) -> FileContent {
/// Convert raw bytes to FileContent, detecting binary and images.
///
/// When binary content is detected and the file path has a known image extension,
/// the bytes are base64-encoded and returned as `ImageBase64` (up to 5 MB).
fn bytes_to_content(bytes: &[u8], path: &Path) -> FileContent {
// Check for binary: look for null bytes in first 8KB
let check_len = bytes.len().min(8192);
if bytes[..check_len].contains(&0) {
return FileContent::Binary;
return bytes_to_binary_or_image(bytes, path);
}

// Parse as UTF-8 (lossy for display)
Expand All @@ -490,6 +493,36 @@ fn bytes_to_content(bytes: &[u8]) -> FileContent {
FileContent::Text { lines }
}

/// For binary content, check if it's a previewable image and base64-encode it.
fn bytes_to_binary_or_image(bytes: &[u8], path: &Path) -> FileContent {
if bytes.len() > IMAGE_PREVIEW_MAX_BYTES {
return FileContent::Binary;
}

if let Some(mime) = image_mime_for_path(path) {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
FileContent::ImageBase64 {
mime_type: mime.to_string(),
data,
}
} else {
FileContent::Binary
}
}

/// Return the MIME type if the path has a known image extension.
fn image_mime_for_path(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}

/// Get hunks for a single file using libgit2
fn get_hunks_libgit2(
repo: &Repository,
Expand Down
38 changes: 35 additions & 3 deletions crates/git-diff/src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use std::path::Path;

use crate::cli::{self, GitError};
use crate::types::{File, FileContent, WORKDIR};
use crate::types::{File, FileContent, IMAGE_PREVIEW_MAX_BYTES, WORKDIR};

/// Search for files matching a query in the repository at a given ref.
///
Expand Down Expand Up @@ -149,7 +149,7 @@ pub fn get_file_at_ref(repo: &Path, ref_name: &str, path: &str) -> Result<File,
.map_err(|e| GitError::CommandFailed(format!("Cannot read file: {e}")))?;

let content = if is_binary(&bytes) {
FileContent::Binary
bytes_to_image_or_binary(&bytes, path)
} else {
let text = String::from_utf8_lossy(&bytes);
text_to_content(&text)
Expand All @@ -172,7 +172,7 @@ pub fn get_file_at_ref(repo: &Path, ref_name: &str, path: &str) -> Result<File,
// git show returns text, check if it looks binary
let bytes = output.as_bytes();
let content = if is_binary(bytes) {
FileContent::Binary
bytes_to_image_or_binary(bytes, path)
} else {
text_to_content(&output)
};
Expand All @@ -190,6 +190,38 @@ fn is_binary(data: &[u8]) -> bool {
data[..check_len].contains(&0)
}

/// For binary content, check if it's a previewable image and base64-encode it.
fn bytes_to_image_or_binary(bytes: &[u8], path: &str) -> FileContent {
if bytes.len() > IMAGE_PREVIEW_MAX_BYTES {
return FileContent::Binary;
}

let file_path = std::path::Path::new(path);
let ext = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());

let mime = match ext.as_deref() {
Some("png") => Some("image/png"),
Some("jpg" | "jpeg") => Some("image/jpeg"),
Some("gif") => Some("image/gif"),
Some("webp") => Some("image/webp"),
_ => None,
};

if let Some(mime) = mime {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
FileContent::ImageBase64 {
mime_type: mime.to_string(),
data,
}
} else {
FileContent::Binary
}
}

/// Convert text to FileContent with lines
fn text_to_content(text: &str) -> FileContent {
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
Expand Down
18 changes: 16 additions & 2 deletions crates/git-diff/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,26 @@ impl Span {
}
}

/// Content of a file - either text lines or binary marker
/// Maximum file size (in bytes) for image preview. Files larger than this
/// fall back to the generic binary notice to avoid sending huge payloads.
pub const IMAGE_PREVIEW_MAX_BYTES: usize = 5 * 1024 * 1024; // 5 MB

/// Image extensions eligible for inline base64 preview.
pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];

/// Content of a file - either text lines, binary marker, or base64-encoded image
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum FileContent {
Text { lines: Vec<String> },
Text {
lines: Vec<String>,
},
Binary,
ImageBase64 {
#[serde(rename = "mimeType")]
mime_type: String,
data: String,
},
}

/// A file with its path and content
Expand Down
Loading