Commit 4c312827dd

4c312827ddbea8ca40c78f0d3aa82a696377a5f4

parent: 787436c54e

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 06:53 UTC

feat: add inbox threads and move builds under more

Layout: unified · split

Hutch/App/RootView.swift +13 −13
@@ -66,19 +66,6 @@ struct RootView: View {
66 Label("Repositories", systemImage: "book.closed") 66 Label("Repositories", systemImage: "book.closed")
67 } 67 }
68 68
69 NavigationStack(path: $buildsPath) {
70 BuildListView()
71 // Int destination used by deep links (hutch://builds/<id>).
72 // JobSummary destination is registered inside BuildListView.
73 .navigationDestination(for: Int.self) { jobId in
74 BuildDetailView(jobId: jobId)
75 }
76 }
77 .tag(AppState.Tab.builds)
78 .tabItem {
79 Label("Builds", systemImage: "hammer")
80 }
81
82 NavigationStack(path: $ticketsPath) { 69 NavigationStack(path: $ticketsPath) {
83 TrackerListView() 70 TrackerListView()
84 // Deep link destination for jumping straight to a ticket. 71 // Deep link destination for jumping straight to a ticket.
@@ -96,6 +83,19 @@ struct RootView: View {
96 .tabItem { 83 .tabItem {
97 Label("Settings", systemImage: "gear") 84 Label("Settings", systemImage: "gear")
98 } 85 }
86
87 NavigationStack(path: $buildsPath) {
88 BuildListView()
89 // Int destination used by deep links (hutch://builds/<id>).
90 // JobSummary destination is registered inside BuildListView.
91 .navigationDestination(for: Int.self) { jobId in
92 BuildDetailView(jobId: jobId)
93 }
94 }
95 .tag(AppState.Tab.builds)
96 .tabItem {
97 Label("Builds", systemImage: "hammer")
98 }
99 } 99 }
100 .overlay { 100 .overlay {
101 if isResolvingDeepLink { 101 if isResolvingDeepLink {
Hutch/Models/Inbox.swift +6
@@ -178,6 +178,12 @@ enum InboxReadStateStore {
178 defaults.set(dictionary, forKey: key) 178 defaults.set(dictionary, forKey: key)
179 } 179 }
180 180
181 static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
182 var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
183 dictionary.removeValue(forKey: threadID)
184 defaults.set(dictionary, forKey: key)
185 }
186
181 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { 187 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
182 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { 188 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
183 return true 189 return true
Hutch/Views/Inbox/InboxView.swift +22 −1
@@ -31,6 +31,12 @@ struct InboxView: View {
31 NavigationLink(value: thread) { 31 NavigationLink(value: thread) {
32 InboxThreadRow(thread: thread) 32 InboxThreadRow(thread: thread)
33 } 33 }
34 .swipeActions(edge: .leading, allowsFullSwipe: true) {
35 readStateAction(for: thread, in: viewModel)
36 }
37 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
38 readStateAction(for: thread, in: viewModel)
39 }
34 } 40 }
35 } 41 }
36 .listStyle(.plain) 42 .listStyle(.plain)
@@ -39,7 +45,7 @@ struct InboxView: View {
39 SRHTLoadingStateView(message: "Loading inbox…") 45 SRHTLoadingStateView(message: "Loading inbox…")
40 } else if let error = viewModel.error, viewModel.threads.isEmpty { 46 } else if let error = viewModel.error, viewModel.threads.isEmpty {
41 SRHTErrorStateView( 47 SRHTErrorStateView(
42 title: "Couldn't Load Threads", 48 title: "Failed to load inbox",
43 message: error, 49 message: error,
44 retryAction: { await viewModel.loadThreads() } 50 retryAction: { await viewModel.loadThreads() }
45 ) 51 )
@@ -64,6 +70,21 @@ struct InboxView: View {
64 } 70 }
65 } 71 }
66 } 72 }
73
74 @ViewBuilder
75 private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
76 Button {
77 withAnimation(.easeInOut(duration: 0.2)) {
78 viewModel.toggleThreadReadState(thread)
79 }
80 } label: {
81 Label(
82 thread.isUnread ? "Mark as Read" : "Mark as Unread",
83 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
84 )
85 }
86 .tint(thread.isUnread ? .blue : .gray)
87 }
67} 88}
68 89
69private struct InboxThreadRow: View { 90private struct InboxThreadRow: View {
Hutch/Views/Inbox/InboxViewModel.swift +53 −23
@@ -134,33 +134,34 @@ final class InboxViewModel {
134 return lhs.lastActivityAt > rhs.lastActivityAt 134 return lhs.lastActivityAt > rhs.lastActivityAt
135 } 135 }
136 } catch { 136 } catch {
137 threads = [] 137 inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
138 self.error = error.localizedDescription 138 self.error = "Failed to load inbox"
139 } 139 }
140 } 140 }
141 141
142 func markThreadRead(_ thread: InboxThreadSummary) { 142 func markThreadRead(_ thread: InboxThreadSummary) {
143 let viewedAt = max(Date(), thread.lastActivityAt) 143 let viewedAt = max(Date(), thread.lastActivityAt)
144 InboxReadStateStore.markViewed(viewedAt, for: thread.id) 144 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
145 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } 145 inboxListLogger.debug(
146 let current = threads[index] 146 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
147 threads[index] = InboxThreadSummary(
148 rootEmailID: current.rootEmailID,
149 rootMessageID: current.rootMessageID,
150 threadRootEmailIDs: current.threadRootEmailIDs,
151 threadRootMessageIDs: current.threadRootMessageIDs,
152 listID: current.listID,
153 listRID: current.listRID,
154 listName: current.listName,
155 listOwner: current.listOwner,
156 subject: current.subject,
157 latestSender: current.latestSender,
158 lastActivityAt: current.lastActivityAt,
159 messageCount: current.messageCount,
160 repo: current.repo,
161 containsPatch: current.containsPatch,
162 isUnread: false
163 ) 147 )
148 updateThread(thread, isUnread: false)
149 }
150
151 func markThreadUnread(_ thread: InboxThreadSummary) {
152 InboxReadStateStore.markUnread(for: thread.id)
153 inboxListLogger.debug(
154 "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
155 )
156 updateThread(thread, isUnread: true)
157 }
158
159 func toggleThreadReadState(_ thread: InboxThreadSummary) {
160 if thread.isUnread {
161 markThreadRead(thread)
162 } else {
163 markThreadUnread(thread)
164 }
164 } 165 }
165 166
166 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { 167 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
@@ -211,7 +212,7 @@ final class InboxViewModel {
211 do { 212 do {
212 return (try await self.fetchThreads(for: mailingList), nil) 213 return (try await self.fetchThreads(for: mailingList), nil)
213 } catch { 214 } catch {
214 return ([], error.localizedDescription) 215 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
215 } 216 }
216 } 217 }
217 } 218 }
@@ -229,6 +230,9 @@ final class InboxViewModel {
229 230
230 summaries.append(contentsOf: batchResult.0) 231 summaries.append(contentsOf: batchResult.0)
231 failureMessages.append(contentsOf: batchResult.1) 232 failureMessages.append(contentsOf: batchResult.1)
233 for failure in batchResult.1 {
234 inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
235 }
232 startIndex = endIndex 236 startIndex = endIndex
233 } 237 }
234 238
@@ -248,11 +252,15 @@ final class InboxViewModel {
248 ) 252 )
249 253
250 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in 254 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
251 let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
252 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())" 255 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())"
256 let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
257 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
253 inboxListLogger.debug( 258 inboxListLogger.debug(
254 "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)" 259 "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)"
255 ) 260 )
261 inboxListLogger.debug(
262 "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
263 )
256 return InboxThreadSummary( 264 return InboxThreadSummary(
257 rootEmailID: thread.root.id, 265 rootEmailID: thread.root.id,
258 rootMessageID: thread.root.messageID, 266 rootMessageID: thread.root.messageID,
@@ -268,7 +276,7 @@ final class InboxViewModel {
268 messageCount: thread.replies + 1, 276 messageCount: thread.replies + 1,
269 repo: Self.deriveRepositoryName(from: mailingList.name), 277 repo: Self.deriveRepositoryName(from: mailingList.name),
270 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), 278 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
271 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) 279 isUnread: isUnread
272 ) 280 )
273 } 281 }
274 } 282 }
@@ -318,6 +326,28 @@ final class InboxViewModel {
318 } 326 }
319 } 327 }
320 328
329 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
330 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
331 let current = threads[index]
332 threads[index] = InboxThreadSummary(
333 rootEmailID: current.rootEmailID,
334 rootMessageID: current.rootMessageID,
335 threadRootEmailIDs: current.threadRootEmailIDs,
336 threadRootMessageIDs: current.threadRootMessageIDs,
337 listID: current.listID,
338 listRID: current.listRID,
339 listName: current.listName,
340 listOwner: current.listOwner,
341 subject: current.subject,
342 latestSender: current.latestSender,
343 lastActivityAt: current.lastActivityAt,
344 messageCount: current.messageCount,
345 repo: current.repo,
346 containsPatch: current.containsPatch,
347 isUnread: isUnread
348 )
349 }
350
321 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { 351 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
322 var seen = Set<String>() 352 var seen = Set<String>()
323 return mailingLists.filter { mailingList in 353 return mailingLists.filter { mailingList in
Hutch/Views/Inbox/ThreadDetailView.swift +9 −1
@@ -93,6 +93,14 @@ struct ThreadDetailView: View {
93 .padding(.vertical, 4) 93 .padding(.vertical, 4)
94 } 94 }
95 95
96 if let partialWarning = viewModel.partialWarning {
97 Section {
98 Text(partialWarning)
99 .font(.caption)
100 .foregroundStyle(.secondary)
101 }
102 }
103
96 ForEach(thread.messages) { message in 104 ForEach(thread.messages) { message in
97 InboxMessageRow(message: message) 105 InboxMessageRow(message: message)
98 } 106 }
@@ -111,7 +119,7 @@ struct ThreadDetailView: View {
111 SRHTLoadingStateView(message: "Loading thread…") 119 SRHTLoadingStateView(message: "Loading thread…")
112 } else if let error = viewModel.error, viewModel.thread == nil { 120 } else if let error = viewModel.error, viewModel.thread == nil {
113 SRHTErrorStateView( 121 SRHTErrorStateView(
114 title: "Couldn't Load Thread", 122 title: "Failed to load thread",
115 message: error, 123 message: error,
116 retryAction: { await viewModel.loadThread() } 124 retryAction: { await viewModel.loadThread() }
117 ) 125 )
Hutch/Views/Inbox/ThreadViewModel.swift +87 −29
@@ -61,6 +61,7 @@ final class ThreadViewModel {
61 private(set) var thread: InboxThreadDetail? 61 private(set) var thread: InboxThreadDetail?
62 private(set) var isLoading = false 62 private(set) var isLoading = false
63 var error: String? 63 var error: String?
64 var partialWarning: String?
64 var composeDraft: MailComposeDraft? 65 var composeDraft: MailComposeDraft?
65 66
66 private let summary: InboxThreadSummary 67 private let summary: InboxThreadSummary
@@ -168,6 +169,7 @@ final class ThreadViewModel {
168 guard !isLoading else { return } 169 guard !isLoading else { return }
169 isLoading = true 170 isLoading = true
170 error = nil 171 error = nil
172 partialWarning = nil
171 defer { isLoading = false } 173 defer { isLoading = false }
172 174
173 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") 175 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
@@ -187,18 +189,27 @@ final class ThreadViewModel {
187 ) 189 )
188 var messagesByID: [Int: InboxMessage] = [:] 190 var messagesByID: [Int: InboxMessage] = [:]
189 191
192 var hadPartialReplyFailure = false
193
190 for payload in threadPayloads { 194 for payload in threadPayloads {
191 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { 195 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
192 continue 196 continue
193 } 197 }
194 messagesByID[rootMessage.id] = rootMessage 198 messagesByID[rootMessage.id] = rootMessage
195 199
196 let descendantMessages = try await fetchAllDescendantMessages( 200 do {
197 initialPayload: payload, 201 let descendantMessages = try await fetchAllDescendantMessages(
198 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) 202 initialPayload: payload,
199 ) 203 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
200 for message in descendantMessages { 204 )
201 messagesByID[message.id] = message 205 for message in descendantMessages {
206 messagesByID[message.id] = message
207 }
208 } catch {
209 hadPartialReplyFailure = true
210 inboxLogger.error(
211 "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
212 )
202 } 213 }
203 } 214 }
204 215
@@ -223,9 +234,15 @@ final class ThreadViewModel {
223 messageCount: max(messages.count, summary.messageCount ?? 0), 234 messageCount: max(messages.count, summary.messageCount ?? 0),
224 messages: messages 235 messages: messages
225 ) 236 )
237 if hadPartialReplyFailure {
238 partialWarning = "Some replies could not be loaded."
239 }
226 } catch { 240 } catch {
227 thread = nil 241 if thread == nil {
228 self.error = error.localizedDescription 242 self.error = "Failed to load thread"
243 } else {
244 self.error = error.localizedDescription
245 }
229 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") 246 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
230 } 247 }
231 } 248 }
@@ -318,18 +335,27 @@ final class ThreadViewModel {
318 variables["cursor"] = threadCursor 335 variables["cursor"] = threadCursor
319 } 336 }
320 337
321 let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest( 338 let response: InboxThreadDetailResponse
322 client: client, 339 do {
323 query: Self.threadDetailQuery, 340 response = try await Self.executeGraphQLRequest(
324 variables: { 341 client: client,
325 var variables = variables 342 query: Self.threadDetailQuery,
326 variables["descCursor"] = nil as String? 343 variables: {
327 return variables 344 var variables = variables
328 }() 345 variables["descCursor"] = nil as String?
329 ) 346 return variables
347 }()
348 )
349 } catch {
350 if Self.isRecoverableNoRows(error) {
351 inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
352 return nil
353 }
354 throw error
355 }
330 356
331 guard let threadPage = response.list?.threads else { 357 guard let threadPage = response.list?.threads else {
332 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) 358 return nil
333 } 359 }
334 360
335 let candidates = threadPage.results.map { payload in 361 let candidates = threadPage.results.map { payload in
@@ -389,15 +415,26 @@ final class ThreadViewModel {
389 candidateMessageIDs: [String] 415 candidateMessageIDs: [String]
390 ) async throws -> InboxThreadMessagesPage? { 416 ) async throws -> InboxThreadMessagesPage? {
391 for messageID in candidateMessageIDs { 417 for messageID in candidateMessageIDs {
392 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( 418 let response: InboxThreadLookupResponse
393 client: client, 419 do {
394 query: Self.threadByMessageIDQuery, 420 response = try await Self.executeGraphQLRequest(
395 variables: [ 421 client: client,
396 "rid": summary.listRID, 422 query: Self.threadByMessageIDQuery,
397 "messageID": messageID, 423 variables: [
398 "descCursor": cursor 424 "rid": summary.listRID,
399 ] 425 "messageID": messageID,
400 ) 426 "descCursor": cursor
427 ]
428 )
429 } catch {
430 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 )
434 continue
435 }
436 throw error
437 }
401 438
402 if let descendants = response.list?.message?.thread?.descendants { 439 if let descendants = response.list?.message?.thread?.descendants {
403 return descendants 440 return descendants
@@ -533,7 +570,8 @@ final class ThreadViewModel {
533 } 570 }
534 571
535 private static func sanitizedDisplayBody(from body: String) -> String { 572 private static func sanitizedDisplayBody(from body: String) -> String {
536 let lines = body.components(separatedBy: .newlines) 573 let normalizedBody = normalizeLineEndings(in: body)
574 let lines = normalizedBody.components(separatedBy: "\n")
537 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] 575 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
538 var headerCount = 0 576 var headerCount = 0
539 var blankLineIndex: Int? 577 var blankLineIndex: Int?
@@ -551,7 +589,7 @@ final class ThreadViewModel {
551 } 589 }
552 590
553 guard headerCount >= 2, let blankLineIndex else { 591 guard headerCount >= 2, let blankLineIndex else {
554 return body 592 return stripLeadingFromLineIfPresent(in: normalizedBody)
555 } 593 }
556 594
557 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") 595 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
@@ -644,6 +682,19 @@ final class ThreadViewModel {
644 .replacingOccurrences(of: "\r", with: "\n") 682 .replacingOccurrences(of: "\r", with: "\n")
645 } 683 }
646 684
685 private static func stripLeadingFromLineIfPresent(in body: String) -> String {
686 let lines = body.components(separatedBy: "\n")
687 guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
688 return body
689 }
690
691 var remainingLines = Array(lines.dropFirst())
692 if let nextLine = remainingLines.first, nextLine.isEmpty {
693 remainingLines.removeFirst()
694 }
695 return remainingLines.joined(separator: "\n")
696 }
697
647 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { 698 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
648 let prefix = "\(headerName):" 699 let prefix = "\(headerName):"
649 let lines = body.components(separatedBy: .newlines) 700 let lines = body.components(separatedBy: .newlines)
@@ -699,4 +750,11 @@ final class ThreadViewModel {
699 } 750 }
700 return payload 751 return payload
701 } 752 }
753
754 private static func isRecoverableNoRows(_ error: Error) -> Bool {
755 guard case let SRHTError.graphQLErrors(errors) = error else {
756 return false
757 }
758 return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
759 }
702} 760}
Hutch/Views/Repositories/DiffView.swift +141 −6
@@ -9,8 +9,73 @@ struct DiffView: View {
9 let diff: String 9 let diff: String
10 10
11 var body: some View { 11 var body: some View {
12 let lines = normalizedDiff.components(separatedBy: "\n") 12 VStack(alignment: .leading, spacing: 12) {
13 ForEach(fileSections) { section in
14 DiffFileSectionView(section: section)
15 }
16 }
17 }
18
19 private var fileSections: [DiffFileSection] {
20 DiffFileSection.parse(from: normalizedDiff)
21 }
13 22
23 private var normalizedDiff: String {
24 diff
25 .replacingOccurrences(of: "\r\n", with: "\n")
26 .replacingOccurrences(of: "\r", with: "\n")
27 }
28}
29
30private struct DiffFileSectionView: View {
31 let section: DiffFileSection
32 @State private var isExpanded = true
33
34 var body: some View {
35 VStack(alignment: .leading, spacing: 0) {
36 Button {
37 isExpanded.toggle()
38 } label: {
39 HStack(spacing: 10) {
40 Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
41 .font(.caption.weight(.semibold))
42 .foregroundStyle(.secondary)
43 .frame(width: 12)
44
45 Text(section.filename)
46 .font(.subheadline.weight(.semibold))
47 .foregroundStyle(.primary)
48 .lineLimit(1)
49
50 Spacer(minLength: 8)
51
52 Text(section.changeSummary)
53 .font(.caption.weight(.medium))
54 .foregroundStyle(.secondary)
55 }
56 .padding(.horizontal, 10)
57 .padding(.vertical, 8)
58 .contentShape(Rectangle())
59 }
60 .buttonStyle(.plain)
61 .background(Color(.tertiarySystemBackground))
62
63 if isExpanded {
64 DiffBlockView(lines: section.lines)
65 }
66 }
67 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
68 .overlay {
69 RoundedRectangle(cornerRadius: 8, style: .continuous)
70 .strokeBorder(Color.primary.opacity(0.06))
71 }
72 }
73}
74
75private struct DiffBlockView: View {
76 let lines: [String]
77
78 var body: some View {
14 VStack(alignment: .leading, spacing: 0) { 79 VStack(alignment: .leading, spacing: 0) {
15 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in 80 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
16 DiffLineView(line: line) 81 DiffLineView(line: line)
@@ -19,13 +84,83 @@ struct DiffView: View {
19 .font(.system(.caption, design: .monospaced)) 84 .font(.system(.caption, design: .monospaced))
20 .frame(maxWidth: .infinity, alignment: .leading) 85 .frame(maxWidth: .infinity, alignment: .leading)
21 .background(Color(.secondarySystemBackground)) 86 .background(Color(.secondarySystemBackground))
22 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
23 } 87 }
88}
24 89
25 private var normalizedDiff: String { 90private struct DiffFileSection: Identifiable {
26 diff 91 let id: String
27 .replacingOccurrences(of: "\r\n", with: "\n") 92 let filename: String
28 .replacingOccurrences(of: "\r", with: "\n") 93 let lines: [String]
94 let additions: Int
95 let deletions: Int
96
97 var changeSummary: String {
98 "+\(additions) -\(deletions)"
99 }
100
101 static func parse(from diff: String) -> [DiffFileSection] {
102 let lines = diff.components(separatedBy: "\n")
103 guard !lines.isEmpty else { return [] }
104
105 let boundaries = lines.enumerated().compactMap { index, line in
106 line.hasPrefix("diff --git ") ? index : nil
107 }
108
109 guard !boundaries.isEmpty else {
110 let section = makeSection(lines: lines, fallbackIndex: 0)
111 return section.lines.isEmpty ? [] : [section]
112 }
113
114 var sections: [DiffFileSection] = []
115 for (position, startIndex) in boundaries.enumerated() {
116 let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count
117 let sectionLines = Array(lines[startIndex..<endIndex])
118 let section = makeSection(lines: sectionLines, fallbackIndex: position)
119 if !section.lines.isEmpty {
120 sections.append(section)
121 }
122 }
123 return sections
124 }
125
126 private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection {
127 let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)"
128 let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count
129 let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count
130 return DiffFileSection(
131 id: "\(fallbackIndex)-\(filename)",
132 filename: filename,
133 lines: lines,
134 additions: additions,
135 deletions: deletions
136 )
137 }
138
139 private static func fileName(from lines: [String]) -> String? {
140 if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) {
141 let parts = diffHeader.split(separator: " ")
142 if let rhs = parts.last, rhs.hasPrefix("b/") {
143 return String(rhs.dropFirst(2))
144 }
145 }
146
147 if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) {
148 let path = String(plusHeader.dropFirst(4))
149 if path.hasPrefix("b/") {
150 return String(path.dropFirst(2))
151 }
152 return path
153 }
154
155 if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) {
156 let path = String(minusHeader.dropFirst(4))
157 if path.hasPrefix("a/") {
158 return String(path.dropFirst(2))
159 }
160 return path
161 }
162
163 return nil
29 } 164 }
30} 165}
31 166
Hutch/Views/Tickets/TicketDetailView.swift +47 −4
@@ -132,7 +132,7 @@ struct TicketDetailView: View {
132 ScrollView { 132 ScrollView {
133 VStack(alignment: .leading, spacing: 0) { 133 VStack(alignment: .leading, spacing: 0) {
134 // Header 134 // Header
135 ticketHeader(ticket) 135 ticketHeader(ticket, viewModel: viewModel)
136 136
137 Divider() 137 Divider()
138 .padding(.vertical, 12) 138 .padding(.vertical, 12)
@@ -186,10 +186,16 @@ struct TicketDetailView: View {
186 // MARK: - Header 186 // MARK: - Header
187 187
188 @ViewBuilder 188 @ViewBuilder
189 private func ticketHeader(_ ticket: TicketDetail) -> some View { 189 private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
190 VStack(alignment: .leading, spacing: 8) { 190 VStack(alignment: .leading, spacing: 8) {
191 Text(ticket.title) 191 HStack(alignment: .top, spacing: 12) {
192 .font(.title3.weight(.semibold)) 192 Text(ticket.title)
193 .font(.title3.weight(.semibold))
194
195 Spacer(minLength: 12)
196
197 assignToMeButton(ticket: ticket, viewModel: viewModel)
198 }
193 199
194 HStack(spacing: 8) { 200 HStack(spacing: 8) {
195 TicketStatusIcon(status: ticket.status) 201 TicketStatusIcon(status: ticket.status)
@@ -235,6 +241,43 @@ struct TicketDetailView: View {
235 .padding() 241 .padding()
236 } 242 }
237 243
244 @ViewBuilder
245 private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
246 if let currentUser = appState.currentUser {
247 let isAssignedToCurrentUser = ticket.assignees.contains {
248 TicketDetailViewModel.matchesAssignee($0, user: currentUser)
249 }
250
251 if isAssignedToCurrentUser {
252 Label("Assigned to you", systemImage: "checkmark.circle.fill")
253 .font(.caption.weight(.medium))
254 .foregroundStyle(.secondary)
255 .padding(.horizontal, 10)
256 .padding(.vertical, 6)
257 .background(Color(.secondarySystemFill), in: Capsule())
258 } else {
259 Button {
260 Task {
261 await viewModel.assignToCurrentUser(currentUser)
262 }
263 } label: {
264 if viewModel.isPerformingAction {
265 ProgressView()
266 .controlSize(.small)
267 .frame(minWidth: 88)
268 } else {
269 Text("Assign to Me")
270 .font(.caption.weight(.semibold))
271 .frame(minWidth: 88)
272 }
273 }
274 .buttonStyle(.borderedProminent)
275 .controlSize(.small)
276 .disabled(viewModel.isPerformingAction)
277 }
278 }
279 }
280
238 // MARK: - Comment Input 281 // MARK: - Comment Input
239 282
240 @ViewBuilder 283 @ViewBuilder
Hutch/Views/Tickets/TicketDetailViewModel.swift +80
@@ -433,6 +433,64 @@ final class TicketDetailViewModel {
433 isPerformingAction = false 433 isPerformingAction = false
434 } 434 }
435 435
436 func assignToCurrentUser(_ user: User) async {
437 guard !isPerformingAction, let currentTicket = ticket else { return }
438
439 let currentAssignees = currentTicket.assignees
440 let currentEntity = Entity(canonicalName: user.canonicalName)
441 guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
442 return
443 }
444
445 isPerformingAction = true
446 error = nil
447
448 ticket = TicketDetail(
449 id: currentTicket.id,
450 created: currentTicket.created,
451 updated: currentTicket.updated,
452 title: currentTicket.title,
453 description: currentTicket.description,
454 status: currentTicket.status,
455 resolution: currentTicket.resolution,
456 authenticity: currentTicket.authenticity,
457 submitter: currentTicket.submitter,
458 assignees: currentAssignees + [currentEntity],
459 labels: currentTicket.labels
460 )
461
462 do {
463 _ = try await client.execute(
464 service: .todo,
465 query: Self.assignUserMutation,
466 variables: [
467 "trackerId": trackerId,
468 "ticketId": ticketId,
469 "userId": user.id
470 ],
471 responseType: AssignUserResponse.self
472 )
473 await loadTicket()
474 } catch {
475 ticket = TicketDetail(
476 id: currentTicket.id,
477 created: currentTicket.created,
478 updated: currentTicket.updated,
479 title: currentTicket.title,
480 description: currentTicket.description,
481 status: currentTicket.status,
482 resolution: currentTicket.resolution,
483 authenticity: currentTicket.authenticity,
484 submitter: currentTicket.submitter,
485 assignees: currentAssignees,
486 labels: currentTicket.labels
487 )
488 self.error = error.localizedDescription
489 }
490
491 isPerformingAction = false
492 }
493
436 func unassignUser(username: String) async { 494 func unassignUser(username: String) async {
437 guard !isPerformingAction else { return } 495 guard !isPerformingAction else { return }
438 isPerformingAction = true 496 isPerformingAction = true
@@ -558,4 +616,26 @@ final class TicketDetailViewModel {
558 isPerformingAction = false 616 isPerformingAction = false
559 } 617 }
560 618
619 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
620 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
621 let userCanonical = normalizedCanonicalName(user.canonicalName)
622 if assigneeCanonical == userCanonical {
623 return true
624 }
625 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
626 }
627
628 private static func normalizedCanonicalName(_ value: String) -> String {
629 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
630 if trimmed.hasPrefix("~") {
631 return trimmed
632 }
633 return "~\(trimmed)"
634 }
635
636 private static func normalizedUsername(_ value: String) -> String {
637 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
638 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
639 }
640
561} 641}