diff --git a/Sources/GitKit/CLIGitService.swift b/Sources/GitKit/CLIGitService.swift index 7b63978..27ae4af 100644 --- a/Sources/GitKit/CLIGitService.swift +++ b/Sources/GitKit/CLIGitService.swift @@ -461,6 +461,12 @@ public struct CLIGitService: GitService { try? String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) } + public func fileData(at path: String, revision: String) async throws -> Data { + try Self.requireSafe(path, "path") + if !revision.isEmpty { try Self.requireSafe(revision, "revision") } + return try await runner.run(["show", "\(revision):\(path)"]) + } + public func resolveFile(path: String, contents: String) async throws { try Self.requireSafe(path, "path") try contents.write(to: root.appendingPathComponent(path), atomically: true, encoding: .utf8) diff --git a/Sources/GitKit/GitService.swift b/Sources/GitKit/GitService.swift index cce661d..debfbc4 100644 --- a/Sources/GitKit/GitService.swift +++ b/Sources/GitKit/GitService.swift @@ -234,6 +234,10 @@ public protocol GitService: Sendable { /// The working-tree contents of `path` (including any conflict markers). func fileContents(path: String) async -> String? + /// Raw file content at `revision` (e.g. `"HEAD"`, a SHA, or `""` for the index). + /// Used for binary file previews such as image diffs. + func fileData(at path: String, revision: String) async throws -> Data + /// Writes `contents` to `path` and stages it (marking a conflict resolved). func resolveFile(path: String, contents: String) async throws } diff --git a/Sources/GitKit/Models/Diff.swift b/Sources/GitKit/Models/Diff.swift index 65e9139..35ffc55 100644 --- a/Sources/GitKit/Models/Diff.swift +++ b/Sources/GitKit/Models/Diff.swift @@ -78,7 +78,27 @@ public struct FileDiff: Sendable { self.header = header } + /// Whether the file has a recognised image extension. + public var isImage: Bool { + Self.imageExtensions.contains((path as NSString).pathExtension.lowercased()) + } + + private static let imageExtensions: Set = [ + "png", "jpg", "jpeg", "gif", "webp", "tiff", "tif", "bmp", "svg", "ico", "heic", "heif", + ] + public static func empty(path: String) -> FileDiff { FileDiff(path: path, oldPath: nil, isBinary: false, isNew: false, isDeleted: false, hunks: []) } } + +/// The old and new image data for a binary image diff. +public struct ImageDiffData: Sendable { + public let oldImage: Data? + public let newImage: Data? + + public init(oldImage: Data?, newImage: Data?) { + self.oldImage = oldImage + self.newImage = newImage + } +} diff --git a/Sources/Gitify/Model/RepositoryViewModel.swift b/Sources/Gitify/Model/RepositoryViewModel.swift index 6c9da93..4084883 100644 --- a/Sources/Gitify/Model/RepositoryViewModel.swift +++ b/Sources/Gitify/Model/RepositoryViewModel.swift @@ -42,6 +42,7 @@ final class RepositoryViewModel { var selectedPath: String? private(set) var selectedStaged = false private(set) var currentDiff: FileDiff? + private(set) var imageDiffData: ImageDiffData? var commitMessage: String = "" var amendMode: Bool = false private(set) var isCommitting = false @@ -132,7 +133,28 @@ final class RepositoryViewModel { private func loadDiff(path: String, staged: Bool) async { guard let service else { return } - currentDiff = (try? await service.diff(path: path, staged: staged)) ?? .empty(path: path) + let diff = (try? await service.diff(path: path, staged: staged)) ?? .empty(path: path) + currentDiff = diff + if diff.isBinary && diff.isImage { + imageDiffData = await loadImageDiffData(diff: diff, staged: staged) + } else { + imageDiffData = nil + } + } + + private func loadImageDiffData(diff: FileDiff, staged: Bool) async -> ImageDiffData { + guard let service else { return ImageDiffData(oldImage: nil, newImage: nil) } + let oldPath = diff.oldPath ?? diff.path + let oldImage: Data? = diff.isNew ? nil : (try? await service.fileData(at: oldPath, revision: "HEAD")) + let newImage: Data? + if diff.isDeleted { + newImage = nil + } else if staged { + newImage = try? await service.fileData(at: diff.path, revision: "") + } else { + newImage = try? Data(contentsOf: service.root.appendingPathComponent(diff.path)) + } + return ImageDiffData(oldImage: oldImage, newImage: newImage) } /// Stages or unstages a single hunk of the currently-displayed diff. The direction is @@ -280,6 +302,7 @@ final class RepositoryViewModel { } else { selectedPath = nil currentDiff = nil + imageDiffData = nil } } // Refresh history so amend/commit changes are reflected. @@ -511,6 +534,18 @@ final class RepositoryViewModel { return try? await service.commitFileDiff(sha: sha, path: path) } + func commitImageDiffData(sha: String, parentSHA: String?, diff: FileDiff) async -> ImageDiffData? { + guard let service, diff.isBinary, diff.isImage else { return nil } + let oldPath = diff.oldPath ?? diff.path + let oldImage: Data? = if let parentSHA, !diff.isNew { + try? await service.fileData(at: oldPath, revision: parentSHA) + } else { + nil + } + let newImage: Data? = diff.isDeleted ? nil : (try? await service.fileData(at: diff.path, revision: sha)) + return ImageDiffData(oldImage: oldImage, newImage: newImage) + } + func merge(branch: String, squash: Bool, noFastForward: Bool, noCommit: Bool, skipHooks: Bool) async { await runIntegration("Merge") { try await $0.merge(branch: branch, squash: squash, noFastForward: noFastForward, @@ -650,6 +685,7 @@ final class RepositoryViewModel { } else { selectedPath = nil currentDiff = nil + imageDiffData = nil } } } diff --git a/Sources/Gitify/Views/CommitDetailView.swift b/Sources/Gitify/Views/CommitDetailView.swift index 0a4036b..f7323f5 100644 --- a/Sources/Gitify/Views/CommitDetailView.swift +++ b/Sources/Gitify/Views/CommitDetailView.swift @@ -11,7 +11,7 @@ struct CommitDetailView: View { VStack(alignment: .leading, spacing: 0) { metadata Divider() - ChangesetDiffPane(ref: commit.id, viewModel: viewModel) + ChangesetDiffPane(ref: commit.id, parentSHA: commit.parents.first, viewModel: viewModel) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -46,11 +46,13 @@ struct CommitDetailView: View { /// changed files with +/- counts on top, and the selected file's diff below. struct ChangesetDiffPane: View { let ref: String + var parentSHA: String? = nil let viewModel: RepositoryViewModel @State private var changes: [FileChange] = [] @State private var selectedFile: String? @State private var fileDiff: FileDiff? + @State private var imageDiffData: ImageDiffData? @State private var loading = false var body: some View { @@ -64,14 +66,23 @@ struct ChangesetDiffPane: View { } .frame(maxWidth: .infinity, minHeight: 140) - CommitFileDiffPane(fileDiff: fileDiff) + CommitFileDiffPane(fileDiff: fileDiff, imageDiffData: imageDiffData) .frame(maxWidth: .infinity, minHeight: 120) } .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: ref) { await load() } .onChange(of: selectedFile) { _, path in - guard let path else { fileDiff = nil; return } - Task { fileDiff = await viewModel.commitFileDiff(ref, path: path) } + guard let path else { fileDiff = nil; imageDiffData = nil; return } + Task { + let diff = await viewModel.commitFileDiff(ref, path: path) + fileDiff = diff + if let diff { + imageDiffData = await viewModel.commitImageDiffData( + sha: ref, parentSHA: parentSHA, diff: diff) + } else { + imageDiffData = nil + } + } } } @@ -122,13 +133,14 @@ struct ChangesetDiffPane: View { /// Concrete (not an inline if/else) so selecting a changed file doesn't reset the dragged divider. private struct CommitFileDiffPane: View { let fileDiff: FileDiff? + var imageDiffData: ImageDiffData? = nil var body: some View { // GeometryReader so the pane keeps a constant (greedy) size whether the diff or the // placeholder is shown — otherwise VSplitView shifts the divider on file selection. GeometryReader { _ in if let fileDiff { - DiffView(diff: fileDiff) + DiffView(diff: fileDiff, imageDiffData: imageDiffData) } else { ContentUnavailableView("No File Selected", systemImage: "doc.text.magnifyingglass", description: Text("Select a changed file to view its diff.")) diff --git a/Sources/Gitify/Views/DiffView.swift b/Sources/Gitify/Views/DiffView.swift index d9dda85..aee1089 100644 --- a/Sources/Gitify/Views/DiffView.swift +++ b/Sources/Gitify/Views/DiffView.swift @@ -9,6 +9,7 @@ private struct LineRef: Hashable { let hunk: Int; let index: Int } /// `onApplyLines` is provided, individual added/removed lines can be selected and staged. struct DiffView: View { let diff: FileDiff + var imageDiffData: ImageDiffData? = nil var actionLabel: String = "Stage Hunk" var lineActionLabel: String = "Stage Lines" var onApplyHunk: ((DiffHunk) -> Void)? = nil @@ -21,9 +22,11 @@ struct DiffView: View { private var language: String { (diff.path as NSString).pathExtension.lowercased() } var body: some View { - if diff.isBinary { + if diff.isBinary, let imageDiffData { + ImageDiffView(data: imageDiffData, isNew: diff.isNew, isDeleted: diff.isDeleted) + } else if diff.isBinary { ContentUnavailableView("Binary File", systemImage: "doc.badge.gearshape", - description: Text("Binary content can't be displayed.")) + description: Text("Binary content can\u{2019}t be displayed.")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if diff.hunks.isEmpty { ContentUnavailableView("No Changes", systemImage: "doc.plaintext", diff --git a/Sources/Gitify/Views/ImageDiffView.swift b/Sources/Gitify/Views/ImageDiffView.swift new file mode 100644 index 0000000..12d2705 --- /dev/null +++ b/Sources/Gitify/Views/ImageDiffView.swift @@ -0,0 +1,306 @@ +import SwiftUI +import GitKit + +/// Image comparison view with side-by-side and swipe scrubber modes. +struct ImageDiffView: View { + let data: ImageDiffData + let isNew: Bool + let isDeleted: Bool + + private enum Mode: String, CaseIterable { + case sideBySide = "Side by Side" + case swipe = "Swipe" + case before = "Before" + case after = "After" + } + @State private var mode: Mode = .sideBySide + @State private var scrubberFraction: CGFloat = 0.5 + + /// Whether both images are available (scrubbing only makes sense with two images). + private var hasBothImages: Bool { + data.oldImage != nil && data.newImage != nil + } + + var body: some View { + VStack(spacing: 0) { + if hasBothImages { + header + } + switch mode { + case .swipe where hasBothImages: + swipeBody + case .before: + singlePanel(label: "Before", imageData: data.oldImage, + placeholder: isNew ? "New File" : nil) + case .after: + singlePanel(label: "After", imageData: data.newImage, + placeholder: isDeleted ? "Deleted" : nil) + default: + sideBySideBody + } + } + .background(Color(nsColor: .controlBackgroundColor)) + } + + // MARK: - Header + + private var header: some View { + HStack { + Picker("", selection: $mode) { + ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + Spacer() + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(.bar) + .overlay(Rectangle().frame(height: 1).foregroundStyle(.separator), alignment: .bottom) + } + + // MARK: - Side by Side + + private var sideBySideBody: some View { + GeometryReader { geo in + let panelWidth = (geo.size.width - 1) / 2 + let panelContentHeight = geo.size.height - 30 // label bar height + let sharedSize: CGSize? = { + guard let oldData = data.oldImage, let oldImg = NSImage(data: oldData), + let newData = data.newImage, let newImg = NSImage(data: newData) else { return nil } + let padding: CGFloat = 16 + let available = CGSize(width: panelWidth - padding * 2, + height: panelContentHeight - padding * 2 - 24) + return fittedSize(old: oldImg, new: newImg, within: available) + }() + + HStack(spacing: 1) { + panel(label: "Before", imageData: data.oldImage, + placeholder: isNew ? "New File" : nil, sharedSize: sharedSize) + panel(label: "After", imageData: data.newImage, + placeholder: isDeleted ? "Deleted" : nil, sharedSize: sharedSize) + } + } + } + + private func panel(label: String, imageData: Data?, placeholder: String?, + sharedSize: CGSize?) -> some View { + VStack(spacing: 0) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.bar) + .overlay(Rectangle().frame(height: 1).foregroundStyle(.separator), alignment: .bottom) + + if let placeholder { + ContentUnavailableView(placeholder, systemImage: "photo", + description: Text("No image to display.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let imageData, let nsImage = NSImage(data: imageData) { + imageContent(nsImage, data: imageData, sharedSize: sharedSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView("Unable to Load", systemImage: "exclamationmark.triangle", + description: Text("The image could not be loaded.")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func imageContent(_ nsImage: NSImage, data: Data, sharedSize: CGSize?) -> some View { + VStack(spacing: 4) { + Spacer() + if let sharedSize { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: sharedSize.width, maxHeight: sharedSize.height) + .padding(16) + .background { + checkerboard + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } else { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(16) + .background { + checkerboard + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + Spacer() + imageMetadata(nsImage, data: data) + } + } + + // MARK: - Single Image (Before / After) + + private func singlePanel(label: String, imageData: Data?, placeholder: String?) -> some View { + panel(label: label, imageData: imageData, placeholder: placeholder, sharedSize: nil) + } + + // MARK: - Swipe / Scrubber + + private var swipeBody: some View { + GeometryReader { geo in + let padding: CGFloat = 16 + let available = CGSize(width: geo.size.width - padding * 2, + height: geo.size.height - padding * 2 - 28) + // Both images must exist (checked by hasBothImages). + let oldImage = NSImage(data: data.oldImage!)! + let newImage = NSImage(data: data.newImage!)! + let imageSize = fittedSize(old: oldImage, new: newImage, within: available) + let originX = (geo.size.width - imageSize.width) / 2 + let originY = padding + let dividerX = originX + imageSize.width * scrubberFraction + + ZStack(alignment: .topLeading) { + checkerboard + + // Old image (full, behind) + Image(nsImage: oldImage) + .resizable() + .frame(width: imageSize.width, height: imageSize.height) + .position(x: originX + imageSize.width / 2, y: originY + imageSize.height / 2) + + // New image (clipped from the scrubber rightward) + Image(nsImage: newImage) + .resizable() + .frame(width: imageSize.width, height: imageSize.height) + .clipShape( + ClipFromLeft(fraction: scrubberFraction) + ) + .position(x: originX + imageSize.width / 2, y: originY + imageSize.height / 2) + + // Scrubber divider line + Rectangle() + .fill(Color.white) + .frame(width: 2, height: imageSize.height) + .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 0) + .overlay(scrubberHandle, alignment: .center) + .position(x: dividerX, y: originY + imageSize.height / 2) + + // Labels + Text("Before") + .font(.caption2.weight(.medium)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.6), radius: 2) + .position(x: originX + 36, y: originY + 14) + + Text("After") + .font(.caption2.weight(.medium)) + .foregroundStyle(.white) + .shadow(color: .black.opacity(0.6), radius: 2) + .position(x: originX + imageSize.width - 30, y: originY + 14) + + // Metadata bar + swipeMetadata(old: oldImage, new: newImage) + .position(x: geo.size.width / 2, + y: originY + imageSize.height + 18) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let fraction = (value.location.x - originX) / imageSize.width + scrubberFraction = min(max(fraction, 0), 1) + } + ) + } + } + + private var scrubberHandle: some View { + Capsule() + .fill(Color.white) + .shadow(color: .black.opacity(0.3), radius: 3) + .frame(width: 8, height: 36) + .overlay { + HStack(spacing: 2) { + ForEach(0..<2, id: \.self) { _ in + RoundedRectangle(cornerRadius: 0.5) + .fill(Color.gray) + .frame(width: 1, height: 12) + } + } + } + } + + private func swipeMetadata(old: NSImage, new: NSImage) -> some View { + HStack(spacing: 20) { + HStack(spacing: 8) { + Circle().fill(Color.red.opacity(0.7)).frame(width: 6, height: 6) + if let rep = old.representations.first { + Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)") + .font(.caption2).foregroundStyle(.secondary) + } + Text(byteCountDescription(data.oldImage!.count)) + .font(.caption2).foregroundStyle(.secondary) + } + HStack(spacing: 8) { + Circle().fill(Color.green.opacity(0.7)).frame(width: 6, height: 6) + if let rep = new.representations.first { + Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)") + .font(.caption2).foregroundStyle(.secondary) + } + Text(byteCountDescription(data.newImage!.count)) + .font(.caption2).foregroundStyle(.secondary) + } + } + } + + /// Computes the largest size that fits both images at the same scale within `bounds`. + private func fittedSize(old: NSImage, new: NSImage, within bounds: CGSize) -> CGSize { + let maxW = max(old.size.width, new.size.width) + let maxH = max(old.size.height, new.size.height) + guard maxW > 0, maxH > 0 else { return .zero } + let scale = min(bounds.width / maxW, bounds.height / maxH, 1) + return CGSize(width: maxW * scale, height: maxH * scale) + } + + // MARK: - Shared + + private func imageMetadata(_ nsImage: NSImage, data: Data) -> some View { + HStack(spacing: 12) { + if let rep = nsImage.representations.first { + Text("\(rep.pixelsWide) \u{00d7} \(rep.pixelsHigh)") + .font(.caption2).foregroundStyle(.secondary) + } + Text(byteCountDescription(data.count)) + .font(.caption2).foregroundStyle(.secondary) + } + .padding(.bottom, 8) + } + + private var checkerboard: some View { + Canvas { context, size in + let step: CGFloat = 8 + let light = Color(white: 0.9) + let dark = Color(white: 0.75) + for row in 0.. String { + ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file) + } +} + +/// Clips to the right portion of the view, starting at `fraction` of the width. +private struct ClipFromLeft: Shape { + var fraction: CGFloat + + func path(in rect: CGRect) -> Path { + Path(CGRect(x: rect.minX + rect.width * fraction, y: rect.minY, + width: rect.width * (1 - fraction), height: rect.height)) + } +} diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index c956fb8..5388f17 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -224,6 +224,7 @@ private struct DiffPane: View { GeometryReader { _ in if let diff = viewModel.currentDiff { DiffView(diff: diff, + imageDiffData: viewModel.imageDiffData, actionLabel: viewModel.selectedStaged ? "Unstage Hunk" : "Stage Hunk", lineActionLabel: viewModel.selectedStaged ? "Unstage Lines" : "Stage Lines", onApplyHunk: { hunk in Task { await viewModel.applyHunk(hunk) } },