diff --git a/IconCommitPlus/Dark-1024@1x.png b/IconCommitPlus/Dark-1024@1x.png new file mode 100644 index 00000000..88e60283 Binary files /dev/null and b/IconCommitPlus/Dark-1024@1x.png differ diff --git a/IconCommitPlus/Default-1024@1x.png b/IconCommitPlus/Default-1024@1x.png new file mode 100644 index 00000000..86c26f03 Binary files /dev/null and b/IconCommitPlus/Default-1024@1x.png differ diff --git a/macgit/Models/ImageComparisonMode.swift b/macgit/Models/ImageComparisonMode.swift new file mode 100644 index 00000000..85f3aea7 --- /dev/null +++ b/macgit/Models/ImageComparisonMode.swift @@ -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" } +} diff --git a/macgit/Models/ImageComparisonRequest.swift b/macgit/Models/ImageComparisonRequest.swift new file mode 100644 index 00000000..77cf89dd --- /dev/null +++ b/macgit/Models/ImageComparisonRequest.swift @@ -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))) + } + + 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)) + } +} diff --git a/macgit/Resources/AppIcon.icon/Assets/Branch (1).png b/macgit/Resources/AppIcon.icon/Assets/Branch (1).png new file mode 100644 index 00000000..c797c919 Binary files /dev/null and b/macgit/Resources/AppIcon.icon/Assets/Branch (1).png differ diff --git a/macgit/Resources/AppIcon.icon/Assets/Commit (1).png b/macgit/Resources/AppIcon.icon/Assets/Commit (1).png new file mode 100644 index 00000000..2c2ca165 Binary files /dev/null and b/macgit/Resources/AppIcon.icon/Assets/Commit (1).png differ diff --git a/macgit/Resources/AppIcon.icon/icon.json b/macgit/Resources/AppIcon.icon/icon.json new file mode 100644 index 00000000..229fc6b3 --- /dev/null +++ b/macgit/Resources/AppIcon.icon/icon.json @@ -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" + } +} \ No newline at end of file diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png index 01fd254a..a3f805d8 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png index 970febc2..964445de 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png index 51fd14b9..a1c0c0b2 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png index 8ed58fa7..7c1936f1 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png index 970febc2..964445de 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png index 36a79fda..290d1349 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png index 8ed58fa7..7c1936f1 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png index a695c28c..2abc2232 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png index 36a79fda..290d1349 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png differ diff --git a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index ee32881e..86c26f03 100644 Binary files a/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/macgit/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/Contents.json b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/Contents.json index fd6c47cb..2758c8b5 100644 --- a/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/Contents.json +++ b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename": "logo.png", "idiom": "universal" + }, + { + "filename": "logo-dark.png", + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] } ], "info": { diff --git a/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo-dark.png b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo-dark.png new file mode 100644 index 00000000..15e23f94 Binary files /dev/null and b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo-dark.png differ diff --git a/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo.png b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo.png index 36a79fda..290d1349 100644 Binary files a/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo.png and b/macgit/Resources/Assets.xcassets/commitplus-logo.imageset/logo.png differ diff --git a/macgit/Services/GitStatusService+ImageComparison.swift b/macgit/Services/GitStatusService+ImageComparison.swift new file mode 100644 index 00000000..4275da7b --- /dev/null +++ b/macgit/Services/GitStatusService+ImageComparison.swift @@ -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 + } + } +} diff --git a/macgit/Views/Common/DiffView.swift b/macgit/Views/Common/DiffView.swift index 587f50c0..4b781358 100644 --- a/macgit/Views/Common/DiffView.swift +++ b/macgit/Views/Common/DiffView.swift @@ -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)? @@ -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 @@ -62,6 +68,9 @@ 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 @@ -69,7 +78,6 @@ struct DiffView: View { @State private var selectedLineIDs: Set = [] @State private var lastSelectedLineID: UUID? - @State private var loadedImage: NSImage? private static let imageExtensions: Set = [ "png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", @@ -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)") - } - } - } diff --git a/macgit/Views/Common/ImageComparisonModeButton.swift b/macgit/Views/Common/ImageComparisonModeButton.swift new file mode 100644 index 00000000..f1a1ed4f --- /dev/null +++ b/macgit/Views/Common/ImageComparisonModeButton.swift @@ -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) + } +} diff --git a/macgit/Views/Common/ImageComparisonOverlay.swift b/macgit/Views/Common/ImageComparisonOverlay.swift new file mode 100644 index 00000000..143e3e6f --- /dev/null +++ b/macgit/Views/Common/ImageComparisonOverlay.swift @@ -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) + } +} diff --git a/macgit/Views/Common/ImageComparisonSide.swift b/macgit/Views/Common/ImageComparisonSide.swift new file mode 100644 index 00000000..7c435c39 --- /dev/null +++ b/macgit/Views/Common/ImageComparisonSide.swift @@ -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) + } +} diff --git a/macgit/Views/Common/ImageComparisonView.swift b/macgit/Views/Common/ImageComparisonView.swift new file mode 100644 index 00000000..464d8e48 --- /dev/null +++ b/macgit/Views/Common/ImageComparisonView.swift @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct ImageComparisonView: View { + let request: ImageComparisonRequest + let repositoryURL: URL + let mode: ImageComparisonMode + var refreshID: UUID? = nil + @State private var before: NSImage? + @State private var after: NSImage? + @State private var beforeError: String? + @State private var afterError: String? + @State private var isLoading = true + @State private var fraction = 0.5 + + private struct LoadID: Hashable { + let request: ImageComparisonRequest + let repositoryURL: URL + let refreshID: UUID? + } + + var body: some View { + Group { + if isLoading { + ProgressView("Loading images…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if request.before == nil { + ImageComparisonSide(title: nil, image: after, error: afterError, + isMissing: request.after == nil) + } else if mode == .overlay, let before, let after { + VStack(spacing: 12) { + HStack { + Text("Before").foregroundStyle(.red) + Spacer() + Text("After").foregroundStyle(.green) + }.font(.caption.weight(.semibold)) + ImagePreviewViewport { + ImageComparisonOverlay(before: before, after: after, fraction: $fraction) + } + Slider(value: $fraction, in: 0...1) + .accessibilityLabel("Image comparison divider") + .help("Drag to reveal the before and after images") + }.padding(12) + } else { + HStack(spacing: 0) { + ImageComparisonSide(title: "Before", image: before, error: beforeError, + isMissing: request.before == nil) + Divider() + ImageComparisonSide(title: "After", image: after, error: afterError, + isMissing: request.after == nil) + } + } + } + .task(id: LoadID(request: request, repositoryURL: repositoryURL, refreshID: refreshID)) { + await load() + } + } + + private func load() async { + isLoading = true + before = nil + after = nil + beforeError = nil + afterError = nil + fraction = 0.5 + for (isBefore, source) in [(true, request.before), (false, request.after)] { + guard let source else { continue } + do { + let data = try await GitStatusService.shared.imageData(from: source, in: repositoryURL) + try Task.checkCancellation() + guard let image = NSImage(data: data) else { + throw GitError.commandFailed("This image format cannot be previewed, or its content is unavailable locally.") + } + if isBefore { before = image } else { after = image } + } catch { + guard !Task.isCancelled else { return } + if isBefore { beforeError = error.localizedDescription } else { afterError = error.localizedDescription } + } + } + guard !Task.isCancelled else { return } + isLoading = false + } +} diff --git a/macgit/Views/Common/ImagePreviewViewport.swift b/macgit/Views/Common/ImagePreviewViewport.swift new file mode 100644 index 00000000..31644768 --- /dev/null +++ b/macgit/Views/Common/ImagePreviewViewport.swift @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +/// Fits the preview to its viewport, scrolling only below the minimum usable height. +struct ImagePreviewViewport: View { + private let minimumHeight: CGFloat = 240 + @ViewBuilder let content: () -> Content + + var body: some View { + GeometryReader { geometry in + let height = max(minimumHeight, geometry.size.height) + ScrollView(.vertical) { + content() + .frame(width: max(0, geometry.size.width), height: height) + } + .scrollDisabled(geometry.size.height >= minimumHeight) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 3c75ff4f..ab8a1a18 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -59,6 +59,7 @@ struct FileStatusView: View { @State private var lfsPaths = Set() @State private var stagedLFSPaths = Set() @State private var changedFiles: [StatusFile] = [] + @State private var imageComparisonMode: ImageComparisonMode = .overlay @State private var selectedFile: StatusFile? = nil @State private var selectedFileKey: FileStatusSelectionKey? = nil @State private var diffRefreshID = UUID() @@ -837,7 +838,11 @@ struct FileStatusView: View { Text(file.path) .font(.system(size: 14, weight: .semibold)) .lineLimit(1) - Spacer() + .truncationMode(.middle) + Spacer(minLength: 8) + if file.isImage && file.status != .added && file.status != .untracked { + ImageComparisonModeButton(mode: $imageComparisonMode) + } } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -1358,26 +1363,8 @@ struct FileStatusView: View { } private func imagePreview(file: StatusFile) -> some View { - let fileURL = repositoryURL.appendingPathComponent(file.path) - return Group { - if let nsImage = NSImage(contentsOf: fileURL) { - GeometryReader { geo in - ScrollView(.vertical) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: geo.size.width, alignment: .top) - .clipped() - } - } - } else { - EmptyStateView( - icon: "photo", - message: "Unable to preview image", - detail: file.path - ) - } - } + ImageComparisonView(request: .working(file, staged: selectedFileKey?.isStaged == true), + repositoryURL: repositoryURL, mode: imageComparisonMode, refreshID: diffRefreshID) } private func videoPreview(file: StatusFile) -> some View { diff --git a/macgit/Views/History/HistoryCommitDetailView.swift b/macgit/Views/History/HistoryCommitDetailView.swift index 673a7a65..e30f0cc4 100644 --- a/macgit/Views/History/HistoryCommitDetailView.swift +++ b/macgit/Views/History/HistoryCommitDetailView.swift @@ -10,6 +10,8 @@ struct HistoryCommitDetailView: View { @Environment(\.appTextScale) private var textScale let onOpenFile: (String) -> Void + @State private var imageComparisonMode: ImageComparisonMode = .overlay + var body: some View { @Bindable var patch = model.patchController Group { @@ -127,7 +129,11 @@ struct HistoryCommitDetailView: View { .foregroundStyle(.primary).font(.system(size: 14, weight: .medium)) Text(file.path).font(.system(size: 13, weight: .semibold).scaled(by: textScale)) .lineLimit(1) - Spacer() + .truncationMode(.middle) + Spacer(minLength: 8) + if file.status != .added && StatusFile(path: file.path, status: .modified, originalPath: nil).isImage { + ImageComparisonModeButton(mode: $imageComparisonMode) + } } .padding(.horizontal, 12).padding(.vertical, 6) .background(.ultraThinMaterial) @@ -139,6 +145,7 @@ struct HistoryCommitDetailView: View { DiffView(hunks: model.diff?.hunks ?? [], file: nil, repositoryURL: repositoryURL, undoManager: nil, onRefresh: {}, onError: { _ in }, filePath: file.path, gitRef: commit.hash, + imageComparisonMode: imageComparisonMode, imageOldPath: file.oldPath, imageFileStatus: file.status, commitPatchDisabledReason: model.patchDisabledReason(for: [file]), onCommitPatch: { lines, direction, scope in guard model.commit?.hash == commit.hash, model.selectedFile == file, diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index bb991eba..73052e08 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -326,8 +326,8 @@ struct RepoPickerView: View { private var dashboardActions: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { - if let icon = NSApp.applicationIconImage { - Image(nsImage: icon) + Group { + Image("commitplus-logo") .resizable() .scaledToFit() .frame(width: 32, height: 32) @@ -406,8 +406,8 @@ struct RepoPickerView: View { private var headerSection: some View { VStack(spacing: 18) { HStack(alignment: .top, spacing: 16) { - if showsApplicationIcon, let icon = NSApp.applicationIconImage { - Image(nsImage: icon) + if showsApplicationIcon { + Image("commitplus-logo") .resizable() .scaledToFit() .frame(width: 56, height: 56) diff --git a/macgit/Views/Stashes/StashView.swift b/macgit/Views/Stashes/StashView.swift index 63b495da..7c775888 100644 --- a/macgit/Views/Stashes/StashView.swift +++ b/macgit/Views/Stashes/StashView.swift @@ -35,6 +35,8 @@ struct StashView: View { @State private var errorMessage: String? @State private var showingError = false + @State private var imageComparisonMode: ImageComparisonMode = .overlay + var body: some View { VStack(spacing: 0) { stashHeader @@ -155,7 +157,11 @@ struct StashView: View { Text(file.path) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) - Spacer() + .truncationMode(.middle) + Spacer(minLength: 8) + if file.status != .added && StatusFile(path: file.path, status: .modified, originalPath: nil).isImage { + ImageComparisonModeButton(mode: $imageComparisonMode) + } } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -178,7 +184,8 @@ struct StashView: View { onRefresh: {}, onError: { _ in }, filePath: file.path, - gitRef: stashRef + gitRef: stashRef, + imageComparisonMode: imageComparisonMode, imageOldPath: file.oldPath, imageFileStatus: file.status ) } }