Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d311099
feat: add ImageDiff component to load and show before/after images
nshcr Dec 4, 2025
72da0e7
Merge branch 'master' into pr/11456
PavelLaptev Dec 6, 2025
0aa478f
Merge branch 'master' into pr/11456
PavelLaptev Dec 6, 2025
151dd9c
Add documentation for get_blob_file function
PavelLaptev Dec 7, 2025
1c20615
Improve alt text for image diff panels
PavelLaptev Dec 7, 2025
878d1e3
Improve image load error messages in ImageDiff
PavelLaptev Dec 7, 2025
1955e95
Add abort support to image loading in ImageDiff
PavelLaptev Dec 7, 2025
83489e5
fix formatting
PavelLaptev Dec 7, 2025
08c9a14
Refactor and enhance ImageDiff component
PavelLaptev Dec 7, 2025
ee26491
refactor Rust
Byron Dec 4, 2025
ccc131e
Fix typo in ImageDiff placeholder class name
PavelLaptev Dec 8, 2025
2608e18
Handle image metadata loading errors in ImageDiff
PavelLaptev Dec 8, 2025
f1331e8
Update ImageDiff.svelte
PavelLaptev Dec 8, 2025
7f8806f
Add note on AbortSignal usage in loadImage function
PavelLaptev Dec 8, 2025
f3633f4
Improve swipe image comparison interaction
PavelLaptev Dec 8, 2025
83f391b
Refactor isImageFile to shared utils
PavelLaptev Dec 8, 2025
45b972d
Refactor ImageDiff story to use sample images
PavelLaptev Dec 8, 2025
35dca4b
Add keyboard controls to image diff slider
PavelLaptev Dec 8, 2025
c0d2682
Remove col-resize cursor from ImageDiff header
PavelLaptev Dec 8, 2025
d5249ad
Update apps/desktop/src/components/ImageDiff.svelte
PavelLaptev Dec 8, 2025
4df7c2b
Update crates/but-api/src/legacy/repo.rs
PavelLaptev Dec 8, 2025
018291c
Merge branch 'feat-diff-images' of https://github.com/nshcr/gitbutler…
PavelLaptev Dec 8, 2025
5e63987
Fix TouchEvent check in handleDragMove
PavelLaptev Dec 8, 2025
8501a2f
Merge branch 'master' into feat-diff-images
PavelLaptev Dec 8, 2025
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
182 changes: 182 additions & 0 deletions apps/desktop/src/components/ImageDiff.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<script lang="ts">
import emptyFileSvg from '$lib/assets/empty-state/empty-file.svg?raw';
import { FILE_SERVICE } from '$lib/files/fileService';
import { inject } from '@gitbutler/core/context';
import { ImageDiff, EmptyStatePlaceholder } from '@gitbutler/ui';
import type { TreeChange } from '$lib/hunks/change';
type Props = {
projectId: string;
change: TreeChange;
/** If provided, this is a commit diff (not a workspace diff). */
commitId?: string;
};
type ImageSource =
| { type: 'workspace'; path: string }
| { type: 'commit'; path: string; commitId: string }
| { type: 'blob'; path: string; blobId: string };
type LoadStrategy = {
before: ImageSource | null;
after: ImageSource | null;
};
const { projectId, change, commitId }: Props = $props();
const fileService = inject(FILE_SERVICE);
let beforeImageUrl = $state<string | null>(null);
let afterImageUrl = $state<string | null>(null);
let loadError = $state<string | null>(null);
let isLoading = $state<boolean>(true);
// Decide image sources for before/after panels without changing logic.
function getLoadStrategy(): LoadStrategy {
const { status, path } = change;
const isCommitDiff = !!commitId;
switch (status.type) {
case 'Addition':
return isCommitDiff
? { before: null, after: { type: 'commit' as const, path, commitId: commitId! } }
: { before: null, after: { type: 'workspace' as const, path } };
case 'Deletion':
return isCommitDiff
? { before: { type: 'commit' as const, path, commitId: `${commitId}^` }, after: null }
Comment on lines +44 to +46
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For commit diffs with deletions, the code constructs ${commitId}^ to reference the parent commit. This assumes the commit has a parent, but the initial commit in a repository has no parent. While this is likely an edge case, consider adding error handling or documentation about this limitation. The backend will likely return an error for the initial commit, but the error message might not be user-friendly.

Copilot uses AI. Check for mistakes.
: {
before: { type: 'blob' as const, path, blobId: status.subject.previousState.id },
after: null
};
case 'Modification':
return isCommitDiff
? {
before: { type: 'commit' as const, path, commitId: `${commitId}^` },
after: { type: 'commit' as const, path, commitId: commitId! }
}
: {
before: { type: 'blob' as const, path, blobId: status.subject.previousState.id },
after: { type: 'workspace' as const, path }
};
case 'Rename':
return isCommitDiff
? {
before: {
type: 'commit' as const,
path: status.subject.previousPath,
commitId: `${commitId}^`
},
after: { type: 'commit' as const, path, commitId: commitId! }
}
: {
before: {
type: 'blob' as const,
path: status.subject.previousPath,
blobId: status.subject.previousState.id
},
after: { type: 'workspace' as const, path }
};
}
}
Comment on lines +34 to +82
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLoadStrategy function handles complex logic for determining how to load images based on different change types (Addition, Deletion, Modification, Rename) and contexts (commit diff vs workspace diff). This critical logic should have unit tests to ensure correctness across all scenarios. Consider adding tests to verify the strategy selection for each combination of change type and context.

Copilot uses AI. Check for mistakes.
// Load image from workspace, commit, or blob.
async function loadImage(
source: ImageSource | null,
signal?: AbortSignal
): Promise<string | null> {
if (!source) return null;
try {
let fileInfo;
if (source.type === 'workspace') {
const { data } = await fileService.readFromWorkspace(source.path, projectId);
fileInfo = data;
} else if (source.type === 'commit') {
fileInfo = await fileService.readFromCommit(source.path, projectId, source.commitId);
} else {
// type === 'blob'
fileInfo = await fileService.readFromBlob(source.path, projectId, source.blobId);
}
if (signal?.aborted) return null;
if (fileInfo?.content && fileInfo?.mimeType) {
return `data:${fileInfo.mimeType};base64,${fileInfo.content}`;
}
return null;
} catch (err) {
if (signal?.aborted) return null;
console.warn(`Failed to load image from ${source.type}: ${source.path}`, err);
return null;
}
}
Comment on lines +100 to +131
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadImage function includes error handling and abort signal logic that should be covered by tests. This would ensure proper handling of loading failures, race conditions with abort signals, and correct data URL generation from file content.

Copilot uses AI. Check for mistakes.
// Load both images according to the strategy.
async function loadImages(signal?: AbortSignal) {
isLoading = true;
loadError = null;
beforeImageUrl = null;
afterImageUrl = null;
try {
const strategy = getLoadStrategy();
const [before, after] = await Promise.all([
loadImage(strategy.before, signal),
loadImage(strategy.after, signal)
]);
if (signal?.aborted) return;
beforeImageUrl = before;
afterImageUrl = after;
if (!before && !after) {
loadError = 'Failed to load both images (before and after).';
} else if (!before && strategy.before) {
loadError = 'Failed to load before image.';
} else if (!after && strategy.after) {
loadError = 'Failed to load after image.';
}
} catch (err) {
console.error('Failed to load images:', err);
loadError = `Failed to load images: ${err instanceof Error ? err.message : String(err)}`;
} finally {
isLoading = false;
}
}
$effect(() => {
const abortController = new AbortController();
loadImages(abortController.signal);
return () => abortController.abort();
});
</script>

{#if loadError}
<div class="imagediff-placehoder">
<EmptyStatePlaceholder image={emptyFileSvg} gap={12} topBottomPadding={34}>
{#snippet caption()}
Can't preview this file type
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message displayed to users ("Can't preview this file type") doesn't match the actual error scenarios. The loadError variable contains specific error messages like "Failed to load before image" or "Failed to load images: [error message]", but the UI always shows the generic "Can't preview this file type" message. Consider either:

  1. Displaying the actual error message from loadError to help users understand what went wrong, or
  2. Using more specific user-friendly messages that reflect the actual error condition

The current implementation makes debugging difficult for users as they can't tell if it's a network issue, missing file, or an actual unsupported file type.

Suggested change
Can't preview this file type
{loadError}

Copilot uses AI. Check for mistakes.
{/snippet}
</EmptyStatePlaceholder>
</div>
{:else}
<ImageDiff {beforeImageUrl} {afterImageUrl} fileName={change.path} {isLoading} />
{/if}

<style lang="scss">
.imagediff-placehoder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 200px;
border: 1px solid var(--clr-border-3);
border-radius: var(--radius-m);
}
</style>
36 changes: 29 additions & 7 deletions apps/desktop/src/components/UnifiedDiffView.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import HunkContextMenu from '$components/HunkContextMenu.svelte';
import ImageDiff from '$components/ImageDiff.svelte';
import LargeDiffMessage from '$components/LargeDiffMessage.svelte';
import LineLocksWarning from '$components/LineLocksWarning.svelte';
import ReduxResult from '$components/ReduxResult.svelte';
Expand Down Expand Up @@ -86,6 +87,23 @@
const assignments = $derived(uncommittedService.assignmentsByPath(stackId || null, change.path));
function isImageFile(path: string): boolean {
const imageExtensions = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.ico',
'.heic',
'.heif',
'.avif'
];
const lowerPath = path.toLowerCase();
return imageExtensions.some((ext) => lowerPath.endsWith(ext));
}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The isImageFile function is defined inline within the component, but this utility function would be better placed in a shared utilities file since it could be reused elsewhere in the codebase. Consider extracting this to a shared file like apps/desktop/src/lib/utils/files.ts or similar.

Additionally, the function could benefit from a more comprehensive list of image extensions. Consider adding .tiff/.tif for completeness.

Copilot uses AI. Check for mistakes.
function filter(hunks: DiffHunk[]): DiffHunk[] {
if (selectionId.type !== 'worktree') return hunks;
// TODO: It does concern me that this is an N+1;
Expand Down Expand Up @@ -317,13 +335,17 @@
</EmptyStatePlaceholder>
</div>
{:else if diff.type === 'Binary'}
<div class="hunk-placehoder">
<EmptyStatePlaceholder image={binarySvg} gap={12} topBottomPadding={34}>
{#snippet caption()}
Binary! Not for human eyes
{/snippet}
</EmptyStatePlaceholder>
</div>
{#if isImageFile(change.path)}
<ImageDiff {projectId} {change} {commitId} />
{:else}
<div class="hunk-placehoder">
<EmptyStatePlaceholder image={binarySvg} gap={12} topBottomPadding={34}>
{#snippet caption()}
Binary! Not for human eyes
{/snippet}
</EmptyStatePlaceholder>
</div>
{/if}
{/if}
</div>
{/snippet}
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/lib/files/fileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ export class FileService {
};
}

async readFromCommit(filePath: string, projectId: string, commitId: string): Promise<FileInfo> {
return await this.backend.invoke('get_commit_file', {
relativePath: filePath,
projectId: projectId,
commitId: commitId
});
}

async readFromBlob(filePath: string, projectId: string, blobId: string): Promise<FileInfo> {
return await this.backend.invoke('get_blob_file', {
relativePath: filePath,
projectId: projectId,
blobId: blobId
});
}

async readFile(path: string): Promise<Uint8Array> {
return await this.backend.readFile(path);
}
Expand Down
28 changes: 25 additions & 3 deletions crates/but-api/src/legacy/repo.rs
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I avoided using project.open_git2() as I saw it is marked deprecated, and used the gix API instead.
I'm not entirely sure if this is the correct approach here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a good call. The repo needs to be opened anyway, and then using gix is faster.
Since it's all in legacy-land, I think it's fine to do what's needed.

However, I don't think that anything but full hashes may be supported here. The frontend already has blob hashes for anything one would want to diff.

Also I think there is a way to read an arbitrary file from within the worktree, in case of files that want comparison between worktree/Git.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::path::PathBuf;

use crate::json::HexHash;
use anyhow::{Context as _, Result};
use but_api_macros::but_api;
use but_core::DiffSpec;
Expand Down Expand Up @@ -70,11 +71,10 @@ pub fn get_uncommitted_files(project_id: ProjectId) -> Result<Vec<RemoteBranchFi
pub fn get_commit_file(
project_id: ProjectId,
relative_path: PathBuf,
commit_id: String,
commit_id: HexHash,
) -> Result<FileInfo> {
let project = gitbutler_project::get(project_id)?;
let commit_id = git2::Oid::from_str(&commit_id).map_err(anyhow::Error::from)?;
project.read_file_from_commit(commit_id, &relative_path)
project.read_file_from_commit(commit_id.0.to_git2(), &relative_path)
}

#[but_api]
Expand All @@ -84,6 +84,28 @@ pub fn get_workspace_file(project_id: ProjectId, relative_path: PathBuf) -> Resu
project.read_file_from_workspace(&relative_path)
}

/// Retrieves file content directly from a Git blob object by its blob ID.
///
/// This function is used for displaying image diff previews when the file
/// isn't available in the current workspace or a specific commit (e.g., for
/// deleted files or when comparing against a previous state).
///
/// # Arguments
/// * `blob_id` - Git blob object ID as a hexadecimal string
#[but_api]
#[instrument(err(Debug))]
pub fn get_blob_file(
project_id: ProjectId,
relative_path: PathBuf,
blob_id: HexHash,
) -> Result<FileInfo> {
let project = gitbutler_project::get(project_id)?;
let repo = project.open_isolated_repo()?;
let object = repo.find_object(blob_id).context("Failed to find blob")?;
let blob = object.try_into_blob()?;
Ok(FileInfo::from_content(&relative_path, &blob.data))
}

#[but_api]
#[instrument(err(Debug))]
pub fn pre_commit_hook(
Expand Down
1 change: 1 addition & 0 deletions crates/but-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ async fn handle_command(
"get_uncommitted_files" => legacy::repo::get_uncommitted_files_cmd(request.params),
"get_commit_file" => legacy::repo::get_commit_file_cmd(request.params),
"get_workspace_file" => legacy::repo::get_workspace_file_cmd(request.params),
"get_blob_file" => legacy::repo::get_blob_file_cmd(request.params),
"find_files" => legacy::repo::find_files_cmd(request.params),
"pre_commit_hook" => legacy::repo::pre_commit_hook_cmd(request.params),
"pre_commit_hook_diffspecs" => legacy::repo::pre_commit_hook_diffspecs_cmd(request.params),
Expand Down
1 change: 1 addition & 0 deletions crates/gitbutler-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ fn main() -> anyhow::Result<()> {
legacy::repo::tauri_get_uncommitted_files::get_uncommitted_files,
legacy::repo::tauri_get_commit_file::get_commit_file,
legacy::repo::tauri_get_workspace_file::get_workspace_file,
legacy::repo::tauri_get_blob_file::get_blob_file,
legacy::repo::tauri_find_files::find_files,
legacy::repo::tauri_pre_commit_hook::pre_commit_hook,
legacy::repo::tauri_pre_commit_hook_diffspecs::pre_commit_hook_diffspecs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
}
.empty-state__caption {
color: var(--clr-scale-ntrl-50);
color: var(--clr-text-2);
opacity: 0.6;
}
Expand Down
Loading
Loading