Commit fa8188260f

fa8188260f99cda75d2d6ed993e54b6ce8c5282d

parent: af758cc55f

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 21:59 UTC

v2.1: bundled polish and fixes

Layout: unified · split

Hutch/App/RootView.swift +16 −14
@@ -146,12 +146,10 @@ struct RootView: View {
146146 resolveRepositoryLink(owner: owner, repo: repo)
147147
148148 case .build(let jobId):
149 // Reset the builds navigation and push the detail
150149 buildsPath = NavigationPath()
151150 appState.selectedTab = .builds
152 // Defer the push slightly so the tab switch takes effect
153 Task { @MainActor in
154 try? await Task.sleep(for: .milliseconds(100))
151 Task {
152 await settleNavigationTransition()
155153 buildsPath.append(jobId)
156154 }
157155
@@ -165,26 +163,25 @@ struct RootView: View {
165163 case .repository(let repository):
166164 repoPath = NavigationPath()
167165 appState.selectedTab = .repositories
168 Task { @MainActor in
169 try? await Task.sleep(for: .milliseconds(100))
166 Task {
167 await settleNavigationTransition()
170168 repoPath.append(repository)
171169 }
172170
173171 case .tracker(let tracker):
174172 ticketsPath = NavigationPath()
175173 appState.selectedTab = .tickets
176 Task { @MainActor in
177 try? await Task.sleep(for: .milliseconds(100))
174 Task {
175 await settleNavigationTransition()
178176 ticketsPath.append(tracker)
179177 }
180178
181179 case .mailingList(let mailingList):
182180 morePath = NavigationPath()
183181 appState.selectedTab = .more
184 Task { @MainActor in
185 try? await Task.sleep(for: .milliseconds(100))
182 Task {
183 await settleNavigationTransition()
186184 morePath.append(MoreRoute.lists)
187 try? await Task.sleep(for: .milliseconds(100))
188185 morePath.append(MoreRoute.mailingList(mailingList))
189186 }
190187 }
@@ -198,7 +195,7 @@ struct RootView: View {
198195 let summary = try await appState.resolveRepository(owner: owner, name: repo)
199196 repoPath = NavigationPath()
200197 appState.selectedTab = .repositories
201 try? await Task.sleep(for: .milliseconds(100))
198 await settleNavigationTransition()
202199 repoPath.append(summary)
203200 } catch {
204201 // Silently fail — the repo may not exist or be inaccessible
@@ -214,9 +211,8 @@ struct RootView: View {
214211 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
215212 ticketsPath = NavigationPath()
216213 appState.selectedTab = .tickets
217 try? await Task.sleep(for: .milliseconds(100))
214 await settleNavigationTransition()
218215 ticketsPath.append(trackerSummary)
219 try? await Task.sleep(for: .milliseconds(100))
220216 ticketsPath.append(TicketDeepLinkTarget(
221217 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
222218 trackerName: trackerSummary.name,
@@ -229,6 +225,12 @@ struct RootView: View {
229225 }
230226 }
231227 }
228
229 @MainActor
230 private func settleNavigationTransition() async {
231 await Task.yield()
232 await Task.yield()
233 }
232234}
233235
234236enum MoreDestination: Hashable {
Hutch/Networking/SRHTError.swift +72
@@ -33,6 +33,55 @@ enum SRHTError: LocalizedError, Sendable {
3333 }
3434 }
3535
36 var userFacingMessage: String {
37 switch self {
38 case .graphQLErrors(let errors):
39 let firstMessage = errors.first?.message.lowercased() ?? ""
40 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
41 return "You do not have permission to do that."
42 }
43 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
44 return "That content is no longer available."
45 }
46 return "Something went wrong. Please try again."
47 case .httpError(let code):
48 if code == 401 {
49 return "Please sign in again."
50 }
51 if code == 403 {
52 return "You do not have permission to do that."
53 }
54 if code == 404 {
55 return "That content is no longer available."
56 }
57 if (500...599).contains(code) {
58 return "The server is unavailable right now. Please try again."
59 }
60 return "Something went wrong. Please try again."
61 case .invalidAuthenticatedURL:
62 return "That request could not be completed."
63 case .decodingError:
64 return "The response could not be loaded right now."
65 case .networkError(let error):
66 let nsError = error as NSError
67 switch nsError.code {
68 case NSURLErrorNotConnectedToInternet,
69 NSURLErrorNetworkConnectionLost,
70 NSURLErrorTimedOut,
71 NSURLErrorCannotFindHost,
72 NSURLErrorCannotConnectToHost,
73 NSURLErrorDNSLookupFailed,
74 NSURLErrorInternationalRoamingOff,
75 NSURLErrorDataNotAllowed:
76 return "Check your connection and try again."
77 default:
78 return "The network request failed. Please try again."
79 }
80 case .unauthorized:
81 return "Please sign in again."
82 }
83 }
84
3685 /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
3786 var isConnectivityError: Bool {
3887 switch self {
@@ -55,6 +104,29 @@ enum SRHTError: LocalizedError, Sendable {
55104 }
56105}
57106
107extension Error {
108 var userFacingMessage: String {
109 if let error = self as? SRHTError {
110 return error.userFacingMessage
111 }
112
113 let nsError = self as NSError
114 switch nsError.code {
115 case NSURLErrorNotConnectedToInternet,
116 NSURLErrorNetworkConnectionLost,
117 NSURLErrorTimedOut,
118 NSURLErrorCannotFindHost,
119 NSURLErrorCannotConnectToHost,
120 NSURLErrorDNSLookupFailed,
121 NSURLErrorInternationalRoamingOff,
122 NSURLErrorDataNotAllowed:
123 return "Check your connection and try again."
124 default:
125 return "Something went wrong. Please try again."
126 }
127 }
128}
129
58130/// A single error entry from the GraphQL `errors` array.
59131struct GraphQLError: Decodable, Sendable {
60132 let message: String
Hutch/Views/Auth/AuthView.swift +1 −1
@@ -87,7 +87,7 @@ struct TokenEntryView: View {
8787 do {
8888 try await appState.connect(with: tokenTrimmed)
8989 } catch {
90 errorMessage = error.localizedDescription
90 errorMessage = error.userFacingMessage
9191 }
9292 isConnecting = false
9393 }
Hutch/Views/Builds/BuildDetailViewModel.swift +5 −5
@@ -110,7 +110,7 @@ final class BuildDetailViewModel {
110110 }
111111 job = loadedJob
112112 } catch {
113 self.error = error.localizedDescription
113 self.error = error.userFacingMessage
114114 }
115115
116116 isLoading = false
@@ -127,7 +127,7 @@ final class BuildDetailViewModel {
127127 do {
128128 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
129129 } catch {
130 self.error = error.localizedDescription
130 self.error = error.userFacingMessage
131131 }
132132
133133 loadingTaskLogs.remove(cacheKey)
@@ -148,7 +148,7 @@ final class BuildDetailViewModel {
148148 // Reload job to get updated status.
149149 await loadJob()
150150 } catch {
151 self.error = error.localizedDescription
151 self.error = error.userFacingMessage
152152 }
153153
154154 isCancelling = false
@@ -185,7 +185,7 @@ final class BuildDetailViewModel {
185185 )
186186 return result.submit.id
187187 } catch {
188 self.error = error.localizedDescription
188 self.error = error.userFacingMessage
189189 return nil
190190 }
191191 }
@@ -233,7 +233,7 @@ final class BuildDetailViewModel {
233233 )
234234 return result.submit.id
235235 } catch {
236 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
236 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
237237 return nil
238238 }
239239 }
Hutch/Views/Builds/BuildListView.swift +1
@@ -23,6 +23,7 @@ struct BuildListView: View {
2323 } label: {
2424 Image(systemName: "plus")
2525 }
26 .accessibilityLabel("Submit build")
2627 }
2728 }
2829 }
Hutch/Views/Builds/BuildListViewModel.swift +3 −3
@@ -97,7 +97,7 @@ final class BuildListViewModel {
9797 hasMore = page.cursor != nil
9898 } catch {
9999 if jobs.isEmpty {
100 self.error = error.localizedDescription
100 self.error = error.userFacingMessage
101101 }
102102 }
103103
@@ -121,7 +121,7 @@ final class BuildListViewModel {
121121 cursor = page.cursor
122122 hasMore = page.cursor != nil
123123 } catch {
124 self.error = error.localizedDescription
124 self.error = error.userFacingMessage
125125 }
126126
127127 isLoadingMore = false
@@ -171,7 +171,7 @@ final class BuildListViewModel {
171171 await loadJobs()
172172 return result.submit.id
173173 } catch {
174 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
174 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
175175 return nil
176176 }
177177 }
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -285,8 +285,8 @@ final class HomeViewModel {
285285 case .failure(let error):
286286 self.recentBuilds = []
287287 self.failedBuilds = []
288 self.failedBuildsError = error.localizedDescription
289 self.recentBuildsError = error.localizedDescription
288 self.failedBuildsError = error.userFacingMessage
289 self.recentBuildsError = error.userFacingMessage
290290 }
291291 isLoadingFailedBuilds = false
292292 isLoadingRecentBuilds = false
@@ -299,7 +299,7 @@ final class HomeViewModel {
299299 self.assignedTicketsError = nil
300300 case .failure(let error):
301301 self.assignedTickets = []
302 self.assignedTicketsError = error.localizedDescription
302 self.assignedTicketsError = error.userFacingMessage
303303 }
304304 isLoadingAssignedTickets = false
305305
Hutch/Views/Inbox/InboxView.swift −15
@@ -1,7 +1,4 @@
11import SwiftUI
2import os
3
4private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation")
52
63struct InboxView: View {
74 @Environment(AppState.self) private var appState
@@ -101,9 +98,6 @@ struct InboxView: View {
10198 systemImage: "tray",
10299 description: Text("This thread could not be restored.")
103100 )
104 .onAppear {
105 inboxNavigationLogger.error("Inbox navigation destination missing thread snapshot")
106 }
107101 }
108102 }
109103 }
@@ -123,9 +117,6 @@ struct InboxView: View {
123117 private func selectThread(_ thread: InboxThreadSummary) {
124118 cacheSelectedThread(thread)
125119 isShowingThreadDetail = true
126 inboxNavigationLogger.debug(
127 "Inbox navigation triggered: threadID=\(thread.id, privacy: .public) subject=\(thread.subject, privacy: .public)"
128 )
129120 }
130121
131122 private func cacheSelectedThread(_ thread: InboxThreadSummary) {
@@ -140,9 +131,6 @@ struct InboxView: View {
140131
141132 private func handleThreadDetailDisappear(for threadID: String) {
142133 let isActiveSelection = selectedThreadID == threadID
143 inboxNavigationLogger.debug(
144 "Inbox thread detail disappeared: threadID=\(threadID, privacy: .public) activeSelection=\(isActiveSelection, privacy: .public)"
145 )
146134 guard isActiveSelection else { return }
147135 clearSelection()
148136 }
@@ -154,9 +142,6 @@ struct InboxView: View {
154142 }
155143
156144 private func clearSelection() {
157 if let selectedThreadID {
158 inboxNavigationLogger.debug("Inbox selection cleared: threadID=\(selectedThreadID, privacy: .public)")
159 }
160145 selectedThreadID = nil
161146 selectedThreadSnapshot = nil
162147 isShowingThreadDetail = false
Hutch/Views/Inbox/InboxViewModel.swift +2 −15
@@ -136,7 +136,7 @@ final class InboxViewModel {
136136 return lhs.lastActivityAt > rhs.lastActivityAt
137137 }
138138 } catch {
139 inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
139 inboxListLogger.error("Inbox request failed")
140140 self.error = "Failed to load inbox"
141141 }
142142 }
@@ -144,17 +144,11 @@ final class InboxViewModel {
144144 func markThreadRead(_ thread: InboxThreadSummary) {
145145 let viewedAt = max(Date(), thread.lastActivityAt)
146146 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
147 inboxListLogger.debug(
148 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
149 )
150147 threads.removeAll { $0.id == thread.id }
151148 }
152149
153150 func markThreadUnread(_ thread: InboxThreadSummary) {
154151 InboxReadStateStore.markUnread(for: thread.id)
155 inboxListLogger.debug(
156 "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
157 )
158152 updateThread(thread, isUnread: true)
159153 }
160154
@@ -237,7 +231,7 @@ final class InboxViewModel {
237231 summaries.append(contentsOf: batchResult.0)
238232 failureMessages.append(contentsOf: batchResult.1)
239233 for failure in batchResult.1 {
240 inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
234 inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
241235 }
242236 startIndex = endIndex
243237 }
@@ -259,14 +253,7 @@ final class InboxViewModel {
259253
260254 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
261255 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
262 let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
263256 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
264 inboxListLogger.debug(
265 "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
266 )
267 inboxListLogger.debug(
268 "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
269 )
270257 return InboxThreadSummary(
271258 rootEmailID: thread.root.id,
272259 rootMessageID: thread.root.messageID,
Hutch/Views/Inbox/ThreadDetailView.swift +3 −11
@@ -4,7 +4,6 @@ import SwiftUI
44import UIKit
55
66private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation")
87
98struct ThreadDetailView: View {
109 let thread: InboxThreadSummary
@@ -53,18 +52,12 @@ struct ThreadDetailView: View {
5352 isUnread = thread.isUnread
5453 await vm.loadThread()
5554 }
56 .onAppear {
57 inboxThreadNavigationLogger.debug("Inbox thread detail appeared: threadID=\(thread.id, privacy: .public)")
58 }
5955 .onChange(of: viewModel?.thread?.id) { _, threadID in
6056 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
6157 hasMarkedCurrentThreadViewed = true
6258 isUnread = false
6359 onViewed()
6460 }
65 .onDisappear {
66 inboxThreadNavigationLogger.debug("Inbox thread detail view disappeared: threadID=\(thread.id, privacy: .public)")
67 }
6861 .sheet(item: Binding(
6962 get: { viewModel?.composeDraft },
7063 set: { _ in viewModel?.dismissReply() }
@@ -72,14 +65,13 @@ struct ThreadDetailView: View {
7265 MailComposeView(draft: draft) { result in
7366 switch result {
7467 case .failed(let message):
75 inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
68 inboxReplyLogger.error("Inbox reply failed")
7669 viewModel?.error = message
7770 case .cancelled:
78 inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
71 break
7972 case .saved:
80 inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
73 break
8174 case .sent:
82 inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
8375 replySuccessMessage = "Reply handed off to Mail."
8476 Task {
8577 await viewModel?.loadThread()
Hutch/Views/Inbox/ThreadViewModel.swift +6 −41
@@ -172,8 +172,6 @@ final class ThreadViewModel {
172172 partialWarning = nil
173173 defer { isLoading = false }
174174
175 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
176
177175 do {
178176 let threadPayloads = try await fetchThreadPayloads()
179177
@@ -207,9 +205,7 @@ final class ThreadViewModel {
207205 }
208206 } catch {
209207 hadPartialReplyFailure = true
210 inboxLogger.error(
211 "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
212 )
208 inboxLogger.error("Inbox thread descendants failed")
213209 }
214210 }
215211
@@ -241,9 +237,9 @@ final class ThreadViewModel {
241237 if thread == nil {
242238 self.error = "Failed to load thread"
243239 } else {
244 self.error = error.localizedDescription
240 self.error = error.userFacingMessage
245241 }
246 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
242 inboxLogger.error("Inbox thread detail failed")
247243 }
248244 }
249245
@@ -275,17 +271,9 @@ final class ThreadViewModel {
275271
276272 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
277273 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
278 inboxLogger.debug(
279 "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)"
280 )
281
282274 var lastLookupError: Error?
283275
284276 for messageID in candidateMessageIDs {
285 inboxLogger.debug(
286 "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
287 )
288
289277 do {
290278 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
291279 client: client,
@@ -303,10 +291,6 @@ final class ThreadViewModel {
303291 } catch let error as SRHTError {
304292 switch error {
305293 case .graphQLErrors(let errors):
306 let combinedMessage = errors.map(\.message).joined(separator: " | ")
307 inboxLogger.error(
308 "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
309 )
310294 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
311295 lastLookupError = error
312296 continue
@@ -318,11 +302,7 @@ final class ThreadViewModel {
318302 }
319303 }
320304
321 if let lastLookupError {
322 inboxLogger.debug(
323 "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
324 )
325 }
305 _ = lastLookupError
326306 return nil
327307 }
328308
@@ -348,7 +328,7 @@ final class ThreadViewModel {
348328 )
349329 } catch {
350330 if Self.isRecoverableNoRows(error) {
351 inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
331 inboxLogger.error("Inbox thread page scan missed a recoverable result")
352332 return nil
353333 }
354334 throw error
@@ -358,11 +338,6 @@ final class ThreadViewModel {
358338 return nil
359339 }
360340
361 let candidates = threadPage.results.map { payload in
362 "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
363 }.joined(separator: " | ")
364 inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
365
366341 if let matchedThread = threadPage.results.first(where: {
367342 $0.root?.messageID == targetRootMessageID ||
368343 $0.root?.id == summary.rootEmailID ||
@@ -428,9 +403,7 @@ final class ThreadViewModel {
428403 )
429404 } catch {
430405 if Self.isRecoverableNoRows(error) {
431 inboxLogger.error(
432 "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
433 )
406 inboxLogger.error("Inbox descendant page missed a recoverable result")
434407 continue
435408 }
436409 throw error
@@ -449,9 +422,6 @@ final class ThreadViewModel {
449422 error = "This thread is not ready to reply to yet."
450423 return
451424 }
452 inboxLogger.debug(
453 "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account"
454 )
455425 composeDraft = MailComposeDraft(
456426 recipients: [thread.replyRecipient],
457427 ccRecipients: [],
@@ -732,11 +702,6 @@ final class ThreadViewModel {
732702 )
733703
734704 let (data, _) = try await URLSession.shared.data(for: request)
735 #if DEBUG
736 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
737 inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
738 #endif
739
740705 let decoder = JSONDecoder()
741706 decoder.dateDecodingStrategy = .srhtFlexible
742707 let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
Hutch/Views/Pastes/PasteDetailViewModel.swift +4 −4
@@ -47,7 +47,7 @@ final class PasteDetailViewModel {
4747 }
4848 await loadSelectedFileContentsIfNeeded()
4949 } catch {
50 self.error = error.localizedDescription
50 self.error = error.userFacingMessage
5151 }
5252 }
5353
@@ -77,7 +77,7 @@ final class PasteDetailViewModel {
7777 }
7878 return updatedPaste
7979 } catch {
80 self.error = error.localizedDescription
80 self.error = error.userFacingMessage
8181 return nil
8282 }
8383 }
@@ -92,7 +92,7 @@ final class PasteDetailViewModel {
9292 _ = try await service.deletePaste(id: pasteID)
9393 return true
9494 } catch {
95 self.error = error.localizedDescription
95 self.error = error.userFacingMessage
9696 return false
9797 }
9898 }
@@ -108,7 +108,7 @@ final class PasteDetailViewModel {
108108 do {
109109 fileContents[file.hash] = try await service.loadContents(from: url)
110110 } catch {
111 self.error = error.localizedDescription
111 self.error = error.userFacingMessage
112112 }
113113 }
114114}
Hutch/Views/Pastes/PasteListViewModel.swift +3 −3
@@ -41,7 +41,7 @@ final class PasteListViewModel {
4141 hasMore = page.cursor != nil
4242 } catch {
4343 if pastes.isEmpty {
44 self.error = error.localizedDescription
44 self.error = error.userFacingMessage
4545 }
4646 }
4747
@@ -66,7 +66,7 @@ final class PasteListViewModel {
6666 cursor = page.cursor
6767 hasMore = page.cursor != nil
6868 } catch {
69 self.error = error.localizedDescription
69 self.error = error.userFacingMessage
7070 }
7171 }
7272
@@ -90,7 +90,7 @@ final class PasteListViewModel {
9090 upsertPaste(paste)
9191 return paste
9292 } catch {
93 self.error = error.localizedDescription
93 self.error = error.userFacingMessage
9494 return nil
9595 }
9696 }
Hutch/Views/Repositories/CommitDetailViewModel.swift +1 −1
@@ -68,7 +68,7 @@ final class CommitDetailViewModel {
6868 let result = try await executeWithRetry()
6969 commit = result.repository?.revparse_single
7070 } catch {
71 self.error = error.localizedDescription
71 self.error = error.userFacingMessage
7272 }
7373
7474 isLoading = false
Hutch/Views/Repositories/FileTreeViewModel.swift +4 −4
@@ -294,7 +294,7 @@ final class FileTreeViewModel {
294294 entries = []
295295 }
296296 } catch {
297 self.error = error.localizedDescription
297 self.error = error.userFacingMessage
298298 }
299299 }
300300
@@ -372,7 +372,7 @@ final class FileTreeViewModel {
372372 }
373373 entries = allEntries
374374 } catch {
375 self.error = error.localizedDescription
375 self.error = error.userFacingMessage
376376 }
377377 }
378378
@@ -397,7 +397,7 @@ final class FileTreeViewModel {
397397 viewingEntry = entry
398398 viewingObject = result.repository?.object ?? .unknown
399399 } catch {
400 self.error = error.localizedDescription
400 self.error = error.userFacingMessage
401401 }
402402 }
403403
@@ -449,7 +449,7 @@ final class FileTreeViewModel {
449449 }
450450 entries = allEntries
451451 } catch {
452 self.error = error.localizedDescription
452 self.error = error.userFacingMessage
453453 }
454454 }
455455 }
Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift +5 −5
@@ -344,7 +344,7 @@ final class HgRepositoryDetailViewModel {
344344
345345 summaryLoaded = true
346346 } catch {
347 self.error = error.localizedDescription
347 self.error = error.userFacingMessage
348348 }
349349 }
350350
@@ -367,7 +367,7 @@ final class HgRepositoryDetailViewModel {
367367 logCursor = nil
368368 hasMoreLog = false
369369 } else {
370 self.error = error.localizedDescription
370 self.error = error.userFacingMessage
371371 }
372372 }
373373 }
@@ -389,7 +389,7 @@ final class HgRepositoryDetailViewModel {
389389 logCursor = page.cursor
390390 hasMoreLog = page.cursor != nil
391391 } catch {
392 self.error = error.localizedDescription
392 self.error = error.userFacingMessage
393393 }
394394 }
395395
@@ -470,7 +470,7 @@ final class HgRepositoryDetailViewModel {
470470 await loadFiles(at: path)
471471 }
472472 } catch {
473 self.error = error.localizedDescription
473 self.error = error.userFacingMessage
474474 }
475475 }
476476
@@ -515,7 +515,7 @@ final class HgRepositoryDetailViewModel {
515515 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
516516 files = []
517517 } else {
518 self.error = error.localizedDescription
518 self.error = error.userFacingMessage
519519 }
520520 }
521521 }
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +6 −6
@@ -164,7 +164,7 @@ final class HgRepositorySettingsViewModel {
164164 editedNonPublishing = repository.nonPublishing ?? false
165165 }
166166 } catch {
167 self.error = error.localizedDescription
167 self.error = error.userFacingMessage
168168 }
169169 }
170170
@@ -186,7 +186,7 @@ final class HgRepositorySettingsViewModel {
186186 responseType: HgUpdateRepositoryResponse.self
187187 )
188188 } catch {
189 self.error = error.localizedDescription
189 self.error = error.userFacingMessage
190190 }
191191 }
192192
@@ -205,7 +205,7 @@ final class HgRepositorySettingsViewModel {
205205 )
206206 acls = result.repository?.accessControlList.results ?? []
207207 } catch {
208 self.error = error.localizedDescription
208 self.error = error.userFacingMessage
209209 }
210210 }
211211
@@ -239,7 +239,7 @@ final class HgRepositorySettingsViewModel {
239239 if message.localizedCaseInsensitiveContains("No such repository or user found") {
240240 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
241241 } else {
242 self.error = message
242 self.error = error.userFacingMessage
243243 }
244244 }
245245 }
@@ -263,7 +263,7 @@ final class HgRepositorySettingsViewModel {
263263 )
264264 acls.removeAll { $0.id == entry.id }
265265 } catch {
266 self.error = error.localizedDescription
266 self.error = error.userFacingMessage
267267 }
268268 }
269269
@@ -281,7 +281,7 @@ final class HgRepositorySettingsViewModel {
281281 )
282282 didDelete = true
283283 } catch {
284 self.error = error.localizedDescription
284 self.error = error.userFacingMessage
285285 }
286286 }
287287}
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +5 −5
@@ -161,7 +161,7 @@ final class RepositoryDetailViewModel {
161161 commitCursor = page.cursor
162162 hasMoreCommits = page.cursor != nil
163163 } catch {
164 self.error = error.localizedDescription
164 self.error = error.userFacingMessage
165165 }
166166
167167 isLoadingCommits = false
@@ -183,7 +183,7 @@ final class RepositoryDetailViewModel {
183183 commitCursor = page.cursor
184184 hasMoreCommits = page.cursor != nil
185185 } catch {
186 self.error = error.localizedDescription
186 self.error = error.userFacingMessage
187187 }
188188
189189 isLoadingMoreCommits = false
@@ -243,7 +243,7 @@ final class RepositoryDetailViewModel {
243243 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
244244 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
245245 } catch {
246 self.error = error.localizedDescription
246 self.error = error.userFacingMessage
247247 }
248248
249249 isLoadingRefs = false
@@ -337,7 +337,7 @@ final class RepositoryDetailViewModel {
337337 readmePath = nil
338338 readmeLoaded = true
339339 } catch {
340 self.error = error.localizedDescription
340 self.error = error.userFacingMessage
341341 }
342342 }
343343
@@ -389,7 +389,7 @@ final class RepositoryDetailViewModel {
389389 .filter { !$0.artifacts.results.isEmpty }
390390 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
391391 } catch {
392 self.error = error.localizedDescription
392 self.error = error.userFacingMessage
393393 }
394394
395395 isLoadingArtifacts = false
Hutch/Views/Repositories/RepositoryListView.swift +1
@@ -24,6 +24,7 @@ struct RepositoryListView: View {
2424 } label: {
2525 Image(systemName: "plus")
2626 }
27 .accessibilityLabel("Create repository")
2728 }
2829 }
2930 }
Hutch/Views/Repositories/RepositoryListViewModel.swift +2 −15
@@ -189,7 +189,7 @@ final class RepositoryListViewModel {
189189 } catch {
190190 // Only show error if we have no cached data to fall back on
191191 if repositories.isEmpty {
192 self.error = error.localizedDescription
192 self.error = error.userFacingMessage
193193 }
194194 }
195195
@@ -272,20 +272,7 @@ final class RepositoryListViewModel {
272272 }
273273
274274 private func repositoryCreationErrorMessage(for error: Error) -> String {
275 let message: String
276
277 if let srhtError = error as? SRHTError {
278 switch srhtError {
279 case .graphQLErrors(let errors):
280 message = errors.map(\.message).joined(separator: "\n")
281 default:
282 message = srhtError.localizedDescription
283 }
284 } else {
285 message = error.localizedDescription
286 }
287
288 return "Couldn’t create the repository. \(message)"
275 "Couldn’t create the repository. \(error.userFacingMessage)"
289276 }
290277
291278 /// Fetch ALL repositories by paginating through all available pages.
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +6 −6
@@ -173,7 +173,7 @@ final class RepositorySettingsViewModel {
173173 responseType: UpdateRepoInfoResponse.self
174174 )
175175 } catch {
176 self.error = error.localizedDescription
176 self.error = error.userFacingMessage
177177 }
178178 }
179179
@@ -196,7 +196,7 @@ final class RepositorySettingsViewModel {
196196 )
197197 updatedName = result.updateRepository.name
198198 } catch {
199 self.error = error.localizedDescription
199 self.error = error.userFacingMessage
200200 }
201201 }
202202
@@ -255,7 +255,7 @@ final class RepositorySettingsViewModel {
255255 )
256256 acls = result.repository?.acls.results ?? []
257257 } catch {
258 self.error = error.localizedDescription
258 self.error = error.userFacingMessage
259259 }
260260 }
261261
@@ -286,7 +286,7 @@ final class RepositorySettingsViewModel {
286286 }
287287 newACLEntity = ""
288288 } catch {
289 self.error = error.localizedDescription
289 self.error = error.userFacingMessage
290290 }
291291 }
292292
@@ -331,7 +331,7 @@ final class RepositorySettingsViewModel {
331331 )
332332 acls.removeAll { $0.id == entry.id }
333333 } catch {
334 self.error = error.localizedDescription
334 self.error = error.userFacingMessage
335335 }
336336 }
337337
@@ -357,7 +357,7 @@ final class RepositorySettingsViewModel {
357357 )
358358 didDelete = true
359359 } catch {
360 self.error = error.localizedDescription
360 self.error = error.userFacingMessage
361361 }
362362 }
363363}
Hutch/Views/Settings/SettingsViewModel.swift +9 −9
@@ -162,7 +162,7 @@ final class SettingsViewModel {
162162 sshKeys = result.me.sshKeys.results
163163 pgpKeys = result.me.pgpKeys.results
164164 } catch {
165 self.error = error.localizedDescription
165 self.error = error.userFacingMessage
166166 }
167167
168168 isLoading = false
@@ -207,7 +207,7 @@ final class SettingsViewModel {
207207 }
208208 isEditingProfile = false
209209 } catch {
210 self.error = error.localizedDescription
210 self.error = error.userFacingMessage
211211 }
212212
213213 isSavingProfile = false
@@ -252,7 +252,7 @@ final class SettingsViewModel {
252252 )
253253 }
254254 } catch {
255 self.error = error.localizedDescription
255 self.error = error.userFacingMessage
256256 }
257257
258258 isUploadingAvatar = false
@@ -289,7 +289,7 @@ final class SettingsViewModel {
289289 )
290290 }
291291 } catch {
292 self.error = error.localizedDescription
292 self.error = error.userFacingMessage
293293 }
294294
295295 isUploadingAvatar = false
@@ -313,7 +313,7 @@ final class SettingsViewModel {
313313 newSSHKey = ""
314314 isAddingSSHKey = false
315315 } catch {
316 self.error = error.localizedDescription
316 self.error = error.userFacingMessage
317317 }
318318 }
319319
@@ -329,7 +329,7 @@ final class SettingsViewModel {
329329 )
330330 sshKeys.removeAll { $0.id == key.id }
331331 } catch {
332 self.error = error.localizedDescription
332 self.error = error.userFacingMessage
333333 }
334334 }
335335
@@ -351,7 +351,7 @@ final class SettingsViewModel {
351351 newPGPKey = ""
352352 isAddingPGPKey = false
353353 } catch {
354 self.error = error.localizedDescription
354 self.error = error.userFacingMessage
355355 }
356356 }
357357
@@ -367,7 +367,7 @@ final class SettingsViewModel {
367367 )
368368 pgpKeys.removeAll { $0.id == key.id }
369369 } catch {
370 self.error = error.localizedDescription
370 self.error = error.userFacingMessage
371371 }
372372 }
373373
@@ -385,7 +385,7 @@ final class SettingsViewModel {
385385 )
386386 personalAccessTokens = result.personalAccessTokens
387387 } catch {
388 self.error = error.localizedDescription
388 self.error = error.userFacingMessage
389389 }
390390
391391 isLoadingPATs = false
Hutch/Views/Tickets/TicketDetailView.swift +2 −6
@@ -100,6 +100,7 @@ struct TicketDetailView: View {
100100 } label: {
101101 Image(systemName: "ellipsis.circle")
102102 }
103 .accessibilityLabel("Ticket actions")
103104 .sheet(isPresented: $showResolveSheet) {
104105 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
105106 .presentationDetents([.medium])
@@ -311,7 +312,7 @@ struct TicketDetailView: View {
311312 } else {
312313 MarkdownContentView(markdown: viewModel.commentText)
313314 .frame(minHeight: 80, maxHeight: 200)
314 .clipShape(RoundedRectangle(cornerRadius: 8))
315 .clipShape(RoundedRectangle(cornerRadius: 8))
315316 }
316317 }
317318
@@ -359,11 +360,6 @@ private struct MarkdownContentView: View {
359360 }
360361 }
361362 .task(id: markdown) {
362 if renderedHTML != nil {
363 try? await Task.sleep(for: .milliseconds(150))
364 guard !Task.isCancelled else { return }
365 }
366
367363 let html = await Task.detached(priority: .userInitiated) {
368364 markdownToHTML(markdown)
369365 }.value
Hutch/Views/Tickets/TicketDetailViewModel.swift +10 −10
@@ -324,7 +324,7 @@ final class TicketDetailViewModel {
324324 )
325325 events = payload.events.results.sorted(by: Self.timelineOrder)
326326 } catch {
327 self.error = error.localizedDescription
327 self.error = error.userFacingMessage
328328 }
329329
330330 isLoading = false
@@ -359,7 +359,7 @@ final class TicketDetailViewModel {
359359 events.sort(by: Self.timelineOrder)
360360 commentText = ""
361361 } catch {
362 self.error = error.localizedDescription
362 self.error = error.userFacingMessage
363363 }
364364
365365 isSubmitting = false
@@ -393,7 +393,7 @@ final class TicketDetailViewModel {
393393 // Re-fetch the ticket to get updated status/resolution
394394 await loadTicket()
395395 } catch {
396 self.error = error.localizedDescription
396 self.error = error.userFacingMessage
397397 }
398398
399399 isPerformingAction = false
@@ -427,7 +427,7 @@ final class TicketDetailViewModel {
427427 // Reload to reflect the change
428428 await loadTicket()
429429 } catch {
430 self.error = error.localizedDescription
430 self.error = error.userFacingMessage
431431 }
432432
433433 isPerformingAction = false
@@ -485,7 +485,7 @@ final class TicketDetailViewModel {
485485 assignees: currentAssignees,
486486 labels: currentTicket.labels
487487 )
488 self.error = error.localizedDescription
488 self.error = error.userFacingMessage
489489 }
490490
491491 isPerformingAction = false
@@ -520,7 +520,7 @@ final class TicketDetailViewModel {
520520 // Reload to reflect the change
521521 await loadTicket()
522522 } catch {
523 self.error = error.localizedDescription
523 self.error = error.userFacingMessage
524524 }
525525
526526 isPerformingAction = false
@@ -544,7 +544,7 @@ final class TicketDetailViewModel {
544544 )
545545 await loadTicket()
546546 } catch {
547 self.error = error.localizedDescription
547 self.error = error.userFacingMessage
548548 }
549549
550550 isPerformingAction = false
@@ -568,7 +568,7 @@ final class TicketDetailViewModel {
568568 )
569569 await loadTicket()
570570 } catch {
571 self.error = error.localizedDescription
571 self.error = error.userFacingMessage
572572 }
573573
574574 isPerformingAction = false
@@ -587,7 +587,7 @@ final class TicketDetailViewModel {
587587 )
588588 trackerLabels = result.user.tracker.labels.results
589589 } catch {
590 self.error = error.localizedDescription
590 self.error = error.userFacingMessage
591591 }
592592 }
593593
@@ -610,7 +610,7 @@ final class TicketDetailViewModel {
610610 )
611611 trackerLabels.append(result.createLabel)
612612 } catch {
613 self.error = error.localizedDescription
613 self.error = error.userFacingMessage
614614 }
615615
616616 isPerformingAction = false
Hutch/Views/Tickets/TicketListView.swift +1
@@ -33,6 +33,7 @@ struct TicketListView: View {
3333 } label: {
3434 Image(systemName: "plus")
3535 }
36 .accessibilityLabel("Create ticket")
3637 }
3738 }
3839 }
Hutch/Views/Tickets/TicketListViewModel.swift +3 −3
@@ -121,7 +121,7 @@ final class TicketListViewModel {
121121 cursor = page.cursor
122122 hasMore = page.cursor != nil
123123 } catch {
124 self.error = error.localizedDescription
124 self.error = error.userFacingMessage
125125 }
126126
127127 isLoading = false
@@ -143,7 +143,7 @@ final class TicketListViewModel {
143143 cursor = page.cursor
144144 hasMore = page.cursor != nil
145145 } catch {
146 self.error = error.localizedDescription
146 self.error = error.userFacingMessage
147147 }
148148
149149 isLoadingMore = false
@@ -185,7 +185,7 @@ final class TicketListViewModel {
185185 tickets.insert(ticket, at: 0)
186186 return ticket
187187 } catch {
188 self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
188 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
189189 return nil
190190 }
191191 }
Hutch/Views/Tickets/TrackerListViewModel.swift +3 −16
@@ -78,7 +78,7 @@ final class TrackerListViewModel {
7878 cursor = page.cursor
7979 hasMore = page.cursor != nil
8080 } catch {
81 self.error = error.localizedDescription
81 self.error = error.userFacingMessage
8282 }
8383
8484 isLoading = false
@@ -100,7 +100,7 @@ final class TrackerListViewModel {
100100 cursor = page.cursor
101101 hasMore = page.cursor != nil
102102 } catch {
103 self.error = error.localizedDescription
103 self.error = error.userFacingMessage
104104 }
105105
106106 isLoadingMore = false
@@ -165,19 +165,6 @@ final class TrackerListViewModel {
165165 }
166166
167167 private func trackerCreationErrorMessage(for error: Error) -> String {
168 let message: String
169
170 if let srhtError = error as? SRHTError {
171 switch srhtError {
172 case .graphQLErrors(let errors):
173 message = errors.map(\.message).joined(separator: "\n")
174 default:
175 message = srhtError.localizedDescription
176 }
177 } else {
178 message = error.localizedDescription
179 }
180
181 return "Couldn’t create the tracker. \(message)"
168 "Couldn’t create the tracker. \(error.userFacingMessage)"
182169 }
183170}