diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 22:41:33 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 22:41:33 -0500 |
| commit | 922502a2c74c66034f3ec2db6612f2a36d242042 (patch) | |
| tree | 0867730c955e308437b8c86dd37638c9bff6c9c5 /Hutch/Views/Patchsets | |
| parent | b9ec80716ea015de5b6b31395fdc5ff03191398c (diff) | |
| parent | 1d2769fc7a347939275e9130ee174d61d96ea401 (diff) | |
| download | hutch-922502a2c74c66034f3ec2db6612f2a36d242042.tar.gz hutch-922502a2c74c66034f3ec2db6612f2a36d242042.tar.bz2 hutch-922502a2c74c66034f3ec2db6612f2a36d242042.zip | |
Merge pull request #4 from zerolabsco/phase-2-patchsets
Phase 2: patchset review
Diffstat (limited to 'Hutch/Views/Patchsets')
| -rw-r--r-- | Hutch/Views/Patchsets/PatchsetDetailView.swift | 336 | ||||
| -rw-r--r-- | Hutch/Views/Patchsets/PatchsetDetailViewModel.swift | 293 |
2 files changed, 629 insertions, 0 deletions
diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift new file mode 100644 index 0000000..f560326 --- /dev/null +++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift @@ -0,0 +1,336 @@ +import SwiftUI + +struct PatchsetDetailView: View { + let patchsetID: Int + let listName: String? + + @Environment(AppState.self) private var appState + @State private var viewModel: PatchsetDetailViewModel? + @State private var showStatusPicker = false + @State private var expandedPatchIDs: Set<Int> = [] + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading Patchset…") + } + } + .navigationTitle("Patchset") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let viewModel, viewModel.patchset != nil { + ToolbarItem(placement: .topBarTrailing) { + actionsMenu(viewModel) + } + } + } + .task { + let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client) + viewModel = model + await model.loadPatchset() + } + } + + @ViewBuilder + private func content(_ viewModel: PatchsetDetailViewModel) -> some View { + if viewModel.isLoading && viewModel.patchset == nil { + SRHTLoadingStateView(message: "Loading Patchset…") + } else if let patchset = viewModel.patchset { + List { + headerSection(patchset) + if let coverLetter = patchset.coverLetter { + emailSection(coverLetter, title: "Cover Letter") + } + if !patchset.tools.isEmpty { + toolsSection(patchset) + } + patchesSection(patchset) + } + .themedList() + // Inset grouped lays cells out at a rounded width while the content + // measures itself at the unrounded one, so a long Text reflows to a + // different height than the cell was sized for and the two chase each + // other into a layout loop. ThreadDetailView renders the same bodies + // through the same DiffView on a plain list without that fight. + .listStyle(.plain) + .refreshable { await viewModel.loadPatchset() } + .overlay { + if viewModel.isUpdatingStatus { + ProgressView() + } + } + .confirmationDialog( + "Set Status", + isPresented: $showStatusPicker, + titleVisibility: .visible + ) { + ForEach(PatchsetStatus.assignable, id: \.self) { status in + Button(status.displayName) { + Task { await viewModel.updateStatus(to: status) } + } + } + Button("Cancel", role: .cancel) {} + } + .alert( + "Couldn't Update Patchset", + isPresented: .init( + get: { viewModel.error != nil }, + set: { if !$0 { viewModel.error = nil } } + ) + ) { + Button("OK", role: .cancel) { viewModel.error = nil } + } message: { + Text(viewModel.error ?? "") + } + } else if let error = viewModel.error { + SRHTErrorStateView( + title: "Couldn't Load Patchset", + message: error, + retryAction: { await viewModel.loadPatchset() } + ) + } + } + + // MARK: - Sections + + @ViewBuilder + private func headerSection(_ patchset: PatchsetDetail) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(patchset.subject) + .font(.headline) + + HStack(spacing: 8) { + PatchsetStatusBadge(status: patchset.status) + if patchset.version > 1 { + Text("v\(patchset.version)") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + + if let listName { + Text(listName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + .themedRow() + + // The version chain matters during review: a superseded series should + // usually be read at its newest version instead. + // + // Pushed directly rather than by value, for the same reason as the rows + // that lead here — this view inherits whatever stack presented it, and + // not all of them declare a MoreRoute destination. + if let supersededBy = patchset.supersededBy { + NavigationLink { + PatchsetDetailView(patchsetID: supersededBy, listName: listName) + } label: { + SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle") + .font(.subheadline) + } + .themedRow() + } + + if let supersedes = patchset.supersedes { + NavigationLink { + PatchsetDetailView(patchsetID: supersedes, listName: listName) + } label: { + SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle") + .font(.subheadline) + } + .themedRow() + } + } + } + + @ViewBuilder + private func toolsSection(_ patchset: PatchsetDetail) -> some View { + Section("Checks") { + ForEach(patchset.tools) { tool in + HStack(spacing: 8) { + Image(systemName: tool.icon.systemImage) + .foregroundStyle(tool.icon == .failed ? .red : .secondary) + Text(tool.details) + .font(.subheadline) + } + .themedRow() + } + } + } + + /// Patches start collapsed. + /// + /// A diff is tall, and a series is many of them. Rendering every patch expanded + /// puts a dozen self-sizing diffs in one List, which drives UICollectionView + /// into a recursive layout loop and wedges the app. The inbox thread view + /// collapses all but the last message for the same reason. + @ViewBuilder + private func patchesSection(_ patchset: PatchsetDetail) -> some View { + Section("Patches") { + ForEach(patchset.patches) { patch in + PatchRow( + patch: patch, + isExpanded: expandedPatchIDs.contains(patch.id), + onToggle: { + withAnimation(.easeInOut(duration: 0.2)) { + if expandedPatchIDs.contains(patch.id) { + expandedPatchIDs.remove(patch.id) + } else { + expandedPatchIDs.insert(patch.id) + } + } + } + ) + .themedRow() + } + } + } + + @ViewBuilder + private func emailSection(_ email: PatchsetEmail, title: String) -> some View { + Section(title) { + VStack(alignment: .leading, spacing: 10) { + Text(email.subject) + .font(.subheadline.weight(.semibold)) + .textSelection(.enabled) + + PatchsetContentBlocks(blocks: email.contentBlocks) + } + .padding(.vertical, 4) + .themedRow() + } + } + + // MARK: - Actions + + @ViewBuilder + private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View { + Menu { + Button { + showStatusPicker = true + } label: { + SwiftUI.Label("Set Status", systemImage: "flag") + } + .disabled(viewModel.isUpdatingStatus) + + if let mbox = viewModel.patchset?.mbox { + Divider() + ShareLink(item: mbox) { + SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up") + } + Button { + appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL") + } label: { + SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Patchset actions") + } +} + +// MARK: - Patch Row + +private struct PatchRow: View { + let patch: PatchsetEmail + let isExpanded: Bool + let onToggle: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) { + Button(action: onToggle) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 3) + + VStack(alignment: .leading, spacing: 2) { + Text(patch.subject) + .font(.subheadline.weight(.medium)) + .lineLimit(isExpanded ? nil : 2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + if let seriesLabel = patch.seriesLabel { + Text(seriesLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .buttonStyle(.plain) + .accessibilityHint(isExpanded ? "Collapses this patch" : "Expands this patch") + + if isExpanded { + PatchsetContentBlocks(blocks: patch.contentBlocks) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Content Blocks + +private struct PatchsetContentBlocks: View { + let blocks: [InboxMessageContentBlock] + + var body: some View { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block { + case .plainText(let text): + Text(text) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + case .diff(let diff): + DiffView(diff: diff) + .textSelection(.enabled) + } + } + } +} + +// MARK: - Status Badge + +struct PatchsetStatusBadge: View { + let status: PatchsetStatus + + var body: some View { + SwiftUI.Label(status.displayName, systemImage: status.systemImage) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(background, in: Capsule()) + .foregroundStyle(foreground) + } + + private var foreground: Color { + switch status { + case .applied, .approved: .green + case .rejected: .red + case .needsRevision: .orange + case .superseded, .unknown, .proposed: .secondary + } + } + + private var background: Color { + foreground.opacity(0.12) + } +} diff --git a/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift new file mode 100644 index 0000000..88af328 --- /dev/null +++ b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift @@ -0,0 +1,293 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct PatchsetDetailResponse: Decodable, Sendable { + let patchset: PatchsetDetailPayload? +} + +private struct PatchsetDetailPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let subject: String + let version: Int + let prefix: String? + let status: PatchsetStatus + let submitter: Entity + let coverLetter: PatchsetEmailPayload? + let supersededBy: PatchsetReferencePayload? + let supersedes: PatchsetReferencePayload? + let patches: PatchsetPatchPage + let tools: [PatchsetToolPayload] + let mbox: URL? +} + +private struct PatchsetReferencePayload: Decodable, Sendable { + let id: Int +} + +private struct PatchsetPatchPage: Decodable, Sendable { + let results: [PatchsetEmailPayload] + let cursor: String? +} + +private struct PatchsetEmailPayload: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let sender: Entity + let body: String + let patch: PatchIndexPayload? +} + +private struct PatchIndexPayload: Decodable, Sendable { + let index: Int? + let count: Int? +} + +private struct PatchsetToolPayload: Decodable, Sendable { + let id: Int + let icon: PatchsetToolIcon + let details: String +} + +private struct UpdatePatchsetResponse: Decodable, Sendable { + let patchset: UpdatedPatchsetPayload? +} + +private struct UpdatedPatchsetPayload: Decodable, Sendable { + let status: PatchsetStatus +} + +// MARK: - View Model + +@Observable +@MainActor +final class PatchsetDetailViewModel { + + let patchsetID: Int + + private(set) var patchset: PatchsetDetail? + private(set) var isLoading = false + private(set) var isUpdatingStatus = false + var error: String? + + private let client: SRHTClient + + init(patchsetID: Int, client: SRHTClient) { + self.patchsetID = patchsetID + self.client = client + } + + // MARK: - Queries + + /// `patches` is paginated, but a series is small and reviewing half of one is + /// worse than useless, so every page is walked before rendering. + private static let detailQuery = """ + query patchset($id: Int!, $cursor: Cursor) { + patchset(id: $id) { + id + created + updated + subject + version + prefix + status + submitter { canonicalName } + supersededBy { id } + supersedes { id } + coverLetter { + id + subject + date + sender { canonicalName } + body + patch { index count } + } + patches(cursor: $cursor) { + results { + id + subject + date + sender { canonicalName } + body + patch { index count } + } + cursor + } + tools { id icon details } + mbox + } + } + """ + + private static let updateStatusMutation = """ + mutation updatePatchset($id: Int!, $status: PatchsetStatus!) { + patchset: updatePatchset(id: $id, status: $status) { + status + } + } + """ + + // MARK: - Loading + + func loadPatchset() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + patchset = try await fetchPatchset() + } catch { + self.error = error.userFacingMessage + } + } + + private func fetchPatchset() async throws -> PatchsetDetail { + var cursor: String? + var payload: PatchsetDetailPayload? + var patches: [PatchsetEmailPayload] = [] + + // Walk the patches pages, keeping the first page's patchset fields. + while true { + var variables: [String: any Sendable] = ["id": patchsetID] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .lists, + query: Self.detailQuery, + variables: variables, + responseType: PatchsetDetailResponse.self + ) + + guard let page = response.patchset else { + throw SRHTError.graphQLErrors([ + GraphQLError(message: "That patchset is no longer available.", locations: nil) + ]) + } + + if payload == nil { + payload = page + } + patches.append(contentsOf: page.patches.results) + + guard let next = page.patches.cursor, !next.isEmpty else { break } + cursor = next + } + + guard let payload else { + throw SRHTError.graphQLErrors([ + GraphQLError(message: "That patchset is no longer available.", locations: nil) + ]) + } + + return PatchsetDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + subject: payload.subject, + version: payload.version, + prefix: payload.prefix, + status: payload.status, + submitter: payload.submitter, + coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) }, + patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }), + supersededBy: payload.supersededBy?.id, + supersedes: payload.supersedes?.id, + tools: payload.tools.map { + PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details) + }, + mbox: payload.mbox + ) + } + + // MARK: - Status + + /// Sets the review status. Returns true on success. + @discardableResult + func updateStatus(to newStatus: PatchsetStatus) async -> Bool { + guard !isUpdatingStatus, let current = patchset else { return false } + guard newStatus != current.status else { return true } + + isUpdatingStatus = true + error = nil + defer { isUpdatingStatus = false } + + do { + let response = try await client.execute( + service: .lists, + query: Self.updateStatusMutation, + variables: [ + "id": patchsetID, + "status": newStatus.rawValue + ], + responseType: UpdatePatchsetResponse.self + ) + + // updatePatchset is nullable: null means the server declined without + // erroring, so the local status must not be advanced. + guard let updated = response.patchset else { + self.error = "SourceHut did not apply that status change." + return false + } + + apply(status: updated.status) + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + private func apply(status: PatchsetStatus) { + guard let current = patchset else { return } + patchset = PatchsetDetail( + id: current.id, + created: current.created, + updated: current.updated, + subject: current.subject, + version: current.version, + prefix: current.prefix, + status: status, + submitter: current.submitter, + coverLetter: current.coverLetter, + patches: current.patches, + supersededBy: current.supersededBy, + supersedes: current.supersedes, + tools: current.tools, + mbox: current.mbox + ) + } + + // MARK: - Mapping + + private nonisolated static func makeEmail( + from payload: PatchsetEmailPayload, + isPatch: Bool + ) -> PatchsetEmail { + PatchsetEmail( + id: payload.id, + subject: payload.subject, + date: payload.date, + sender: payload.sender, + contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch), + index: payload.patch?.index, + count: payload.patch?.count + ) + } + + /// Orders a series by its `[PATCH n/m]` index. + /// + /// sr.ht returns patches in receipt order, which is not series order when a + /// contributor's mail arrives out of sequence. Patches without an index keep + /// their relative position at the end rather than being dropped. + nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] { + let indexed = patches.filter { $0.index != nil } + let unindexed = patches.filter { $0.index == nil } + return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed + } +} |
