Commit 165f0cf230

165f0cf23046ed3a78c4793d58edb17fc1e21c90

parent: 63dd5cadae

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 05:34 UTC

improve home attention flows and cross-linking

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 30;
518 CURRENT_PROJECT_VERSION = 31;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.13.1;
535 MARKETING_VERSION = 2.14.0;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 30;
555 CURRENT_PROJECT_VERSION = 31;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.13.1;
572 MARKETING_VERSION = 2.14.0;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 30;
635 CURRENT_PROJECT_VERSION = 31;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.13.1;
645 MARKETING_VERSION = 2.14.0;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 30;
664 CURRENT_PROJECT_VERSION = 31;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.13.1;
674 MARKETING_VERSION = 2.14.0;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +31 −5
@@ -261,22 +261,48 @@ final class AppState {
261261
262262 func openProjectSource(_ source: Project.SourceRepo) async throws {
263263 let repository = try await resolveProjectSource(source)
264 pendingTabNavigation = .repository(repository)
265 selectedTab = .repositories
264 navigateToRepository(repository)
266265 }
267266
268267 func openProjectTracker(_ tracker: Project.Tracker) async throws {
269268 let resolvedTracker = try await resolveProjectTracker(tracker)
270 pendingTabNavigation = .tracker(resolvedTracker)
271 selectedTab = .tickets
269 navigateToTracker(resolvedTracker)
272270 }
273271
274272 func openMailingList(_ mailingList: InboxMailingListReference) {
273 navigateToMailingList(mailingList)
274 }
275
276 func openSystemStatus() {
277 navigateToSystemStatus()
278 }
279
280 func navigateToRepository(_ repository: RepositorySummary) {
281 pendingTabNavigation = .repository(repository)
282 selectedTab = .repositories
283 }
284
285 func navigateToTracker(_ tracker: TrackerSummary) {
286 pendingTabNavigation = .tracker(tracker)
287 selectedTab = .tickets
288 }
289
290 func navigateToBuild(jobId: Int) {
291 pendingDeepLink = .build(jobId: jobId)
292 selectedTab = .builds
293 }
294
295 func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) {
296 pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId)
297 selectedTab = .tickets
298 }
299
300 func navigateToMailingList(_ mailingList: InboxMailingListReference) {
275301 pendingTabNavigation = .mailingList(mailingList)
276302 selectedTab = .more
277303 }
278304
279 func openSystemStatus() {
305 func navigateToSystemStatus() {
280306 pendingTabNavigation = .systemStatus
281307 selectedTab = .more
282308 }
Hutch/Models/Inbox.swift +6 −2
@@ -52,10 +52,14 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
5252 }
5353
5454 var threadGroupingKey: String {
55 "\(listRID)#\(displaySubject.lowercased())"
55 "\(listRID)#\(Self.normalizationKey(for: subject))"
5656 }
5757
58 private static func normalizedSubject(from subject: String) -> String {
58 nonisolated static func normalizationKey(for subject: String) -> String {
59 normalizedSubject(from: subject).lowercased()
60 }
61
62 private nonisolated static func normalizedSubject(from subject: String) -> String {
5963 let collapsedWhitespace = subject
6064 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
6165 .trimmingCharacters(in: .whitespacesAndNewlines)
Hutch/Views/Builds/BuildDetailView.swift +38
@@ -9,6 +9,7 @@ struct BuildDetailView: View {
99 @State private var selectedTaskName: String?
1010 @State private var showEditResubmitSheet = false
1111 @State private var showCancelConfirmation = false
12 @State private var isOpeningRepository = false
1213
1314 private var isPresentingLogSheet: Bool {
1415 selectedTaskName != nil
@@ -158,6 +159,28 @@ struct BuildDetailView: View {
158159 LabeledContent("Updated", value: job.updated.relativeDescription)
159160 }
160161
162 if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
163 Section("Source") {
164 Button {
165 openRepository(ownerCanonicalName: repositoryReference.ownerCanonicalName, repositoryName: repositoryReference.name)
166 } label: {
167 HStack {
168 Label("\(repositoryReference.ownerCanonicalName)/\(repositoryReference.name)", systemImage: "book.closed")
169 Spacer()
170 if isOpeningRepository {
171 ProgressView()
172 .controlSize(.small)
173 } else {
174 Image(systemName: "arrow.up.right")
175 .font(.caption)
176 .foregroundStyle(.tertiary)
177 }
178 }
179 }
180 .disabled(isOpeningRepository)
181 }
182 }
183
161184 // Per-task logs
162185 if !job.tasks.isEmpty {
163186 ForEach(job.tasks) { task in
@@ -241,6 +264,21 @@ struct BuildDetailView: View {
241264 ))
242265 }
243266 }
267
268 private func openRepository(ownerCanonicalName: String, repositoryName: String) {
269 guard !isOpeningRepository else { return }
270 isOpeningRepository = true
271 Task {
272 defer { isOpeningRepository = false }
273 do {
274 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
275 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
276 appState.navigateToRepository(repository)
277 } catch {
278 appState.presentRepositoryDeepLinkError()
279 }
280 }
281 }
244282}
245283
246284private struct EditResubmitBuildSheet: View {
Hutch/Views/Builds/BuildListView.swift +11
@@ -65,6 +65,17 @@ struct BuildListView: View {
6565 @Bindable var vm = viewModel
6666
6767 List {
68 Section {
69 Picker("Filter", selection: $vm.filter) {
70 ForEach(BuildListFilter.allCases, id: \.self) { filter in
71 Text(filter.rawValue).tag(filter)
72 }
73 }
74 .pickerStyle(.segmented)
75 .listRowBackground(Color.clear)
76 .listRowInsets(EdgeInsets())
77 }
78
6879 ForEach(viewModel.filteredJobs) { job in
6980 NavigationLink(value: job) {
7081 BuildRowView(job: job)
Hutch/Views/Builds/BuildListViewModel.swift +33 −2
@@ -19,6 +19,12 @@ private struct SubmittedJob: Decodable, Sendable {
1919 let id: Int
2020}
2121
22enum BuildListFilter: String, CaseIterable, Sendable {
23 case attention = "Attention"
24 case active = "Active"
25 case all = "All"
26}
27
2228// MARK: - View Model
2329
2430@Observable
@@ -31,6 +37,7 @@ final class BuildListViewModel {
3137 private(set) var isRefreshing = false
3238 private(set) var isSubmitting = false
3339 var error: String?
40 var filter: BuildListFilter = .attention
3441 var searchText = ""
3542
3643 private var cursor: String?
@@ -44,9 +51,10 @@ final class BuildListViewModel {
4451 }
4552
4653 var filteredJobs: [JobSummary] {
54 let statusFiltered = Self.filterJobs(jobs, filter: filter)
4755 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
48 guard !q.isEmpty else { return jobs }
49 return jobs.filter {
56 guard !q.isEmpty else { return statusFiltered }
57 return statusFiltered.filter {
5058 String($0.id).contains(q) ||
5159 $0.tags.contains { $0.lowercased().contains(q) } ||
5260 ($0.note?.lowercased().contains(q) == true) ||
@@ -272,4 +280,27 @@ final class BuildListViewModel {
272280
273281 let cancel: CancelResult
274282 }
283
284 nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] {
285 jobs.filter { job in
286 switch filter {
287 case .attention:
288 switch job.status {
289 case .failed, .timeout, .running, .queued, .pending:
290 return true
291 case .success, .cancelled:
292 return false
293 }
294 case .active:
295 switch job.status {
296 case .running, .queued, .pending:
297 return true
298 case .success, .failed, .cancelled, .timeout:
299 return false
300 }
301 case .all:
302 return true
303 }
304 }
305 }
275306}
Hutch/Views/Home/HomeView.swift +332 −46
@@ -55,7 +55,9 @@ struct HomeView: View {
5555 @ViewBuilder
5656 private func content(_ viewModel: HomeViewModel) -> some View {
5757 List {
58 attentionSection(viewModel)
5859 systemStatusBannerSection(viewModel)
60 inboxSection(viewModel)
5961 projectsSection(viewModel)
6062 assignedTicketsSection(viewModel)
6163 recentBuildsSection(viewModel)
@@ -63,15 +65,17 @@ struct HomeView: View {
6365 .listStyle(.insetGrouped)
6466 .overlay {
6567 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds &&
66 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty {
68 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
69 viewModel.unreadInboxThreads.isEmpty {
6770 SRHTLoadingStateView(message: "Loading Home…")
6871 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
6972 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
73 viewModel.unreadInboxThreads.isEmpty &&
7074 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
7175 ContentUnavailableView(
7276 "All Clear",
7377 systemImage: "checkmark.circle",
74 description: Text("There are no assigned tickets or recent builds right now.")
78 description: Text("There are no unread threads, assigned tickets, or urgent builds right now.")
7579 )
7680 }
7781 }
@@ -83,6 +87,41 @@ struct HomeView: View {
8387 }
8488 }
8589
90 @ViewBuilder
91 private func attentionSection(_ viewModel: HomeViewModel) -> some View {
92 Section("Needs Attention") {
93 HomeAttentionSummaryRow(
94 title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention",
95 summary: viewModel.attentionSummaryText
96 )
97
98 HomeAttentionLinkRow(
99 title: "Inbox",
100 summary: viewModel.inboxSummaryText,
101 countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?"
102 ) {
103 InboxView()
104 }
105
106 HomeAttentionLinkRow(
107 title: "Assigned Tickets",
108 summary: viewModel.ticketsSummaryText,
109 countText: String(viewModel.assignedTickets.count)
110 ) {
111 HomeAssignedTicketsListView(viewModel: viewModel)
112 }
113
114 HomeAttentionLinkRow(
115 title: "Builds",
116 summary: viewModel.buildsSummaryText,
117 countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount),
118 action: {
119 appState.selectedTab = .builds
120 }
121 )
122 }
123 }
124
86125 @ViewBuilder
87126 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
88127 Section {
@@ -100,6 +139,40 @@ struct HomeView: View {
100139 }
101140 }
102141
142 @ViewBuilder
143 private func inboxSection(_ viewModel: HomeViewModel) -> some View {
144 Section {
145 if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 {
146 HomeSectionMessageRow(
147 text: "No unread inbox threads.",
148 systemImage: "tray"
149 )
150 } else if viewModel.unreadInboxThreads.isEmpty {
151 HomeSectionMessageRow(
152 text: viewModel.inboxSummaryText,
153 systemImage: "tray"
154 )
155 } else {
156 ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in
157 NavigationLink {
158 ThreadDetailView(
159 thread: thread,
160 onViewed: { viewModel.markInboxThreadRead(thread) },
161 onMarkRead: { viewModel.markInboxThreadRead(thread) },
162 onMarkUnread: { viewModel.markInboxThreadUnread(thread) }
163 )
164 } label: {
165 HomeInboxThreadRow(thread: thread)
166 }
167 }
168 }
169 } header: {
170 HomeSectionHeader("Inbox") {
171 InboxView()
172 }
173 }
174 }
175
103176 @ViewBuilder
104177 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
105178 if !viewModel.projects.isEmpty {
@@ -312,41 +385,55 @@ private struct HomeProjectsListView: View {
312385}
313386
314387private struct HomeBuildRow: View {
388 @Environment(AppState.self) private var appState
315389 let build: HomeBuildItem
316390
317391 var body: some View {
318 HStack(spacing: 12) {
319 JobStatusIcon(status: build.job.status)
320 .frame(width: 20)
321
322 VStack(alignment: .leading, spacing: 4) {
323 Text(primaryTitle)
324 .font(.subheadline.weight(.medium))
325 .lineLimit(1)
326
327 HStack(spacing: 8) {
328 Text("Job #\(build.job.id)")
329 .font(.caption)
330 .foregroundStyle(.secondary)
331
332 Text("•")
333 .font(.caption)
334 .foregroundStyle(.tertiary)
392 VStack(alignment: .leading, spacing: 6) {
393 HStack(spacing: 12) {
394 JobStatusIcon(status: build.job.status)
395 .frame(width: 20)
396
397 VStack(alignment: .leading, spacing: 4) {
398 Text(primaryTitle)
399 .font(.subheadline.weight(.medium))
400 .lineLimit(1)
401
402 HStack(spacing: 8) {
403 Text("Job #\(build.job.id)")
404 .font(.caption)
405 .foregroundStyle(.secondary)
406
407 Text("•")
408 .font(.caption)
409 .foregroundStyle(.tertiary)
410
411 Text(build.job.status.rawValue.capitalized)
412 .font(.caption)
413 .foregroundStyle(.secondary)
414
415 Text("•")
416 .font(.caption)
417 .foregroundStyle(.tertiary)
418
419 Text(build.job.created.relativeDescription)
420 .font(.caption)
421 .foregroundStyle(.tertiary)
422
423 Spacer()
424 }
425 }
426 }
335427
336 Text(build.job.status.rawValue.capitalized)
428 if let repositoryDisplayName = build.repositoryDisplayName {
429 Button {
430 openRepository()
431 } label: {
432 Label(repositoryDisplayName, systemImage: "book.closed")
337433 .font(.caption)
338434 .foregroundStyle(.secondary)
339
340 Text("•")
341 .font(.caption)
342 .foregroundStyle(.tertiary)
343
344 Text(build.job.created.relativeDescription)
345 .font(.caption)
346 .foregroundStyle(.tertiary)
347
348 Spacer()
349435 }
436 .buttonStyle(.plain)
350437 }
351438 }
352439 .padding(.vertical, 2)
@@ -358,38 +445,148 @@ private struct HomeBuildRow: View {
358445 }
359446 return build.job.displayLabel
360447 }
448
449 private func openRepository() {
450 guard let repositoryName = build.repositoryName,
451 let repositoryOwner = build.repositoryOwner else { return }
452 Task {
453 do {
454 let repository = try await appState.resolveRepository(
455 owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner,
456 name: repositoryName
457 )
458 appState.navigateToRepository(repository)
459 } catch {
460 appState.presentRepositoryDeepLinkError()
461 }
462 }
463 }
361464}
362465
363466private struct HomeAssignedTicketRow: View {
467 @Environment(AppState.self) private var appState
364468 let ticket: HomeAssignedTicket
365469
366470 var body: some View {
367 HStack(alignment: .top, spacing: 12) {
368 TicketStatusIcon(status: ticket.ticket.status)
369 .frame(width: 20)
471 VStack(alignment: .leading, spacing: 6) {
472 HStack(alignment: .top, spacing: 12) {
473 TicketStatusIcon(status: ticket.ticket.status)
474 .frame(width: 20)
370475
371 VStack(alignment: .leading, spacing: 4) {
372 Text(ticket.ticket.title)
373 .font(.subheadline.weight(.medium))
374 .lineLimit(2)
476 VStack(alignment: .leading, spacing: 4) {
477 Text(ticket.ticket.title)
478 .font(.subheadline.weight(.medium))
479 .lineLimit(2)
375480
376 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
377 .font(.caption)
481 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
482 .font(.caption)
483 .foregroundStyle(.secondary)
484 .lineLimit(1)
485 .truncationMode(.tail)
486 }
487
488 Spacer(minLength: 8)
489
490 Text(ticket.ticket.status.displayName)
491 .font(.caption2.weight(.medium))
378492 .foregroundStyle(.secondary)
379493 .lineLimit(1)
380 .truncationMode(.tail)
494 .fixedSize()
381495 }
382496
383 Spacer(minLength: 8)
497 Button {
498 openTracker()
499 } label: {
500 Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist")
501 .font(.caption)
502 .foregroundStyle(.secondary)
503 }
504 .buttonStyle(.plain)
505 }
506 .padding(.vertical, 2)
507 }
384508
385 Text(ticket.ticket.status.displayName)
386 .font(.caption2.weight(.medium))
387 .foregroundStyle(.secondary)
388 .lineLimit(1)
389 .fixedSize()
509 private func openTracker() {
510 Task {
511 do {
512 let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
513 appState.navigateToTracker(tracker)
514 } catch {
515 appState.presentTicketDeepLinkError()
516 }
517 }
518 }
519}
520
521private struct HomeInboxThreadRow: View {
522 @Environment(AppState.self) private var appState
523 let thread: InboxThreadSummary
524
525 var body: some View {
526 VStack(alignment: .leading, spacing: 6) {
527 HStack(alignment: .top, spacing: 10) {
528 Circle()
529 .fill(.blue)
530 .frame(width: 8, height: 8)
531 .padding(.top, 6)
532
533 VStack(alignment: .leading, spacing: 4) {
534 Text(thread.displaySubject)
535 .font(.subheadline.weight(.medium))
536 .lineLimit(2)
537
538 Text(thread.metadataLine)
539 .font(.caption)
540 .foregroundStyle(.secondary)
541 .lineLimit(1)
542 }
543 }
544
545 HStack(spacing: 10) {
546 Button {
547 appState.navigateToMailingList(
548 InboxMailingListReference(
549 id: thread.listID,
550 rid: thread.listRID,
551 name: thread.listName,
552 owner: thread.listOwner
553 )
554 )
555 } label: {
556 Label(thread.listName, systemImage: "list.bullet")
557 .font(.caption)
558 .foregroundStyle(.secondary)
559 }
560 .buttonStyle(.plain)
561
562 if let repo = thread.repo {
563 Button {
564 openRepository(named: repo)
565 } label: {
566 Label(repo, systemImage: "book.closed")
567 .font(.caption)
568 .foregroundStyle(.secondary)
569 }
570 .buttonStyle(.plain)
571 }
572 }
390573 }
391574 .padding(.vertical, 2)
392575 }
576
577 private func openRepository(named repositoryName: String) {
578 Task {
579 do {
580 let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~")
581 ? String(thread.listOwner.canonicalName.dropFirst())
582 : thread.listOwner.canonicalName
583 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
584 appState.navigateToRepository(repository)
585 } catch {
586 appState.presentRepositoryDeepLinkError()
587 }
588 }
589 }
393590}
394591
395592private struct HomeSectionLoadingRow: View {
@@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View {
431628 }
432629}
433630
631private struct HomeAttentionSummaryRow: View {
632 let title: String
633 let summary: String
634
635 var body: some View {
636 VStack(alignment: .leading, spacing: 4) {
637 Text(title)
638 .font(.subheadline.weight(.semibold))
639 Text(summary)
640 .font(.caption)
641 .foregroundStyle(.secondary)
642 .lineLimit(2)
643 }
644 .padding(.vertical, 2)
645 }
646}
647
648private struct HomeAttentionLinkRow<Destination: View>: View {
649 let title: String
650 let summary: String
651 let countText: String
652 let destination: Destination?
653 let action: (() -> Void)?
654
655 init(
656 title: String,
657 summary: String,
658 countText: String,
659 @ViewBuilder destination: () -> Destination
660 ) {
661 self.title = title
662 self.summary = summary
663 self.countText = countText
664 self.destination = destination()
665 self.action = nil
666 }
667
668 init(
669 title: String,
670 summary: String,
671 countText: String,
672 action: @escaping () -> Void
673 ) where Destination == EmptyView {
674 self.title = title
675 self.summary = summary
676 self.countText = countText
677 self.destination = nil
678 self.action = action
679 }
680
681 var body: some View {
682 Group {
683 if let destination {
684 NavigationLink {
685 destination
686 } label: {
687 content
688 }
689 } else if let action {
690 Button(action: action) {
691 content
692 }
693 .buttonStyle(.plain)
694 }
695 }
696 }
697
698 private var content: some View {
699 HStack(spacing: 12) {
700 VStack(alignment: .leading, spacing: 4) {
701 Text(title)
702 .font(.subheadline.weight(.medium))
703 Text(summary)
704 .font(.caption)
705 .foregroundStyle(.secondary)
706 .lineLimit(1)
707 }
708 Spacer()
709 Text(countText)
710 .font(.caption.weight(.semibold))
711 .foregroundStyle(.secondary)
712 .padding(.horizontal, 8)
713 .padding(.vertical, 4)
714 .background(Color(.secondarySystemFill), in: Capsule())
715 }
716 .padding(.vertical, 2)
717 }
718}
719
434720private struct HomeSectionActionHeader: View {
435721 let title: String
436722 let action: () -> Void
Hutch/Views/Home/HomeViewModel.swift +295 −27
@@ -63,8 +63,31 @@ private struct HomeInboxThreadPage: Decodable, Sendable {
6363}
6464
6565private struct HomeInboxThreadPayload: Decodable, Sendable {
66 let created: Date
6667 let updated: Date
6768 let subject: String
69 let replies: Int
70 let sender: Entity
71 let root: HomeInboxEmailPreview
72}
73
74private struct HomeInboxEmailPreview: Decodable, Sendable {
75 let id: Int
76 let subject: String
77 let date: Date?
78 let received: Date
79 let messageID: String
80 let body: String
81 let patch: HomeInboxPatchPreview?
82}
83
84private struct HomeInboxPatchPreview: Decodable, Sendable {
85 let subject: String?
86}
87
88private struct HomeInboxUnreadSnapshot: Sendable {
89 let unreadCount: Int
90 let threads: [InboxThreadSummary]
6891}
6992
7093private struct HomeTicketPayload: Decodable, Sendable {
@@ -135,6 +158,15 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
135158 }
136159 return repositoryName
137160 }
161
162 var requiresAttention: Bool {
163 switch job.status {
164 case .failed, .timeout, .running, .queued, .pending:
165 true
166 case .success, .cancelled:
167 false
168 }
169 }
138170}
139171
140172@Observable
@@ -143,6 +175,7 @@ final class HomeViewModel {
143175 private(set) var projects: [Project] = []
144176 var assignedTickets: [HomeAssignedTicket] = []
145177 var recentBuilds: [HomeBuildItem] = []
178 var unreadInboxThreads: [InboxThreadSummary] = []
146179 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
147180 private(set) var isLoadingSystemStatus = false
148181 private(set) var isShowingStaleSystemStatus = false
@@ -242,8 +275,20 @@ final class HomeViewModel {
242275 list(rid: $rid) {
243276 threads(cursor: $cursor) {
244277 results {
278 created
245279 updated
246280 subject
281 replies
282 sender { canonicalName }
283 root {
284 id
285 subject
286 date
287 received
288 messageID
289 body
290 patch { subject }
291 }
247292 }
248293 cursor
249294 }
@@ -291,7 +336,7 @@ final class HomeViewModel {
291336 async let projectsTask = loadProjects()
292337 async let jobsTask = loadRecentJobs()
293338 async let assignedTicketsTask = loadAssignedTickets()
294 async let inboxUnreadTask = loadInboxUnreadCount()
339 async let inboxUnreadTask = loadInboxUnreadSnapshot()
295340 async let systemStatusTask = loadSystemStatusSnapshot()
296341
297342 let projectsResult = await projectsTask
@@ -328,7 +373,9 @@ final class HomeViewModel {
328373 }
329374 isLoadingAssignedTickets = false
330375
331 unreadInboxThreadCount = await inboxUnreadTask
376 let inboxUnreadSnapshot = await inboxUnreadTask
377 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
378 unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
332379 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
333380 let systemStatusResult = await systemStatusTask
334381 switch systemStatusResult {
@@ -344,7 +391,116 @@ final class HomeViewModel {
344391 }
345392
346393 var hasDashboardContent: Bool {
347 !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil
394 !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
395 }
396
397 var failedBuildCount: Int {
398 recentBuilds.filter {
399 switch $0.job.status {
400 case .failed, .timeout:
401 return true
402 default:
403 return false
404 }
405 }.count
406 }
407
408 var activeBuildCount: Int {
409 recentBuilds.filter {
410 switch $0.job.status {
411 case .pending, .queued, .running:
412 return true
413 default:
414 return false
415 }
416 }.count
417 }
418
419 var activeIncidentCount: Int {
420 systemStatusSnapshot?.activeIncidents.count ?? 0
421 }
422
423 var disruptedServiceCount: Int {
424 systemStatusSnapshot?.disruptedServices.count ?? 0
425 }
426
427 var needsAttentionCount: Int {
428 var count = 0
429 if let unreadInboxThreadCount {
430 count += unreadInboxThreadCount
431 }
432 count += assignedTickets.count
433 count += failedBuildCount
434 count += activeBuildCount
435 if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
436 count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
437 }
438 return count
439 }
440
441 var attentionSummaryText: String {
442 if needsAttentionCount == 0 {
443 return "All clear"
444 }
445 var parts: [String] = []
446 if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
447 parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
448 }
449 if !assignedTickets.isEmpty {
450 parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
451 }
452 if failedBuildCount > 0 {
453 parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
454 }
455 if activeBuildCount > 0 {
456 parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
457 }
458 if disruptedServiceCount > 0 {
459 parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
460 }
461 return parts.joined(separator: " • ")
462 }
463
464 var inboxSummaryText: String {
465 guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
466 if unreadInboxThreadCount == 0 {
467 return "Inbox zero"
468 }
469 return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
470 }
471
472 var ticketsSummaryText: String {
473 if assignedTickets.isEmpty {
474 return "No open tickets assigned to you"
475 }
476 return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
477 }
478
479 var buildsSummaryText: String {
480 if recentBuilds.isEmpty {
481 return "No recent builds"
482 }
483 var parts: [String] = []
484 if failedBuildCount > 0 {
485 parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
486 }
487 if activeBuildCount > 0 {
488 parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
489 }
490 if parts.isEmpty {
491 return "Recent builds are clear"
492 }
493 return parts.joined(separator: " • ")
494 }
495
496 var systemSummaryText: String {
497 guard let systemStatusSnapshot else {
498 return systemStatusErrorMessage ?? "System status unavailable"
499 }
500 if systemStatusSnapshot.hasDisruption {
501 return systemStatusSnapshot.bannerSummary
502 }
503 return systemStatusSnapshot.overallStatusText
348504 }
349505
350506 func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -415,6 +571,43 @@ final class HomeViewModel {
415571 }
416572 }
417573
574 func markInboxThreadRead(_ thread: InboxThreadSummary) {
575 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
576 unreadInboxThreads.removeAll { $0.id == thread.id }
577 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
578 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
579 persistNeedsAttentionSnapshot()
580 }
581
582 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
583 InboxReadStateStore.markUnread(for: thread.id)
584 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
585 unreadInboxThreads.append(
586 InboxThreadSummary(
587 rootEmailID: thread.rootEmailID,
588 rootMessageID: thread.rootMessageID,
589 threadRootEmailIDs: thread.threadRootEmailIDs,
590 threadRootMessageIDs: thread.threadRootMessageIDs,
591 listID: thread.listID,
592 listRID: thread.listRID,
593 listName: thread.listName,
594 listOwner: thread.listOwner,
595 subject: thread.subject,
596 latestSender: thread.latestSender,
597 lastActivityAt: thread.lastActivityAt,
598 messageCount: thread.messageCount,
599 repo: thread.repo,
600 containsPatch: thread.containsPatch,
601 isUnread: true
602 )
603 )
604 unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
605 }
606 unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
607 hasUnreadInboxThreads = true
608 persistNeedsAttentionSnapshot()
609 }
610
418611 private func loadProjects() async -> Result<[Project], Error> {
419612 do {
420613 return .success(try await projectService.fetchProjects())
@@ -436,9 +629,9 @@ final class HomeViewModel {
436629 }
437630 }
438631
439 private func loadInboxUnreadCount() async -> Int? {
632 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
440633 do {
441 return try await fetchUnreadInboxThreadCount()
634 return try await fetchUnreadInboxSnapshot()
442635 } catch {
443636 return nil
444637 }
@@ -452,13 +645,14 @@ final class HomeViewModel {
452645 }
453646 }
454647
455 private func fetchUnreadInboxThreadCount() async throws -> Int {
648 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
456649 let mailingLists = try await fetchInboxMailingLists()
457 guard !mailingLists.isEmpty else { return 0 }
650 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
458651
459652 var startIndex = mailingLists.startIndex
460653 var unreadCount = 0
461654 var successfulFetchCount = 0
655 var unreadThreads: [InboxThreadSummary] = []
462656 while startIndex < mailingLists.endIndex {
463657 let endIndex = mailingLists.index(
464658 startIndex,
@@ -467,31 +661,32 @@ final class HomeViewModel {
467661 ) ?? mailingLists.endIndex
468662 let batch = Array(mailingLists[startIndex..<endIndex])
469663
470 let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
664 let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
471665 for mailingList in batch {
472666 group.addTask {
473667 do {
474 return .success(try await self.fetchUnreadThreadCount(for: mailingList))
668 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
475669 } catch {
476670 return .failure(error)
477671 }
478672 }
479673 }
480674
481 var counts: [Int] = []
675 var snapshots: [HomeInboxUnreadSnapshot] = []
482676 var errors: [Error] = []
483677 for await result in group {
484678 switch result {
485 case .success(let count):
486 counts.append(count)
679 case .success(let snapshot):
680 snapshots.append(snapshot)
487681 case .failure(let error):
488682 errors.append(error)
489683 }
490684 }
491 return (counts, errors)
685 return (snapshots, errors)
492686 }
493687
494 unreadCount += batchResult.0.reduce(0, +)
688 unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
689 unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
495690 successfulFetchCount += batchResult.0.count
496691
497692 startIndex = endIndex
@@ -501,7 +696,10 @@ final class HomeViewModel {
501696 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
502697 }
503698
504 return unreadCount
699 return HomeInboxUnreadSnapshot(
700 unreadCount: unreadCount,
701 threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
702 )
505703 }
506704
507705 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -532,9 +730,10 @@ final class HomeViewModel {
532730 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
533731 }
534732
535 private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
733 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
536734 var unreadCount = 0
537735 var cursor: String?
736 var unreadThreads: [InboxThreadSummary] = []
538737
539738 while true {
540739 var variables: [String: any Sendable] = ["rid": mailingList.rid]
@@ -549,15 +748,31 @@ final class HomeViewModel {
549748 responseType: HomeInboxListThreadsResponse.self
550749 )
551750
552 unreadCount += response.list.threads.results.filter { thread in
553 let normalizedSubject = thread.subject
554 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
555 .trimmingCharacters(in: .whitespacesAndNewlines)
556 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
557 .lowercased()
558 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
559 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
560 }.count
751 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
752 let summary = InboxThreadSummary(
753 rootEmailID: thread.root.id,
754 rootMessageID: thread.root.messageID,
755 threadRootEmailIDs: [thread.root.id],
756 threadRootMessageIDs: [thread.root.messageID],
757 listID: mailingList.id,
758 listRID: mailingList.rid,
759 listName: mailingList.name,
760 listOwner: mailingList.owner,
761 subject: thread.subject,
762 latestSender: thread.sender,
763 lastActivityAt: thread.updated,
764 messageCount: thread.replies + 1,
765 repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
766 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
767 isUnread: InboxReadStateStore.isUnread(
768 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
769 lastActivityAt: thread.updated
770 )
771 )
772 return summary.isUnread ? summary : nil
773 }
774 unreadCount += unreadThreadSummaries.count
775 unreadThreads.append(contentsOf: unreadThreadSummaries)
561776
562777 guard let nextCursor = response.list.threads.cursor else {
563778 break
@@ -565,14 +780,17 @@ final class HomeViewModel {
565780 cursor = nextCursor
566781 }
567782
568 return unreadCount
783 return HomeInboxUnreadSnapshot(
784 unreadCount: unreadCount,
785 threads: unreadThreads
786 )
569787 }
570788
571789 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
572790 do {
573791 let trackers = try await fetchAllTrackers()
574792 let tickets = try await fetchAssignedTickets(for: trackers)
575 .sorted { $0.ticket.created > $1.ticket.created }
793 .sorted(by: Self.sortAssignedTicketsForTriage)
576794 return .success(tickets)
577795 } catch {
578796 return .failure(error)
@@ -719,6 +937,7 @@ final class HomeViewModel {
719937 repositoryOwner: repository?.ownerCanonicalName
720938 )
721939 }
940 .sorted(by: sortBuildItemsForTriage)
722941 }
723942
724943 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
@@ -732,6 +951,36 @@ final class HomeViewModel {
732951 }
733952 }
734953
954 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
955 let lhsPriority = buildPriority(for: lhs.job.status)
956 let rhsPriority = buildPriority(for: rhs.job.status)
957 if lhsPriority != rhsPriority {
958 return lhsPriority < rhsPriority
959 }
960 if lhs.job.updated != rhs.job.updated {
961 return lhs.job.updated > rhs.job.updated
962 }
963 return lhs.job.id > rhs.job.id
964 }
965
966 nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
967 if lhs.ticket.created != rhs.ticket.created {
968 return lhs.ticket.created < rhs.ticket.created
969 }
970 return lhs.ticket.id < rhs.ticket.id
971 }
972
973 nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
974 if lhs.containsPatch != rhs.containsPatch {
975 return lhs.containsPatch && !rhs.containsPatch
976 }
977 if lhs.lastActivityAt != rhs.lastActivityAt {
978 return lhs.lastActivityAt > rhs.lastActivityAt
979 }
980 return InboxThreadSummary.normalizationKey(for: lhs.subject)
981 .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
982 }
983
735984 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
736985 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
737986 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
@@ -784,6 +1033,25 @@ final class HomeViewModel {
7841033 return trimmed
7851034 }
7861035
1036 private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1037 switch status {
1038 case .failed, .timeout:
1039 return 0
1040 case .running:
1041 return 1
1042 case .queued, .pending:
1043 return 2
1044 case .cancelled:
1045 return 3
1046 case .success:
1047 return 4
1048 }
1049 }
1050
1051 private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1052 count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1053 }
1054
7871055 private struct StatusEventResponse: Decodable, Sendable {
7881056 struct EventRef: Decodable, Sendable {
7891057 let eventType: String
Hutch/Views/Inbox/InboxView.swift +11
@@ -42,6 +42,17 @@ struct InboxView: View {
4242 @Bindable var vm = viewModel
4343
4444 List {
45 Section {
46 Picker("Filter", selection: $vm.filter) {
47 ForEach(InboxThreadFilter.allCases, id: \.self) { filter in
48 Text(filter.rawValue).tag(filter)
49 }
50 }
51 .pickerStyle(.segmented)
52 .listRowBackground(Color.clear)
53 .listRowInsets(EdgeInsets())
54 }
55
4556 ForEach(viewModel.filteredThreads) { thread in
4657 Button {
4758 selectThread(thread)
Hutch/Views/Inbox/InboxViewModel.swift +23 −2
@@ -56,12 +56,19 @@ private struct InboxEmailPreview: Decodable, Sendable {
5656 let patch: InboxPatchPreview?
5757}
5858
59enum InboxThreadFilter: String, CaseIterable, Sendable {
60 case all = "All"
61 case patches = "Patches"
62 case discussions = "Talk"
63}
64
5965@Observable
6066@MainActor
6167final class InboxViewModel {
6268 private(set) var threads: [InboxThreadSummary] = []
6369 private(set) var isLoading = false
6470 var error: String?
71 var filter: InboxThreadFilter = .all
6572 var searchText = ""
6673
6774 private let client: SRHTClient
@@ -181,9 +188,10 @@ final class InboxViewModel {
181188 }
182189
183190 var filteredThreads: [InboxThreadSummary] {
191 let filteredByKind = Self.filterThreads(threads, filter: filter)
184192 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
185 guard !q.isEmpty else { return threads }
186 return threads.filter {
193 guard !q.isEmpty else { return filteredByKind }
194 return filteredByKind.filter {
187195 $0.displaySubject.lowercased().contains(q) ||
188196 $0.listName.lowercased().contains(q) ||
189197 $0.latestSender.canonicalName.lowercased().contains(q)
@@ -389,4 +397,17 @@ final class InboxViewModel {
389397 }
390398 return nil
391399 }
400
401 nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] {
402 threads.filter { thread in
403 switch filter {
404 case .all:
405 return true
406 case .patches:
407 return thread.containsPatch
408 case .discussions:
409 return !thread.containsPatch
410 }
411 }
412 }
392413}
Hutch/Views/Inbox/ThreadDetailView.swift +51
@@ -18,6 +18,7 @@ struct ThreadDetailView: View {
1818 @State private var hasMarkedCurrentThreadViewed = false
1919 @State private var suppressAutoMarkViewed = false
2020 @State private var isUnread: Bool
21 @State private var isOpeningRepository = false
2122
2223 init(
2324 thread: InboxThreadSummary,
@@ -119,6 +120,41 @@ struct ThreadDetailView: View {
119120 .padding(.vertical, 4)
120121 }
121122
123 Section("Related") {
124 Button {
125 appState.navigateToMailingList(
126 InboxMailingListReference(
127 id: thread.listID,
128 rid: thread.listRID,
129 name: thread.listName,
130 owner: thread.listOwner
131 )
132 )
133 } label: {
134 Label(thread.listDisplayName, systemImage: "list.bullet")
135 }
136
137 if let repo = self.thread.repo {
138 Button {
139 openRepository(named: repo, ownerCanonicalName: thread.listOwner.canonicalName)
140 } label: {
141 HStack {
142 Label("\(thread.listOwner.canonicalName)/\(repo)", systemImage: "book.closed")
143 Spacer()
144 if isOpeningRepository {
145 ProgressView()
146 .controlSize(.small)
147 } else {
148 Image(systemName: "arrow.up.right")
149 .font(.caption)
150 .foregroundStyle(.tertiary)
151 }
152 }
153 }
154 .disabled(isOpeningRepository)
155 }
156 }
157
122158 if let partialWarning = viewModel.partialWarning {
123159 Section {
124160 Text(partialWarning)
@@ -180,6 +216,21 @@ struct ThreadDetailView: View {
180216 parts.append(thread.lastActivityAt.relativeDescription)
181217 return parts.joined(separator: " • ")
182218 }
219
220 private func openRepository(named repositoryName: String, ownerCanonicalName: String) {
221 guard !isOpeningRepository else { return }
222 isOpeningRepository = true
223 Task {
224 defer { isOpeningRepository = false }
225 do {
226 let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName
227 let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName)
228 appState.navigateToRepository(repository)
229 } catch {
230 appState.presentRepositoryDeepLinkError()
231 }
232 }
233 }
183234}
184235
185236private struct InboxMessageRow: View {
Hutch/Views/Tickets/TicketDetailView.swift +35
@@ -16,6 +16,7 @@ struct TicketDetailView: View {
1616 @State private var showResolveSheet = false
1717 @State private var showAssignSheet = false
1818 @State private var showLabelsSheet = false
19 @State private var isOpeningTracker = false
1920
2021 // Comment composer mode
2122 @State private var commentMode: CommentMode = .write
@@ -227,6 +228,26 @@ struct TicketDetailView: View {
227228 }
228229 .font(.caption)
229230
231 Button {
232 openTracker()
233 } label: {
234 HStack(spacing: 6) {
235 Label("\(ownerUsername)/\(trackerName)", systemImage: "checklist")
236 .font(.caption.weight(.medium))
237 if isOpeningTracker {
238 ProgressView()
239 .controlSize(.small)
240 } else {
241 Image(systemName: "arrow.up.right")
242 .font(.caption2)
243 .foregroundStyle(.tertiary)
244 }
245 }
246 .foregroundStyle(.secondary)
247 }
248 .buttonStyle(.plain)
249 .disabled(isOpeningTracker)
250
230251 if !ticket.assignees.isEmpty {
231252 HStack(spacing: 4) {
232253 Image(systemName: "person.fill")
@@ -346,6 +367,20 @@ struct TicketDetailView: View {
346367 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
347368 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
348369 }
370
371 private func openTracker() {
372 guard !isOpeningTracker else { return }
373 isOpeningTracker = true
374 Task {
375 defer { isOpeningTracker = false }
376 do {
377 let tracker = try await appState.resolveTracker(owner: ownerUsername, name: trackerName)
378 appState.navigateToTracker(tracker)
379 } catch {
380 appState.presentTicketDeepLinkError()
381 }
382 }
383 }
349384}
350385
351386// MARK: - Self-Sizing Markdown Web View
HutchTests/AppStateTests.swift +42
@@ -33,4 +33,46 @@ struct AppStateTests {
3333 #expect(appState.selectedTab == .more)
3434 #expect(appState.pendingTabNavigation == .systemStatus)
3535 }
36
37 @Test
38 @MainActor
39 func navigationHelpersQueueExpectedTargets() {
40 let appState = AppState()
41 let repository = RepositorySummary(
42 id: 1,
43 rid: "repo",
44 service: .git,
45 name: "hutch",
46 description: nil,
47 visibility: .public,
48 updated: .distantPast,
49 owner: Entity(canonicalName: "~owner"),
50 head: nil
51 )
52 let tracker = TrackerSummary(
53 id: 2,
54 rid: "tracker",
55 name: "todo",
56 description: nil,
57 visibility: .public,
58 updated: .distantPast,
59 owner: Entity(canonicalName: "~owner")
60 )
61
62 appState.navigateToRepository(repository)
63 #expect(appState.selectedTab == .repositories)
64 #expect(appState.pendingTabNavigation == .repository(repository))
65
66 appState.navigateToTracker(tracker)
67 #expect(appState.selectedTab == .tickets)
68 #expect(appState.pendingTabNavigation == .tracker(tracker))
69
70 appState.navigateToBuild(jobId: 42)
71 #expect(appState.selectedTab == .builds)
72 #expect(appState.pendingDeepLink == .build(jobId: 42))
73
74 appState.navigateToTicket(ownerUsername: "owner", trackerName: "todo", ticketId: 9)
75 #expect(appState.selectedTab == .tickets)
76 #expect(appState.pendingDeepLink == .ticket(owner: "owner", tracker: "todo", ticketId: 9))
77 }
3678}
HutchTests/BuildListViewModelTests.swift +18 −2
@@ -28,6 +28,22 @@ struct BuildListViewModelTests {
2828 #expect(filtered.map(\.id) == [42])
2929 }
3030
31 @Test
32 func buildFilterPrioritizesActionableStates() {
33 let jobs = [
34 makeJob(id: 1, status: .success, tags: []),
35 makeJob(id: 2, status: .failed, tags: []),
36 makeJob(id: 3, status: .running, tags: []),
37 makeJob(id: 4, status: .cancelled, tags: [])
38 ]
39
40 let attention = BuildListViewModel.filterJobs(jobs, filter: .attention)
41 let active = BuildListViewModel.filterJobs(jobs, filter: .active)
42
43 #expect(attention.map(\.id) == [2, 3])
44 #expect(active.map(\.id) == [3])
45 }
46
3147 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
3248 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3349 guard !q.isEmpty else { return jobs }
@@ -39,12 +55,12 @@ struct BuildListViewModelTests {
3955 }
4056 }
4157
42 private func makeJob(id: Int, tags: [String]) -> JobSummary {
58 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
4359 JobSummary(
4460 id: id,
4561 created: Date(),
4662 updated: Date(),
47 status: .success,
63 status: status,
4864 note: nil,
4965 tags: tags,
5066 visibility: nil,
HutchTests/HomeViewModelTests.swift +13
@@ -59,6 +59,19 @@ struct HomeViewModelTests {
5959 #expect(repository?.name == "hutch")
6060 }
6161
62 @Test
63 func buildItemsAreSortedForTriage() {
64 let jobs = [
65 makeJob(id: 1, status: .success, created: Date(timeIntervalSince1970: 10)),
66 makeJob(id: 2, status: .running, created: Date(timeIntervalSince1970: 20)),
67 makeJob(id: 3, status: .failed, created: Date(timeIntervalSince1970: 30))
68 ]
69
70 let sorted = HomeViewModel.buildItems(from: jobs)
71
72 #expect(sorted.map(\.job.id) == [3, 2, 1])
73 }
74
6275 private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload {
6376 HomeJobPayload(
6477 id: id,
HutchTests/InboxViewModelTests.swift +49
@@ -165,6 +165,55 @@ struct InboxViewModelTests {
165165 #expect(senderMatches.map(\.rootEmailID) == [11])
166166 }
167167
168 @Test
169 func inboxFilterSeparatesPatchThreadsFromDiscussionThreads() {
170 let baseList = InboxMailingListReference(
171 id: 1,
172 rid: "list",
173 name: "hutch-devel",
174 owner: Entity(canonicalName: "~owner")
175 )
176 let patchThread = InboxThreadSummary(
177 rootEmailID: 10,
178 rootMessageID: "message-1",
179 threadRootEmailIDs: [10],
180 threadRootMessageIDs: ["message-1"],
181 listID: baseList.id,
182 listRID: baseList.rid,
183 listName: baseList.name,
184 listOwner: baseList.owner,
185 subject: "[PATCH] add search",
186 latestSender: Entity(canonicalName: "~alice"),
187 lastActivityAt: Date(timeIntervalSince1970: 100),
188 messageCount: 1,
189 repo: "hutch",
190 containsPatch: true,
191 isUnread: true
192 )
193 let discussionThread = InboxThreadSummary(
194 rootEmailID: 11,
195 rootMessageID: "message-2",
196 threadRootEmailIDs: [11],
197 threadRootMessageIDs: ["message-2"],
198 listID: baseList.id,
199 listRID: baseList.rid,
200 listName: baseList.name,
201 listOwner: baseList.owner,
202 subject: "release planning",
203 latestSender: Entity(canonicalName: "~bob"),
204 lastActivityAt: Date(timeIntervalSince1970: 200),
205 messageCount: 2,
206 repo: "hutch",
207 containsPatch: false,
208 isUnread: true
209 )
210
211 let threads = [patchThread, discussionThread]
212
213 #expect(InboxViewModel.filterThreads(threads, filter: .patches).map(\.rootEmailID) == [10])
214 #expect(InboxViewModel.filterThreads(threads, filter: .discussions).map(\.rootEmailID) == [11])
215 }
216
168217 @Test
169218 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
170219 let body = """