summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 14:20:27 -0500
committerChristian Cleberg <[email protected]>2026-04-13 14:20:27 -0500
commit5e63048ab6a741ae947f7fe42efcb5206a3f3963 (patch)
treecf8eb2e3bc3b2edb20ef96a2599e684378120e5b /Hutch
parent0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50 (diff)
downloadhutch-3.0.4.tar.gz
hutch-3.0.4.tar.bz2
hutch-3.0.4.zip
fix: mercurial parity and graphql handlingv3.0.4
- implements consistent UX between repo types - ensures centralized services and routes are used between repo types - graphql error handling is centralized - error messages are consistent Implements: https://todo.sr.ht/~ccleberg/hutch/58 Implements: https://todo.sr.ht/~ccleberg/hutch/59
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Networking/SRHTClient.swift33
-rw-r--r--Hutch/Networking/SRHTError.swift88
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift51
-rw-r--r--Hutch/Views/Builds/BuildListView.swift87
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift5
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift49
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift19
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsView.swift54
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift37
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift11
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift4
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift9
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift46
13 files changed, 301 insertions, 192 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 1b8b9b3..7e1f619 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -89,19 +89,12 @@ final class SRHTClient: Sendable {
throw SRHTError.unauthorized
}
if !(200...299).contains(http.statusCode) {
- // Try to extract GraphQL errors from the response body even on non-2xx
- if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
- let errors = gqlResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
+ try throwGraphQLErrorsIfPresent(in: data)
throw SRHTError.httpError(http.statusCode)
}
}
- if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
- let errors = errorEnvelope.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
+ try throwGraphQLErrorsIfPresent(in: data)
// Decode GraphQL response envelope
let graphQLResponse: GraphQLResponse<T>
@@ -249,14 +242,12 @@ final class SRHTClient: Sendable {
throw SRHTError.unauthorized
}
if !(200...299).contains(http.statusCode) {
+ try throwGraphQLErrorsIfPresent(in: data)
throw SRHTError.httpError(http.statusCode)
}
}
- if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
- let errors = errorEnvelope.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
+ try throwGraphQLErrorsIfPresent(in: data)
let graphQLResponse: GraphQLResponse<T>
do {
@@ -381,14 +372,13 @@ final class SRHTClient: Sendable {
throw SRHTError.unauthorized
}
if !(200...299).contains(http.statusCode) {
- if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
- let errors = gqlResponse.errors, !errors.isEmpty {
- throw SRHTError.graphQLErrors(errors)
- }
+ try throwGraphQLErrorsIfPresent(in: data)
throw SRHTError.httpError(http.statusCode)
}
}
+ try throwGraphQLErrorsIfPresent(in: data)
+
let graphQLResponse: GraphQLResponse<T>
do {
graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
@@ -474,6 +464,7 @@ final class SRHTClient: Sendable {
throw SRHTError.unauthorized
}
if !(200...299).contains(http.statusCode) {
+ try throwGraphQLErrorsIfPresent(in: data)
throw SRHTError.httpError(http.statusCode)
}
}
@@ -627,6 +618,14 @@ final class SRHTClient: Sendable {
// MARK: - Data Helper
private extension SRHTClient {
+ func throwGraphQLErrorsIfPresent(in data: Data) throws {
+ if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
+ let errors = envelope.errors,
+ !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ }
+
static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
let host = url.host?.lowercased() else {
diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift
index b9fc4c8..e946774 100644
--- a/Hutch/Networking/SRHTError.swift
+++ b/Hutch/Networking/SRHTError.swift
@@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable {
var errorDescription: String? {
switch self {
case .graphQLErrors(let errors):
- let messages = errors.map(\.message).joined(separator: "\n")
+ let messages = errors.diagnosticSummary
return "GraphQL error: \(messages)"
case .httpError(let code):
return "Server returned HTTP \(code)."
@@ -33,17 +33,21 @@ enum SRHTError: LocalizedError, Sendable {
}
}
- var userFacingMessage: String {
+ nonisolated var userFacingMessage: String {
switch self {
case .graphQLErrors(let errors):
- let firstMessage = errors.first?.message.lowercased() ?? ""
- if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
+ switch errors.classification {
+ case .unauthorized, .forbidden:
return "You do not have permission to do that."
- }
- if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
+ case .notFound, .noRows, .missingReference, .unknownRevision:
return "That content is no longer available."
+ case .serviceNotProvisioned:
+ return "That account needs to activate this SourceHut service before this action can succeed."
+ case .validation:
+ return errors.primaryMessage ?? "Please review your changes and try again."
+ case .other:
+ return "Something went wrong. Please try again."
}
- return "Something went wrong. Please try again."
case .httpError(let code):
if code == 401 {
return "Please sign in again."
@@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable {
}
extension Error {
- var userFacingMessage: String {
+ nonisolated var userFacingMessage: String {
if let error = self as? SRHTError {
return error.userFacingMessage
}
@@ -125,6 +129,22 @@ extension Error {
return "Something went wrong. Please try again."
}
}
+
+ nonisolated var graphQLErrors: [GraphQLError]? {
+ guard let srhtError = self as? SRHTError,
+ case let SRHTError.graphQLErrors(errors) = srhtError else {
+ return nil
+ }
+ return errors
+ }
+
+ nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool {
+ graphQLErrors?.classification == classification
+ }
+
+ nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool {
+ graphQLErrors?.containsMessage(fragment) == true
+ }
}
/// A single error entry from the GraphQL `errors` array.
@@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable {
let locations: [GraphQLErrorLocation]?
}
+enum GraphQLErrorClassification: Sendable {
+ case unauthorized
+ case forbidden
+ case notFound
+ case noRows
+ case missingReference
+ case unknownRevision
+ case serviceNotProvisioned
+ case validation
+ case other
+}
+
+extension Array where Element == GraphQLError {
+ nonisolated var classification: GraphQLErrorClassification {
+ if containsMessage("unauthorized") { return .unauthorized }
+ if containsMessage("forbidden") { return .forbidden }
+ if containsMessage("reference not found") { return .missingReference }
+ if containsMessage("no rows in result set") { return .noRows }
+ if containsMessage("unknown revision") || containsMessage("path not in the working tree") {
+ return .unknownRevision
+ }
+ if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") {
+ return .notFound
+ }
+ if containsMessage("no such repository or user found") {
+ return .serviceNotProvisioned
+ }
+ if let primaryMessage, !primaryMessage.isEmpty {
+ return .validation
+ }
+ return .other
+ }
+
+ nonisolated var primaryMessage: String? {
+ let candidates = map(\.message)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ return candidates.first
+ }
+
+ nonisolated var diagnosticSummary: String {
+ map(\.message)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n")
+ }
+
+ nonisolated func containsMessage(_ fragment: String) -> Bool {
+ contains { $0.message.localizedCaseInsensitiveContains(fragment) }
+ }
+}
+
struct GraphQLErrorLocation: Decodable, Sendable {
let line: Int
let column: Int
diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift
index 5f51315..028c64e 100644
--- a/Hutch/Networking/SystemStatusRepository.swift
+++ b/Hutch/Networking/SystemStatusRepository.swift
@@ -130,56 +130,7 @@ actor SystemStatusRepository {
}
private func refreshErrorMessage(from error: any Error) -> String {
- if let error = error as? SRHTError {
- switch error {
- case .graphQLErrors(let errors):
- let firstMessage = errors.first?.message.lowercased() ?? ""
- if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
- return "You do not have permission to do that."
- }
- if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
- return "That content is no longer available."
- }
- return "Something went wrong. Please try again."
- case .httpError(let code):
- if code == 401 {
- return "Please sign in again."
- }
- if code == 403 {
- return "You do not have permission to do that."
- }
- if code == 404 {
- return "That content is no longer available."
- }
- if (500...599).contains(code) {
- return "The server is unavailable right now. Please try again."
- }
- return "Something went wrong. Please try again."
- case .invalidAuthenticatedURL:
- return "That request could not be completed."
- case .decodingError:
- return "The response could not be loaded right now."
- case .networkError(let underlyingError):
- return refreshErrorMessage(from: underlyingError)
- case .unauthorized:
- return "Please sign in again."
- }
- }
-
- let nsError = error as NSError
- switch nsError.code {
- case NSURLErrorNotConnectedToInternet,
- NSURLErrorNetworkConnectionLost,
- NSURLErrorTimedOut,
- NSURLErrorCannotFindHost,
- NSURLErrorCannotConnectToHost,
- NSURLErrorDNSLookupFailed,
- NSURLErrorInternationalRoamingOff,
- NSURLErrorDataNotAllowed:
- return "Check your connection and try again."
- default:
- return "Something went wrong. Please try again."
- }
+ error.userFacingMessage
}
}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index d525918..f4647e4 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -130,66 +130,71 @@ struct BuildListView: View {
}
}
.pickerStyle(.segmented)
- .listRowBackground(Color.clear)
+ .padding(.horizontal, 16)
+ .padding(.top, 6)
+ .padding(.bottom, 10)
.listRowInsets(EdgeInsets())
- }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
- ForEach(viewModel.filteredJobs) { job in
- NavigationLink(value: job) {
- BuildRowView(job: job)
- .equatable()
- }
- .contextMenu {
- Button {
- appState.copyToPasteboard(String(job.id), label: "job ID")
- } label: {
- Label("Copy Job ID", systemImage: "doc.on.doc")
+ ForEach(viewModel.filteredJobs) { job in
+ NavigationLink(value: job) {
+ BuildRowView(job: job)
+ .equatable()
}
-
- if let note = job.note, !note.isEmpty {
+ .contextMenu {
Button {
- appState.copyToPasteboard(note, label: "build note")
+ appState.copyToPasteboard(String(job.id), label: "job ID")
} label: {
- Label("Copy Note", systemImage: "text.alignleft")
+ Label("Copy Job ID", systemImage: "doc.on.doc")
}
- }
- if !job.tags.isEmpty {
- Button {
- appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
- } label: {
- Label("Copy Tags", systemImage: "tag")
+ if let note = job.note, !note.isEmpty {
+ Button {
+ appState.copyToPasteboard(note, label: "build note")
+ } label: {
+ Label("Copy Note", systemImage: "text.alignleft")
+ }
+ }
+
+ if !job.tags.isEmpty {
+ Button {
+ appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
+ } label: {
+ Label("Copy Tags", systemImage: "tag")
+ }
}
}
- }
- .swipeActions(edge: .leading, allowsFullSwipe: true) {
- if swipeActionsEnabled, job.status.isCancellable {
- Button {
- Task {
- await viewModel.cancelJob(job)
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ if swipeActionsEnabled, job.status.isCancellable {
+ Button {
+ Task {
+ await viewModel.cancelJob(job)
+ }
+ } label: {
+ Label("Cancel", systemImage: "xmark.circle")
}
- } label: {
- Label("Cancel", systemImage: "xmark.circle")
+ .tint(.red)
}
- .tint(.red)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: job)
}
}
- .task {
- await viewModel.loadMoreIfNeeded(currentItem: job)
- }
- }
- if viewModel.isLoadingMore {
- HStack {
- Spacer()
- ProgressView()
- Spacer()
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
}
- .listRowSeparator(.hidden)
}
}
.themedList()
.listStyle(.plain)
+ .listSectionSpacing(.compact)
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
index 1c3705b..850b83c 100644
--- a/Hutch/Views/Inbox/ThreadViewModel.swift
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -693,9 +693,6 @@ final class ThreadViewModel {
}
private static func isRecoverableNoRows(_ error: Error) -> Bool {
- guard case let SRHTError.graphQLErrors(errors) = error else {
- return false
- }
- return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
+ error.matchesGraphQLErrorClassification(.noRows)
}
}
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
index ac7c04f..8362467 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var colorScheme
@AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
@@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View {
async let log: () = vm.loadLog()
_ = await (summary, browse, log)
}
+ RecentActivityStore.recordRepository(repository, defaults: appState.accountDefaults)
}
}
@@ -132,6 +134,42 @@ struct HgRepositoryDetailView: View {
}
}
+ Divider()
+
+ if let repositoryURL = SRHTWebURL.repository(repository) {
+ Button {
+ openURL(repositoryURL)
+ } label: {
+ Label("Open in Browser", systemImage: "safari")
+ }
+
+ Button {
+ appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL")
+ } label: {
+ Label("Copy URL", systemImage: "doc.on.doc")
+ }
+ }
+
+ if let httpsURL = SRHTWebURL.httpsCloneURL(repository) {
+ Button {
+ appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL")
+ } label: {
+ Label("Copy HTTPS URL", systemImage: "doc.on.doc")
+ }
+ }
+
+ Button {
+ appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL")
+ } label: {
+ Label("Copy SSH URL", systemImage: "terminal")
+ }
+
+ Button {
+ appState.copyToPasteboard(repository.rid, label: "repository RID")
+ } label: {
+ Label("Copy RID", systemImage: "number")
+ }
+
if canManageRepository {
Divider()
@@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View {
@ViewBuilder
private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
VStack(alignment: .leading, spacing: 10) {
- SummaryMetadataRow(
- icon: "arrow.triangle.branch",
- title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
- )
+ if let branchLabel = repositoryPrimaryBranchLabel(for: repository, hgTipBranch: viewModel.tip?.branch) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: branchLabel
+ )
+ }
if let readmePath = viewModel.readmePath {
SummaryMetadataRow(
@@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View {
private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service))
SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
index 5a0298c..d8303e1 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
@@ -526,20 +526,9 @@ final class HgRepositoryDetailViewModel {
}
private func isEmptyRepositoryError(_ error: Error) -> Bool {
- if let srhtError = error as? SRHTError,
- case .graphQLErrors(let errors) = srhtError {
- return errors.contains {
- let message = $0.message.localizedLowercase
- return message.contains("missing")
- || message.contains("not found")
- || message.contains("unknown revision")
- || message.contains("unknown revision or path not in the working tree")
- }
- }
-
- let message = error.localizedDescription.localizedLowercase
- return message.contains("missing")
- || message.contains("not found")
- || message.contains("unknown revision")
+ error.matchesGraphQLErrorClassification(.notFound)
+ || error.matchesGraphQLErrorClassification(.unknownRevision)
+ || error.matchesGraphQLErrorClassification(.noRows)
+ || error.containsGraphQLErrorMessage("missing")
}
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
index 6a27465..a6aa728 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
@@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View {
@State private var viewModel: HgRepositorySettingsViewModel?
@State private var showDeleteConfirmation = false
@State private var pendingACLDeletion: HgACLEntry?
- @State private var saveResultAlert: SaveResultAlert?
var body: some View {
NavigationStack {
@@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View {
Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
}
}
- .alert(item: $saveResultAlert) { alert in
- Alert(
- title: Text(alert.title),
- message: Text(alert.message),
- dismissButton: .default(Text("OK"))
- )
- }
}
@ViewBuilder
private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
- Section("Info") {
- LabeledContent("Name") {
- Text(repository.name)
+ Section("Current Configuration") {
+ LabeledContent("Repository") {
+ Text("\(repository.owner.canonicalName)/\(repository.name)")
.font(.body.monospaced())
}
+ LabeledContent("Forge") {
+ Text(repositoryForgeLabel(repository.service))
+ }
+
+ LabeledContent("Visibility") {
+ Text(repositoryVisibilityLabel(viewModel.editedVisibility))
+ }
+ }
+
+ Section("Repository Details") {
TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
.lineLimit(3...6)
@@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View {
Button {
Task {
- let didSave = await viewModel.saveInfo()
- saveResultAlert = SaveResultAlert(
- title: didSave ? "Settings Updated" : "Couldn't Update Settings",
- message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
- )
+ _ = await viewModel.saveInfo()
}
} label: {
if viewModel.isSavingInfo {
@@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View {
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isSavingInfo)
+ .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
}
}
@@ -202,16 +200,15 @@ struct HgRepositorySettingsView: View {
@ViewBuilder
private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
- Section("Features") {
+ Section("Sensitive Settings") {
Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
+ Text("Changes stay pending until you save this section.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
Button {
Task {
- let didSave = await viewModel.saveInfo()
- saveResultAlert = SaveResultAlert(
- title: didSave ? "Settings Updated" : "Couldn't Update Settings",
- message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
- )
+ _ = await viewModel.saveInfo()
}
} label: {
if viewModel.isSavingInfo {
@@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View {
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isSavingInfo)
+ .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
}
}
@@ -261,13 +258,8 @@ struct HgRepositorySettingsView: View {
}
}
.disabled(viewModel.isDeleting)
+ } header: {
+ Text("Danger Zone")
}
}
-
- private struct SaveResultAlert: Identifiable {
- let title: String
- let message: String
-
- var id: String { "\(title)-\(message)" }
- }
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
index a1a11ae..7b8d7f7 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -64,11 +64,14 @@ final class HgRepositorySettingsViewModel {
let repositoryRid: String
let repositoryName: String
private let client: SRHTClient
+ private var initialDescription: String
+ private var initialVisibility: Visibility
var editedDescription: String
var editedVisibility: Visibility
var editedNonPublishing: Bool
var isSavingInfo = false
+ private(set) var loadedNonPublishing = false
private(set) var acls: [HgACLEntry] = []
private(set) var isLoadingACLs = false
@@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel {
var didDelete = false
var error: String?
+ var normalizedEditedDescription: String {
+ editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var isInfoDirty: Bool {
+ normalizedEditedDescription != initialDescription ||
+ editedVisibility != initialVisibility ||
+ editedNonPublishing != loadedNonPublishing
+ }
+
init(repository: RepositorySummary, client: SRHTClient) {
self.repositoryId = repository.id
self.repositoryRid = repository.rid
self.repositoryName = repository.name
self.client = client
- self.editedDescription = repository.description ?? ""
+ let description = repository.description ?? ""
+ self.initialDescription = description
+ self.initialVisibility = repository.visibility
+ self.editedDescription = description
self.editedVisibility = repository.visibility
self.editedNonPublishing = false
}
@@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel {
)
if let repository = result.repository {
- editedDescription = repository.description ?? ""
+ let description = repository.description ?? ""
+ initialDescription = description
+ initialVisibility = repository.visibility
+ editedDescription = description
editedVisibility = repository.visibility
- editedNonPublishing = repository.nonPublishing ?? false
+ let nonPublishing = repository.nonPublishing ?? false
+ editedNonPublishing = nonPublishing
+ loadedNonPublishing = nonPublishing
}
} catch {
self.error = error.userFacingMessage
@@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel {
do {
let input: [String: any Sendable] = [
- "description": editedDescription,
+ "description": normalizedEditedDescription,
"visibility": editedVisibility.rawValue,
"nonPublishing": editedNonPublishing
]
@@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel {
variables: ["id": repositoryId, "input": input],
responseType: HgUpdateRepositoryResponse.self
)
+ initialDescription = normalizedEditedDescription
+ initialVisibility = editedVisibility
+ loadedNonPublishing = editedNonPublishing
return true
} catch {
self.error = error.userFacingMessage
@@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel {
}
newACLEntity = ""
} catch {
- let message = error.localizedDescription
- if message.localizedCaseInsensitiveContains("No such repository or user found") {
- self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
+ if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
+ self.error = error.userFacingMessage
} else {
self.error = error.userFacingMessage
}
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index f5c0c9f..21d7a74 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -54,10 +54,12 @@ struct ReadmeView: View {
@ViewBuilder
private var metadataSection: some View {
VStack(alignment: .leading, spacing: 10) {
- SummaryMetadataRow(
- icon: "arrow.triangle.branch",
- title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
- )
+ if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: branchLabel
+ )
+ }
if let readmePath = viewModel.readmePath {
SummaryMetadataRow(
@@ -71,6 +73,7 @@ struct ReadmeView: View {
private var repositoryDetailsSection: some View {
DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 72c4d1f..ebd07d2 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -372,9 +372,7 @@ final class RepositoryDetailViewModel {
}
private func isMissingGitReferenceError(_ error: Error) -> Bool {
- guard let srhtError = error as? SRHTError else { return false }
- guard case .graphQLErrors(let errors) = srhtError else { return false }
- return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ error.matchesGraphQLErrorClassification(.missingReference)
}
// MARK: - Artifacts
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index 0da6cbc..afbd740 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -15,14 +15,7 @@ struct RepositoryRowView: View {
Spacer()
- if repository.service == .hg {
- Text("HG")
- .font(.caption2.weight(.medium))
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(Color.cyan.opacity(0.15), in: Capsule())
- .foregroundStyle(.cyan)
- }
+ RepositoryForgeBadge(service: repository.service)
if buildStatus != .none {
RepositoryBuildStatusIndicator(status: buildStatus)
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index 7861b40..741ff39 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -46,6 +46,28 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
}
}
+func repositoryForgeLabel(_ service: SRHTService) -> String {
+ switch service {
+ case .git:
+ return "GIT"
+ case .hg:
+ return "HG"
+ default:
+ return service.rawValue.uppercased()
+ }
+}
+
+func repositoryPrimaryBranchLabel(for repository: RepositorySummary, hgTipBranch: String? = nil) -> String? {
+ switch repository.service {
+ case .git:
+ return repository.defaultBranchName
+ case .hg:
+ return hgTipBranch ?? repository.defaultBranchName ?? "tip"
+ default:
+ return repository.defaultBranchName
+ }
+}
+
struct SummaryMetadataRow: View {
let icon: String
let title: String
@@ -85,3 +107,27 @@ struct SummaryDetailRow: View {
}
}
}
+
+struct RepositoryForgeBadge: View {
+ let service: SRHTService
+
+ var body: some View {
+ Text(repositoryForgeLabel(service))
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.15), in: Capsule())
+ .foregroundStyle(color)
+ }
+
+ private var color: Color {
+ switch service {
+ case .git:
+ .indigo
+ case .hg:
+ .cyan
+ default:
+ .secondary
+ }
+ }
+}