Skip to content

Conversation

@nshcr
Copy link
Contributor

@nshcr nshcr commented Dec 4, 2025

This PR adds before-and-after visual diff previews for image files in the diff view.

Most of the code was generated with the help of Claude Sonnet 4.5, and was reviewed by me before submitting the PR to ensure code quality and correct functionality.

It covers the following scenarios:

  1. Adding
  1. Modifying
  1. Renaming
  1. Deleting
  1. Displayed side-by-side when the window width is sufficient

@vercel
Copy link

vercel bot commented Dec 4, 2025

@nshcr is attempting to deploy a commit to the GitButler Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added rust Pull requests that update Rust code @gitbutler/desktop labels Dec 4, 2025
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.

@nshcr
Copy link
Contributor Author

nshcr commented Dec 4, 2025

@Byron — could you please help review the Rust changes? I added a new function to but-api and modified an existing one.

@PavelLaptev — could you please help review the frontend changes and adjust the styling so that the UI better matches GitButler's design?

Thanks in advance for taking the time to review this and for any feedback or improvements you can suggest!

@nshcr
Copy link
Contributor Author

nshcr commented Dec 4, 2025

Oh, this PR could fix #9946.

Copy link
Collaborator

@Byron Byron left a comment

Choose a reason for hiding this comment

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

Thanks a lot for getting this started, that's such a useful feature!

I just went ahead and made the changes I'd need to see with some explanation in the commit message. Probably I don't understand where the need for arbitrary revspecs is coming from.
With that it looks good on the Rust side.

Something I could use your help with is to understand how your are diffing worktree/Git changes. I also vaguely remember the presence of a function to obtain a worktree file, but I can't find it now. Ah, right, it's readFromWorkspace and it's already used.

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.

@Byron
Copy link
Collaborator

Byron commented Dec 4, 2025

Oh, and allow me unleash Copilot on this PR as I actually value what it has to say, most of the time 😅. Please feel free to ignore entirely.

@Byron Byron requested a review from Copilot December 4, 2025 18:16
Copilot finished reviewing on behalf of Byron December 4, 2025 18:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds visual diff previews for image files in the diff view, enabling before-and-after comparisons for image changes. It introduces a new ImageDiff Svelte component and supporting backend functions to load image content from commits, workspaces, and blob objects. The feature intelligently detects image files by extension and displays them with appropriate visual treatment for additions, modifications, renames, and deletions.

Key Changes

  • Added get_blob_file Rust API function to retrieve file content directly from Git blob objects
  • Extended FileService with readFromCommit and readFromBlob methods for flexible image loading
  • Created new ImageDiff component with responsive layout and support for all Git change types

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
crates/but-api/src/legacy/repo.rs Added get_blob_file function to retrieve files by blob ID; enhanced get_commit_file to support refs like "HEAD"
crates/gitbutler-tauri/src/main.rs Registered new get_blob_file command for Tauri app
crates/but-server/src/lib.rs Registered new get_blob_file command for CLI/server
apps/desktop/src/lib/files/fileService.ts Added readFromCommit and readFromBlob methods to support different image loading strategies
apps/desktop/src/components/UnifiedDiffView.svelte Added isImageFile helper and conditional rendering to show ImageDiff for image files
apps/desktop/src/components/ImageDiff.svelte New component implementing visual diff with before/after panels, responsive layout, and error handling

Comment on lines 1 to 264
<script lang="ts">
import { FILE_SERVICE } from '$lib/files/fileService';
import { inject } from '@gitbutler/core/context';
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);
// 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 }
: {
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 }
};
}
}
// Load image from workspace, commit, or blob.
async function loadImage(source: ImageSource | null): 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 (fileInfo?.content && fileInfo?.mimeType) {
return `data:${fileInfo.mimeType};base64,${fileInfo.content}`;
}
return null;
} catch (err) {
console.warn(`Failed to load image from ${source.type}: ${source.path}`, err);
return null;
}
}
// Load both images according to the strategy.
async function loadImages() {
loadError = null;
beforeImageUrl = null;
afterImageUrl = null;
try {
const strategy = getLoadStrategy();
const [before, after] = await Promise.all([
loadImage(strategy.before),
loadImage(strategy.after)
]);
beforeImageUrl = before;
afterImageUrl = after;
if (!before && !after) {
loadError = 'Failed to load images';
}
} catch (err) {
console.error('Failed to load images:', err);
loadError = 'Failed to load images';
}
}
$effect(() => {
loadImages();
});
</script>

{#if loadError}
<div class="error-message">
<p>{loadError}</p>
</div>
{:else}
<div class="image-diff-container">
{#if beforeImageUrl || afterImageUrl}
<div class="image-comparison">
{#if beforeImageUrl}
<div class="image-panel before">
<div class="image-header">
<span class="label">Before</span>
</div>
<div class="image-wrapper">
<img src={beforeImageUrl} alt="Before" />
</div>
</div>
{/if}

{#if afterImageUrl}
<div class="image-panel after">
<div class="image-header">
<span class="label">After</span>
</div>
<div class="image-wrapper">
<img src={afterImageUrl} alt="After" />
</div>
</div>
{/if}
</div>
{:else}
<div class="loading">Loading images...</div>
{/if}
</div>
{/if}

<style lang="postcss">
.error-message {
padding: 20px;
color: var(--clr-scale-warn-40);
text-align: center;
}
.image-diff-container {
padding: 14px;
border: 1px solid var(--clr-border-3);
border-radius: var(--radius-m);
background: var(--clr-bg-2);
}
.image-comparison {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 14px;
}
.image-panel {
overflow: hidden;
border-radius: var(--radius-s);
background: var(--clr-bg-1);
&.before {
border: 2px solid var(--clr-scale-err-40);
}
&.after {
border: 2px solid var(--clr-scale-succ-40);
}
}
.image-header {
padding: 8px 12px;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.5px;
text-transform: uppercase;
.before & {
background: var(--clr-scale-err-40);
color: var(--clr-core-ntrl-100);
}
.after & {
background: var(--clr-scale-succ-40);
color: var(--clr-core-ntrl-100);
}
}
.label {
display: inline-block;
}
.image-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 14px;
background-image:
linear-gradient(45deg, var(--clr-bg-3) 25%, transparent 25%),
linear-gradient(-45deg, var(--clr-bg-3) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--clr-bg-3) 75%),
linear-gradient(-45deg, transparent 75%, var(--clr-bg-3) 75%);
background-position:
0 0,
0 10px,
10px -10px,
-10px 0px;
background-size: 20px 20px;
}
.image-wrapper img {
display: block;
max-width: 100%;
max-height: 600px;
object-fit: contain;
border-radius: var(--radius-s);
}
.loading {
padding: 40px;
color: var(--clr-scale-ntrl-50);
text-align: center;
}
</style>
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.

The new ImageDiff component lacks test coverage. Given that the desktop app has comprehensive E2E testing (e.g., unifiedDiffView.cy.ts), this new feature should have corresponding tests to verify:

  1. Image loading for different change types (Addition, Deletion, Modification, Rename)
  2. Both workspace and commit diff scenarios
  3. Error handling when images fail to load
  4. Layout behavior for different window sizes

Consider adding E2E tests in a file like apps/desktop/cypress/e2e/imageDiff.cy.ts to cover these scenarios.

Copilot uses AI. Check for mistakes.
Comment on lines 90 to 105
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.
Copilot AI review requested due to automatic review settings December 6, 2025 21:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Added a doc comment to the get_blob_file function explaining its purpose, usage, and arguments. This improves code clarity for developers working with image diff previews and blob retrieval.
Updated the alt attributes for before and after images to include the file path and context, enhancing accessibility and clarity for screen readers.
Enhanced error handling to provide more specific messages when before or after images fail to load. Error messages now indicate which image failed and include error details from exceptions.
Introduces AbortSignal handling to image loading functions, allowing image fetches to be cancelled if the component unmounts or the effect is cleaned up. This improves resource management and prevents unnecessary work when the component is no longer active.
Copilot AI review requested due to automatic review settings December 7, 2025 14:08
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Replaces the inline image diff logic in the desktop app with a new reusable ImageDiff component in the UI package, supporting 2-up, swipe, and onion-skin comparison modes, metadata display, and loading states. Updates RangeInput to use a simpler, more accessible range fill style. Adjusts EmptyStatePlaceholder and SkeletonBone styles for consistency. Adds Storybook stories for ImageDiff. Updates exports in the UI index.
@vercel
Copy link

vercel bot commented Dec 7, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
gitbutler-web Ignored Ignored Preview Dec 7, 2025 10:03pm

@nshcr
Copy link
Contributor Author

nshcr commented Dec 8, 2025

@PavelLaptev Sorry I haven't had time to handle this PR over the past few days, and thank you so much for the updates you've made!

If Byron thinks the Rust changes look good and are ready to be merged, I'm completely fine with that as well — honestly, I don't really have a clear idea of what should be changed on the Rust side...

As for the frontend changes — they're fantastic! Far beyond what I originally expected for this feature.

The Swipe and Onion Skin modes are incredibly useful. I was thinking: for Swipe mode, would it be more intuitive to place the drag handle in the center of the image and use a semi-transparent center line to separate the before / after images? (similar to Squoosh)

Also, I think replacing the "before / after" text with "deleted / added" would be more appropriate and consistent with the labels used by GitHub. Looking back, using "before / after" was probably not the best choice on my part.

* use `open_isolated_repo()` to indicate we just need ODB access and nothing else matters.
* don't make the impression we support anything but full hashes to refer to objects.
  The UI knows these.
Copilot AI review requested due to automatic review settings December 8, 2025 04:16
@Byron
Copy link
Collaborator

Byron commented Dec 8, 2025

This looks fantastic now, amazing work!

Regarding Rust, I have rebased my refactor so now I'd think we are good on the Rust side.

CI seems to fail genuinely, but I wouldn't know how to fix it. But besides that, I think it's good to be merged.

Commit generation doesn’t see image files (probably all binaries, see image below).

Let's debug this in a follow-up. It's probably something about the backend side being made with text files in mind, somewhere, but I didn't try to reproduce it yet.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.

Comment on lines +34 to +82
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 }
: {
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 }
};
}
}
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.
Comment on lines +85 to +116
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;
}
}
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.
Added error handling to the image metadata loading logic in ImageDiff.svelte. Now, if loading metadata fails for either image, an error is logged and the corresponding metadata is set to null.
Clarified that backend service methods do not support AbortSignal for IO cancellation, and the signal is only used to prevent processing after abortion in the loadImage function.
Refactored the swipe comparison mode to support drag interaction using mouse and touch events. Added a visual handle for the swipe divider, improved accessibility with ARIA attributes, and separated swipe and onion-skin modes for clearer logic and styling.
Moved the isImageFile function from UnifiedDiffView.svelte to packages/shared/src/lib/utils/file.ts for reuse and improved maintainability. UnifiedDiffView now imports isImageFile from the shared utils module.
@PavelLaptev
Copy link
Contributor

The Swipe and Onion Skin modes are incredibly useful. I was thinking: for Swipe mode, would it be more intuitive to place the drag handle in the center of the image and use a semi-transparent center line to separate the before / after images? (similar to Squoosh)

Good idea. Done

Screen.Recording.2025-12-08.at.10.46.46.mov

@PavelLaptev
Copy link
Contributor

PavelLaptev commented Dec 8, 2025

Also, I think replacing the "before / after" text with "deleted / added" would be more appropriate and consistent with the labels used by GitHub. Looking back, using "before / after" was probably not the best choice on my part.

"After"/"Before" works well when comparing two images. It clearly shows what changed when an image was replaced.
However, it's confusing when there's only one image.

"Removed"/"Added" is more descriptive for single images, but less intuitive than "After"/"Before" for replacements.
Use "After"/"Before" when an image is replaced, and "Removed"/"Added" when only one image exists, typically "Added" (since "Removed" implies there was a previous image to compare against).

Screen.Recording.2025-12-08.at.10.57.05.mov

Moves sample image data URLs to the top of the file and sets them as default args for the ImageDiff story. This improves demonstration clarity and removes redundant code.
Copilot AI review requested due to automatic review settings December 8, 2025 10:13
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 11 comments.

Comment on lines +44 to +46
case 'Deletion':
return isCommitDiff
? { before: { type: 'commit' as const, path, commitId: `${commitId}^` }, after: null }
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.
Comment on lines +56 to +78
async function loadImageMetadata(url: string): Promise<ImageMetadata> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = async () => {
const metadata: ImageMetadata = {
width: img.naturalWidth,
height: img.naturalHeight
};
// Try to fetch file size
try {
const response = await fetch(url);
const blob = await response.blob();
metadata.size = blob.size;
} catch {
// Size not available, continue without it
}
resolve(metadata);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = url;
});
}
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 loadImageMetadata function fetches the image twice - once via new Image() to get dimensions, and then again via fetch() to get the file size. For data URLs (which is what this component receives), the second fetch will re-download/re-decode the entire image. Consider calculating the size from the data URL itself (by decoding the base64 string) or accepting it as a prop from the parent component, which already has access to the FileInfo with size information.

Copilot uses AI. Check for mistakes.
Introduces keyboard navigation for the image comparison slider, allowing users to adjust the swipe position using arrow keys, Home, End, and Shift for larger steps. Enhances accessibility and usability for keyboard users.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

PavelLaptev and others added 2 commits December 8, 2025 11:34
Eliminates the col-resize cursor style from the header element in ImageDiff.svelte, likely to improve UX or clarify that the header is not resizable.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings December 8, 2025 10:34
PavelLaptev and others added 3 commits December 8, 2025 11:35
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Added a typeof check for TouchEvent before using it in handleDragMove to prevent errors in environments where TouchEvent is undefined.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Comment on lines +80 to +98
$effect(() => {
if (beforeImageUrl) {
loadImageMetadata(beforeImageUrl)
.then((metadata) => (beforeImageMetadata = metadata))
.catch((error) => {
console.error('Failed to load before image metadata:', error);
beforeImageMetadata = null;
});
}
if (afterImageUrl) {
loadImageMetadata(afterImageUrl)
.then((metadata) => (afterImageMetadata = metadata))
.catch((error) => {
console.error('Failed to load after image metadata:', error);
afterImageMetadata = null;
});
}
});
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 $effect hook that loads image metadata (lines 80-98) doesn't clean up when beforeImageUrl or afterImageUrl changes. If the URLs change while metadata is still loading, the old promises will still resolve and update state, potentially causing a race condition where outdated metadata is displayed. Consider using an AbortController or a similar cleanup mechanism to cancel in-flight requests when dependencies change.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +139
function handleKeyDown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1; // Larger steps with Shift key
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
e.preventDefault();
swipePosition = Math.max(0, swipePosition - step);
break;
case 'ArrowRight':
case 'ArrowUp':
e.preventDefault();
swipePosition = Math.min(100, swipePosition + step);
break;
case 'Home':
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 event listeners for swipe functionality are only conditionally added when viewMode === 'swipe', but the event handlers (handleDragMove, handleDragEnd) check isDragging which could be true from a previous interaction. If a user starts dragging in swipe mode, then switches to another mode while dragging, isDragging will remain true and the drag handlers could be called if the user re-enables swipe mode. Consider resetting isDragging = false when the effect cleanup runs or when the view mode changes.

Copilot uses AI. Check for mistakes.
<div class="imagediff-placeholder">
<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.
Comment on lines +57 to +60
export function isImageFile(path: string): boolean {
const lowerPath = path.toLowerCase();
return IMAGE_EXTENSIONS.some((ext) => lowerPath.endsWith(ext));
}
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 new isImageFile function lacks test coverage. Since this file has comprehensive tests for other functions (see file.test.ts), this new function should also have test coverage to ensure it correctly identifies image files with various extensions and handles edge cases (e.g., uppercase extensions, files without extensions, etc.).

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +107
/// 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().context("Object is not a blob")?;
Ok(FileInfo::from_content(&relative_path, &blob.data))
}
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 get_blob_file function lacks test coverage. Since this is a new backend API function that handles file content retrieval, it should have tests to verify:

  • Correct blob loading from valid blob IDs
  • Proper error handling when blob ID is invalid or not found
  • Correct MIME type detection and content encoding

This is especially important as this function is used by the image diff feature and errors here could lead to broken image previews.

Copilot uses AI. Check for mistakes.
@PavelLaptev PavelLaptev merged commit fcfba0d into gitbutlerapp:master Dec 8, 2025
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

@gitbutler/desktop @gitbutler/ui rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants