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
Binary file added IconCommitPlus/Dark-1024@1x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added IconCommitPlus/Default-1024@1x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions macgit/Models/ImageComparisonMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated enum ImageComparisonMode: String, CaseIterable, Identifiable {
case sideBySide
case overlay
var id: String { rawValue }
var title: String { self == .overlay ? "Overlay Slider" : "Side by Side" }
var icon: String { self == .overlay ? "slider.horizontal.below.rectangle" : "rectangle.split.2x1" }
}
25 changes: 25 additions & 0 deletions macgit/Models/ImageComparisonRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

nonisolated struct ImageComparisonRequest: Hashable, Sendable {
enum Source: Hashable, Sendable {
case revision(String, String)
case index(String)
case indexWithFallback(String, String)
case disk(String)
}
let before: Source?
let after: Source?

static func working(_ file: StatusFile, staged: Bool) -> Self {
Self(before: file.status == .untracked || file.status == .added ? nil :
(staged ? .revision("HEAD", file.originalPath ?? file.path) :
(file.status == .renamed ? .indexWithFallback(file.path, file.originalPath ?? file.path) : .index(file.path))),
after: file.status == .deleted ? nil : (staged ? .index(file.path) : .disk(file.path)))
Comment thread
Tranthanh98 marked this conversation as resolved.
}

static func commit(path: String, oldPath: String?, status: CommitFileStatus?, ref: String) -> Self {
Self(before: status == .added ? nil : .revision("\(ref)^1", oldPath ?? path),
after: status == .deleted ? nil : .revision(ref, path))
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions macgit/Resources/AppIcon.icon/icon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill" : {
"linear-gradient" : [
"extended-srgb:1.00000,0.21961,0.23529,1.00000",
"extended-srgb:1.00000,0.55294,0.15686,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"glass" : true,
"image-name" : "Branch (1).png",
"name" : "Branch (1)",
"position" : {
"scale" : 1.62,
"translation-in-points" : [
95.921875,
-75.0703125
]
}
},
{
"blend-mode" : "lighten",
"fill" : "automatic",
"glass" : true,
"image-name" : "Commit (1).png",
"name" : "Commit (1)",
"position" : {
"scale" : 1.35,
"translation-in-points" : [
-108.4921875,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
{
"filename": "logo.png",
"idiom": "universal"
},
{
"filename": "logo-dark.png",
"idiom": "universal",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions macgit/Services/GitStatusService+ImageComparison.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

extension GitStatusService {
func imageData(from source: ImageComparisonRequest.Source, in repositoryURL: URL) async throws -> Data {
switch source {
case let .revision(ref, path):
return try await showFile(at: path, ref: ref, in: repositoryURL)
case let .index(path):
return try await indexFile(at: path, in: repositoryURL)
case let .indexWithFallback(path, originalPath):
// Paired worktree renames may have either path in the index.
do {
return try await indexFile(at: path, in: repositoryURL)
} catch {
try Task.checkCancellation()
guard path != originalPath else { throw error }
return try await indexFile(at: originalPath, in: repositoryURL)
}
case let .disk(path):
return try await Task.detached(priority: .userInitiated) {
let root = repositoryURL.standardizedFileURL.resolvingSymlinksInPath()
let file = repositoryURL.appendingPathComponent(path).standardizedFileURL.resolvingSymlinksInPath()
let rootComponents = root.pathComponents
guard file.pathComponents.count > rootComponents.count,
Array(file.pathComponents.prefix(rootComponents.count)) == rootComponents else {
throw GitError.commandFailed("Image preview paths must remain inside the repository.")
}
return try Data(contentsOf: file)
}.value
Comment thread
Tranthanh98 marked this conversation as resolved.
}
}
}
56 changes: 15 additions & 41 deletions macgit/Views/Common/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ struct DiffView: View {
let onError: (String) -> Void
let filePath: String?
let gitRef: String?
let imageComparisonMode: ImageComparisonMode
let imageOldPath: String?
let imageFileStatus: CommitFileStatus?
let commitPatchDisabledReason: String?
let prefersTextDiff: Bool
let onCommitPatch: (([DiffLine], CommitPatchRequest.Direction, String) -> Void)?
Expand All @@ -50,6 +53,9 @@ struct DiffView: View {
onError: @escaping (String) -> Void = { _ in },
filePath: String? = nil,
gitRef: String? = nil,
imageComparisonMode: ImageComparisonMode = .overlay,
imageOldPath: String? = nil,
imageFileStatus: CommitFileStatus? = nil,
prefersTextDiff: Bool = false,
commitPatchDisabledReason: String? = nil,
onCommitPatch: (([DiffLine], CommitPatchRequest.Direction, String) -> Void)? = nil
Expand All @@ -62,14 +68,16 @@ struct DiffView: View {
self.onError = onError
self.filePath = filePath
self.gitRef = gitRef
self.imageComparisonMode = imageComparisonMode
self.imageOldPath = imageOldPath
self.imageFileStatus = imageFileStatus
self.prefersTextDiff = prefersTextDiff
self.commitPatchDisabledReason = commitPatchDisabledReason
self.onCommitPatch = onCommitPatch
}

@State private var selectedLineIDs: Set<UUID> = []
@State private var lastSelectedLineID: UUID?
@State private var loadedImage: NSImage?

private static let imageExtensions: Set<String> = [
"png", "jpg", "jpeg", "gif", "svg", "webp", "bmp",
Expand Down Expand Up @@ -171,51 +179,17 @@ struct DiffView: View {

@ViewBuilder
private var imagePreview: some View {
if let file = file, let url = repositoryURL {
let fileURL = url.appendingPathComponent(file.path)
diskImagePreview(fileURL: fileURL, filePath: file.path)
} else if let ref = gitRef, let url = repositoryURL, let path = filePath {
gitImagePreview(ref: ref, url: url, path: path)
if let file, let repositoryURL {
ImageComparisonView(request: .working(file, staged: file.status == .staged || file.status == .added || file.status == .renamed),
repositoryURL: repositoryURL, mode: imageComparisonMode)
} else if let ref = gitRef, let repositoryURL, let path = filePath {
ImageComparisonView(request: .commit(path: path, oldPath: imageOldPath, status: imageFileStatus, ref: ref),
repositoryURL: repositoryURL, mode: imageComparisonMode)
} else {
EmptyStateView(icon: "photo", message: "Unable to preview image")
}
}

private func diskImagePreview(fileURL: URL, filePath: String) -> some View {
Group {
if let nsImage = NSImage(contentsOf: fileURL) {
FileImagePreview(image: nsImage)
} else {
EmptyStateView(icon: "photo", message: "Unable to preview image", detail: filePath)
}
}
}

private func gitImagePreview(ref: String, url: URL, path: String) -> some View {
Group {
if let image = loadedImage {
FileImagePreview(image: image)
} else {
ProgressView("Loading image…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task {
await loadImageFromGit(ref: ref, url: url, path: path)
}
}

private func loadImageFromGit(ref: String, url: URL, path: String) async {
do {
let data = try await GitStatusService.shared.showFile(at: path, ref: ref, in: url)
if let image = NSImage(data: data) {
loadedImage = image
}
} catch {
onError("Failed to load image: \(error.localizedDescription)")
}
}


}

Expand Down
25 changes: 25 additions & 0 deletions macgit/Views/Common/ImageComparisonModeButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI

struct ImageComparisonModeButton: View {
@Binding var mode: ImageComparisonMode

private var nextMode: ImageComparisonMode {
mode == .overlay ? .sideBySide : .overlay
}

var body: some View {
Button {
mode = nextMode
} label: {
Image(systemName: mode.icon)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.fixedSize()
.help("Switch to \(nextMode.title)")
.accessibilityLabel("Switch image comparison to \(nextMode.title)")
.accessibilityValue(mode.title)
}
}
52 changes: 52 additions & 0 deletions macgit/Views/Common/ImageComparisonOverlay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI

struct ImageComparisonOverlay: View {
let before: NSImage
let after: NSImage
@Binding var fraction: Double

var body: some View {
GeometryReader { geometry in
let size = geometry.size
let width = max(before.size.width, after.size.width, 1)
let height = max(before.size.height, after.size.height, 1)
let scale = max(0, min(size.width / width, size.height / height))
let imageWidth = width * scale
let imageOffset = (size.width - imageWidth) / 2
let dividerX = imageOffset + imageWidth * fraction
ZStack {
Color(nsColor: .textBackgroundColor)
image(after, scale: scale)
.frame(width: width * scale, height: height * scale, alignment: .topLeading)
ZStack {
Color(nsColor: .textBackgroundColor)
image(before, scale: scale)
.frame(width: width * scale, height: height * scale, alignment: .topLeading)
}
.frame(width: size.width, height: size.height)
.mask(alignment: .leading) {
Rectangle().frame(width: dividerX)
}
Rectangle().fill(.primary).frame(width: 2)
.position(x: dividerX, y: size.height / 2)
Image(systemName: "arrow.left.and.right")
.font(.caption.bold()).padding(8)
.background(.regularMaterial, in: Circle())
.position(x: dividerX, y: size.height / 2)
}
.clipped()
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0).onChanged { value in
fraction = min(1, max(0, (value.location.x - imageOffset) / max(imageWidth, 1)))
})
.accessibilityElement(children: .ignore)
.accessibilityLabel("Before and after image comparison")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func image(_ image: NSImage, scale: CGFloat) -> some View {
Image(nsImage: image).resizable()
.frame(width: image.size.width * scale, height: image.size.height * scale)
}
}
28 changes: 28 additions & 0 deletions macgit/Views/Common/ImageComparisonSide.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI

struct ImageComparisonSide: View {
let title: String?
let image: NSImage?
let error: String?
let isMissing: Bool
var body: some View {
VStack(spacing: 0) {
if let title {
Text(title).font(.caption.weight(.semibold)).padding(10)
Divider()
}
if let image {
ImagePreviewViewport {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
}
} else {
EmptyStateView(icon: "photo", message: isMissing ? "No image" : "Unable to preview image", detail: error)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Loading
Loading