-
Notifications
You must be signed in to change notification settings - Fork 728
feat: add ImageDiff component to load and show before/after images #11456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d311099
72da0e7
0aa478f
151dd9c
1c20615
878d1e3
1955e95
83489e5
08c9a14
ee26491
ccc131e
2608e18
f1331e8
7f8806f
f3633f4
83f391b
45b972d
35dca4b
c0d2682
d5249ad
4df7c2b
018291c
5e63987
8501a2f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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 } | ||
| }; | ||
| } | ||
| } | ||
|
Comment on lines
+34
to
+82
|
||
| // 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); | ||
PavelLaptev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| 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'; | ||
PavelLaptev marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
PavelLaptev marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| $effect(() => { | ||
| loadImages(); | ||
| }); | ||
PavelLaptev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </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" /> | ||
PavelLaptev marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| </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> | ||
|
Comment on lines
1
to
197
|
||
| 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'; | ||
|
|
@@ -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' | ||
| ]; | ||
PavelLaptev marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| const lowerPath = path.toLowerCase(); | ||
| return imageExtensions.some((ext) => lowerPath.endsWith(ext)); | ||
| } | ||
|
||
| function filter(hunks: DiffHunk[]): DiffHunk[] { | ||
| if (selectionId.type !== 'worktree') return hunks; | ||
| // TODO: It does concern me that this is an N+1; | ||
|
|
@@ -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} | ||
|
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I avoided using
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a good call. The 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. |
There was a problem hiding this comment.
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.