diff options
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Patchsets/PatchsetDetailView.swift | 248 | ||||
| -rw-r--r-- | Hutch/Views/Patchsets/PatchsetDetailViewModel.swift | 293 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 124 |
5 files changed, 669 insertions, 1 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 1ae4651..59d7caa 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -439,6 +439,7 @@ enum MoreRoute: Hashable { case projectDashboard(id: String, title: String?) case mailingList(InboxMailingListReference) case thread(InboxThreadSummary) + case patchset(id: Int, listName: String?) case manPageBrowser case manPage(URL) } @@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) + case .patchset(let id, let listName): + PatchsetDetailView(patchsetID: id, listName: listName) case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift index 2a26282..b52bb3f 100644 --- a/Hutch/Views/Lookup/LookupView.swift +++ b/Hutch/Views/Lookup/LookupView.swift @@ -465,6 +465,8 @@ struct LookupView: View { ProjectDashboardDeepLinkView(projectID: id, title: title) case .mailingList(let mailingList): MailingListDetailView(mailingList: mailingList) + case .patchset(let id, let listName): + PatchsetDetailView(patchsetID: id, listName: listName) case .thread(let thread): ThreadDetailView( thread: thread, diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift new file mode 100644 index 0000000..d76aab4 --- /dev/null +++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift @@ -0,0 +1,248 @@ +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 + + 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() + .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. + if let supersededBy = patchset.supersededBy { + NavigationLink(value: MoreRoute.patchset(id: supersededBy, listName: listName)) { + SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle") + .font(.subheadline) + } + .themedRow() + } + + if let supersedes = patchset.supersedes { + NavigationLink(value: MoreRoute.patchset(id: supersedes, listName: listName)) { + 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() + } + } + } + + @ViewBuilder + private func patchesSection(_ patchset: PatchsetDetail) -> some View { + ForEach(patchset.patches) { patch in + emailSection(patch, title: patch.seriesLabel.map { "Patch \($0)" } ?? "Patch") + } + } + + @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) + + ForEach(Array(email.contentBlocks.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) + } + } + } + .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: - 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 + } +} diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index d466121..3933bf3 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable { let id: Int let messageID: String let patch: InboxPatchPreview? + /// Null unless the thread's root email opens a patchset. `MailingList` has no + /// patchsets field, so this is the only way to enumerate a list's patchsets. + let patchset: PatchsetSummaryPayload? +} + +private struct PatchsetSummaryPayload: Decodable, Sendable { + let id: Int + let subject: String + let version: Int + let prefix: String? + let status: PatchsetStatus } @Observable @MainActor final class MailingListDetailViewModel { private(set) var threads: [InboxThreadSummary] = [] + /// Patchsets on this list, derived from thread roots — see the query below. + private(set) var patchsets: [PatchsetSummary] = [] private(set) var isLoading = false var error: String? var searchText = "" @@ -52,6 +65,13 @@ final class MailingListDetailViewModel { id messageID patch { subject } + patchset { + id + subject + version + prefix + status + } } } } @@ -87,11 +107,46 @@ final class MailingListDetailViewModel { threads = deduplicateThreads( response.list.threads.results.map(makeSummary(from:)) ) + patchsets = Self.patchsets(from: response.list.threads.results) } catch { self.error = "Failed to load mailing list" } } + var filteredPatchsets: [PatchsetSummary] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return patchsets } + return patchsets.filter { $0.subject.lowercased().contains(query) } + } + + /// Collects the patchsets opened by these threads, newest first. + /// + /// A revised series arrives as its own thread, so the same subject can appear + /// at several versions; they are kept as distinct patchsets and the version + /// chain is shown in the detail view. + private nonisolated static func patchsets( + from threads: [ProjectMailingListThreadPayload] + ) -> [PatchsetSummary] { + var seenIDs = Set<Int>() + var results: [PatchsetSummary] = [] + + for thread in threads { + guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue } + seenIDs.insert(payload.id) + results.append( + PatchsetSummary( + id: payload.id, + subject: payload.subject, + version: payload.version, + prefix: payload.prefix, + status: payload.status + ) + ) + } + + return results + } + func markThreadRead(_ thread: InboxThreadSummary) { let viewedAt = max(Date(), thread.lastActivityAt) InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults) @@ -257,12 +312,25 @@ final class MailingListDetailViewModel { } } +enum MailingListScope: String, CaseIterable, Hashable { + case threads + case patches + + var displayName: String { + switch self { + case .threads: "Threads" + case .patches: "Patches" + } + } +} + struct MailingListDetailView: View { let mailingList: InboxMailingListReference @Environment(AppState.self) private var appState @State private var viewModel: MailingListDetailViewModel? @State private var pinChangeCount = 0 + @State private var scope: MailingListScope = .threads private var currentUserKey: String? { appState.currentUser?.canonicalName @@ -337,6 +405,27 @@ struct MailingListDetailView: View { @Bindable var vm = viewModel List { + // Only offered when the list actually carries patches, so discussion + // lists do not grow an empty tab. + if !viewModel.patchsets.isEmpty { + Picker("Scope", selection: $scope) { + ForEach(MailingListScope.allCases, id: \.self) { scope in + Text(scope.displayName).tag(scope) + } + } + .pickerStyle(.segmented) + .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) + .themedRow() + } + + if showingPatches(viewModel) { + ForEach(viewModel.filteredPatchsets) { patchset in + NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) { + PatchsetRow(patchset: patchset) + } + .themedRow() + } + } else { ForEach(viewModel.filteredThreads) { thread in NavigationLink { ThreadDetailView( @@ -373,13 +462,14 @@ struct MailingListDetailView: View { } } .themedRow() + } } .themedList() .listStyle(.plain) .searchable( text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search messages" + prompt: showingPatches(viewModel) ? "Search patches" : "Search messages" ) .overlay { if viewModel.isLoading, viewModel.threads.isEmpty { @@ -390,6 +480,10 @@ struct MailingListDetailView: View { message: error, retryAction: { await viewModel.loadThreads() } ) + } else if showingPatches(viewModel) { + if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty { + ContentUnavailableView.search(text: viewModel.searchText) + } } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { ContentUnavailableView.search(text: viewModel.searchText) } else if viewModel.threads.isEmpty { @@ -405,6 +499,34 @@ struct MailingListDetailView: View { } .srhtErrorBanner(error: $vm.error) } + + private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool { + scope == .patches && !viewModel.patchsets.isEmpty + } +} + +struct PatchsetRow: View { + let patchset: PatchsetSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(patchset.subject) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + HStack(spacing: 8) { + PatchsetStatusBadge(status: patchset.status) + if let versionLabel = patchset.versionLabel { + Text(versionLabel) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)") + } } struct ProjectMailingListView: View { |
