summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/CommitDetailView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Repositories/CommitDetailView.swift')
-rw-r--r--Hutch/Views/Repositories/CommitDetailView.swift332
1 files changed, 332 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift
new file mode 100644
index 0000000..d303542
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitDetailView.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+
+struct CommitDetailView: View {
+ let commitSummary: CommitSummary
+ let repository: RepositorySummary
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: CommitDetailViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ commitContent(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(commitSummary.shortId)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = CommitDetailViewModel(
+ repositoryRid: repository.rid,
+ service: repository.service,
+ commitId: commitSummary.id,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadCommit()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func commitContent(_ viewModel: CommitDetailViewModel) -> some View {
+ if viewModel.isLoading {
+ ProgressView()
+ } else if let error = viewModel.error {
+ ContentUnavailableView {
+ Label("Error", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(error)
+ } actions: {
+ Button("Retry") {
+ Task { await viewModel.loadCommit() }
+ }
+ }
+ } else if let commit = viewModel.commit {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ // Header
+ commitHeader(commit)
+
+ sectionDivider
+
+ // Message
+ commitMessage(commit)
+
+ // Trailers
+ if !commit.trailers.isEmpty {
+ sectionDivider
+ trailersSection(commit.trailers)
+ }
+
+ // Parents
+ if !commit.parents.isEmpty {
+ sectionDivider
+ parentsSection(commit.parents)
+ }
+
+ // Diff
+ if let diff = commit.diff, !diff.isEmpty {
+ sectionDivider
+ diffSection(diff)
+ }
+
+ // Tree
+ if let tree = commit.tree, !tree.entries.results.isEmpty {
+ sectionDivider
+ treeSection(tree.entries.results)
+ }
+ }
+ }
+ .navigationDestination(for: ParentCommit.self) { parent in
+ CommitDetailView(
+ commitSummary: CommitSummary(
+ id: parent.id,
+ shortId: parent.shortId,
+ author: CommitAuthor(name: parent.author.name, email: nil, time: .now),
+ message: ""
+ ),
+ repository: repository
+ )
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func commitHeader(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Full hash — tappable to copy
+ Button {
+ UIPasteboard.general.string = commit.id
+ } label: {
+ HStack(spacing: 4) {
+ Text(commit.id)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Image(systemName: "doc.on.doc")
+ .font(.caption2)
+ }
+ .foregroundStyle(.secondary)
+ }
+
+ // Author
+ HStack {
+ Label(commit.author.name, systemImage: "person")
+ Spacer()
+ Text(commit.author.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+
+ // Committer (if different from author)
+ if commit.committer.name != commit.author.name
+ || commit.committer.email != commit.author.email {
+ HStack {
+ Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
+ Spacer()
+ Text(commit.committer.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Message
+
+ @ViewBuilder
+ private func commitMessage(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Message")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ Text(commit.title)
+ .font(.headline)
+
+ if let body = commit.body {
+ Text(body)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Trailers
+
+ @ViewBuilder
+ private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Trailers")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(trailers) { trailer in
+ HStack(alignment: .top, spacing: 4) {
+ Text("\(trailer.name):")
+ .font(.subheadline.monospaced().weight(.medium))
+ Text(trailer.value)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Parents
+
+ @ViewBuilder
+ private func parentsSection(_ parents: [ParentCommit]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Parents")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(parents) { parent in
+ NavigationLink(value: parent) {
+ HStack {
+ Text(parent.shortId)
+ .font(.subheadline.monospaced())
+ Text(parent.author.name)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Diff
+
+ @ViewBuilder
+ private func diffSection(_ diff: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Diff")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ .padding(.horizontal)
+ .padding(.top)
+
+ DiffView(diff: invertDiff(diff))
+ .padding(.bottom)
+ }
+ }
+
+ /// The sr.ht API returns diffs comparing current→parent (inverted).
+ /// This swaps +/- prefixes so the diff reads as parent→current.
+ private func invertDiff(_ diff: String) -> String {
+ diff.split(separator: "\n", omittingEmptySubsequences: false)
+ .map { line in
+ let s = String(line)
+ if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
+ return s
+ }
+ if s.hasPrefix("---") {
+ return "+++" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+++") {
+ return "---" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+") {
+ return "-" + s.dropFirst(1)
+ }
+ if s.hasPrefix("-") {
+ return "+" + s.dropFirst(1)
+ }
+ return s
+ }
+ .joined(separator: "\n")
+ }
+
+ // MARK: - Tree
+
+ @ViewBuilder
+ private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Tree")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(entries) { entry in
+ HStack(spacing: 8) {
+ Image(systemName: treeEntryIcon(for: entry))
+ .foregroundStyle(treeEntryColor(for: entry))
+ .frame(width: 20)
+ Text(entry.name)
+ .font(.subheadline.monospaced())
+ Spacer()
+ if let obj = entry.object, let shortId = obj.shortId {
+ Text(shortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Helpers
+
+ private var sectionDivider: some View {
+ Divider().padding(.horizontal)
+ }
+
+ private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
+ guard let type = entry.object?.type else {
+ return "doc"
+ }
+ switch type {
+ case "tree": return "folder"
+ case "blob": return "doc.text"
+ case "tag": return "tag"
+ case "commit": return "arrow.triangle.branch"
+ default: return "doc"
+ }
+ }
+
+ private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
+ guard let type = entry.object?.type else {
+ return .secondary
+ }
+ switch type {
+ case "tree": return .blue
+ case "blob": return .secondary
+ case "tag": return .orange
+ case "commit": return .purple
+ default: return .secondary
+ }
+ }
+}