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 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 30; 518 CURRENT_PROJECT_VERSION = 31;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.13.1; 535 MARKETING_VERSION = 2.14.0;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 30; 555 CURRENT_PROJECT_VERSION = 31;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.13.1; 572 MARKETING_VERSION = 2.14.0;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 30; 635 CURRENT_PROJECT_VERSION = 31;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.13.1; 645 MARKETING_VERSION = 2.14.0;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 30; 664 CURRENT_PROJECT_VERSION = 31;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.13.1; 674 MARKETING_VERSION = 2.14.0;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +31 −5
@@ -261,22 +261,48 @@ final class AppState {
261 261
262 func openProjectSource(_ source: Project.SourceRepo) async throws { 262 func openProjectSource(_ source: Project.SourceRepo) async throws {
263 let repository = try await resolveProjectSource(source) 263 let repository = try await resolveProjectSource(source)
264 pendingTabNavigation = .repository(repository) 264 navigateToRepository(repository)
265 selectedTab = .repositories
266 } 265 }
267 266
268 func openProjectTracker(_ tracker: Project.Tracker) async throws { 267 func openProjectTracker(_ tracker: Project.Tracker) async throws {
269 let resolvedTracker = try await resolveProjectTracker(tracker) 268 let resolvedTracker = try await resolveProjectTracker(tracker)
270 pendingTabNavigation = .tracker(resolvedTracker) 269 navigateToTracker(resolvedTracker)
271 selectedTab = .tickets
272 } 270 }
273 271
274 func openMailingList(_ mailingList: InboxMailingListReference) { 272 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) {
275 pendingTabNavigation = .mailingList(mailingList) 301 pendingTabNavigation = .mailingList(mailingList)
276 selectedTab = .more 302 selectedTab = .more
277 } 303 }
278 304
279 func openSystemStatus() { 305 func navigateToSystemStatus() {
280 pendingTabNavigation = .systemStatus 306 pendingTabNavigation = .systemStatus
281 selectedTab = .more 307 selectedTab = .more
282 } 308 }
Hutch/Models/Inbox.swift +6 −2
@@ -52,10 +52,14 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
52 } 52 }
53 53
54 var threadGroupingKey: String { 54 var threadGroupingKey: String {
55 "\(listRID)#\(displaySubject.lowercased())" 55 "\(listRID)#\(Self.normalizationKey(for: subject))"
56 } 56 }
57 57
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 {
59 let collapsedWhitespace = subject 63 let collapsedWhitespace = subject
60 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) 64 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
61 .trimmingCharacters(in: .whitespacesAndNewlines) 65 .trimmingCharacters(in: .whitespacesAndNewlines)
Hutch/Views/Builds/BuildDetailView.swift +38
@@ -9,6 +9,7 @@ struct BuildDetailView: View {
9 @State private var selectedTaskName: String? 9 @State private var selectedTaskName: String?
10 @State private var showEditResubmitSheet = false 10 @State private var showEditResubmitSheet = false
11 @State private var showCancelConfirmation = false 11 @State private var showCancelConfirmation = false
12 @State private var isOpeningRepository = false
12 13
13 private var isPresentingLogSheet: Bool { 14 private var isPresentingLogSheet: Bool {
14 selectedTaskName != nil 15 selectedTaskName != nil
@@ -158,6 +159,28 @@ struct BuildDetailView: View {
158 LabeledContent("Updated", value: job.updated.relativeDescription) 159 LabeledContent("Updated", value: job.updated.relativeDescription)
159 } 160 }
160 161
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
161 // Per-task logs 184 // Per-task logs
162 if !job.tasks.isEmpty { 185 if !job.tasks.isEmpty {
163 ForEach(job.tasks) { task in 186 ForEach(job.tasks) { task in
@@ -241,6 +264,21 @@ struct BuildDetailView: View {
241 )) 264 ))
242 } 265 }
243 } 266 }
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 }
244} 282}
245 283
246private struct EditResubmitBuildSheet: View { 284private struct EditResubmitBuildSheet: View {
Hutch/Views/Builds/BuildListView.swift +11
@@ -65,6 +65,17 @@ struct BuildListView: View {
65 @Bindable var vm = viewModel 65 @Bindable var vm = viewModel
66 66
67 List { 67 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
68 ForEach(viewModel.filteredJobs) { job in 79 ForEach(viewModel.filteredJobs) { job in
69 NavigationLink(value: job) { 80 NavigationLink(value: job) {
70 BuildRowView(job: job) 81 BuildRowView(job: job)
Hutch/Views/Builds/BuildListViewModel.swift +33 −2
@@ -19,6 +19,12 @@ private struct SubmittedJob: Decodable, Sendable {
19 let id: Int 19 let id: Int
20} 20}
21 21
22enum BuildListFilter: String, CaseIterable, Sendable {
23 case attention = "Attention"
24 case active = "Active"
25 case all = "All"
26}
27
22// MARK: - View Model 28// MARK: - View Model
23 29
24@Observable 30@Observable
@@ -31,6 +37,7 @@ final class BuildListViewModel {
31 private(set) var isRefreshing = false 37 private(set) var isRefreshing = false
32 private(set) var isSubmitting = false 38 private(set) var isSubmitting = false
33 var error: String? 39 var error: String?
40 var filter: BuildListFilter = .attention
34 var searchText = "" 41 var searchText = ""
35 42
36 private var cursor: String? 43 private var cursor: String?
@@ -44,9 +51,10 @@ final class BuildListViewModel {
44 } 51 }
45 52
46 var filteredJobs: [JobSummary] { 53 var filteredJobs: [JobSummary] {
54 let statusFiltered = Self.filterJobs(jobs, filter: filter)
47 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 55 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
48 guard !q.isEmpty else { return jobs } 56 guard !q.isEmpty else { return statusFiltered }
49 return jobs.filter { 57 return statusFiltered.filter {
50 String($0.id).contains(q) || 58 String($0.id).contains(q) ||
51 $0.tags.contains { $0.lowercased().contains(q) } || 59 $0.tags.contains { $0.lowercased().contains(q) } ||
52 ($0.note?.lowercased().contains(q) == true) || 60 ($0.note?.lowercased().contains(q) == true) ||
@@ -272,4 +280,27 @@ final class BuildListViewModel {
272 280
273 let cancel: CancelResult 281 let cancel: CancelResult
274 } 282 }
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 }
275} 306}
Hutch/Views/Home/HomeView.swift +332 −46
@@ -55,7 +55,9 @@ struct HomeView: View {
55 @ViewBuilder 55 @ViewBuilder
56 private func content(_ viewModel: HomeViewModel) -> some View { 56 private func content(_ viewModel: HomeViewModel) -> some View {
57 List { 57 List {
58 attentionSection(viewModel)
58 systemStatusBannerSection(viewModel) 59 systemStatusBannerSection(viewModel)
60 inboxSection(viewModel)
59 projectsSection(viewModel) 61 projectsSection(viewModel)
60 assignedTicketsSection(viewModel) 62 assignedTicketsSection(viewModel)
61 recentBuildsSection(viewModel) 63 recentBuildsSection(viewModel)
@@ -63,15 +65,17 @@ struct HomeView: View {
63 .listStyle(.insetGrouped) 65 .listStyle(.insetGrouped)
64 .overlay { 66 .overlay {
65 if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && 67 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 {
67 SRHTLoadingStateView(message: "Loading Home…") 70 SRHTLoadingStateView(message: "Loading Home…")
68 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && 71 } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
69 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && 72 viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
73 viewModel.unreadInboxThreads.isEmpty &&
70 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { 74 viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
71 ContentUnavailableView( 75 ContentUnavailableView(
72 "All Clear", 76 "All Clear",
73 systemImage: "checkmark.circle", 77 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.")
75 ) 79 )
76 } 80 }
77 } 81 }
@@ -83,6 +87,41 @@ struct HomeView: View {
83 } 87 }
84 } 88 }
85 89
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
86 @ViewBuilder 125 @ViewBuilder
87 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { 126 private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View {
88 Section { 127 Section {
@@ -100,6 +139,40 @@ struct HomeView: View {
100 } 139 }
101 } 140 }
102 141
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
103 @ViewBuilder 176 @ViewBuilder
104 private func projectsSection(_ viewModel: HomeViewModel) -> some View { 177 private func projectsSection(_ viewModel: HomeViewModel) -> some View {
105 if !viewModel.projects.isEmpty { 178 if !viewModel.projects.isEmpty {
@@ -312,41 +385,55 @@ private struct HomeProjectsListView: View {
312} 385}
313 386
314private struct HomeBuildRow: View { 387private struct HomeBuildRow: View {
388 @Environment(AppState.self) private var appState
315 let build: HomeBuildItem 389 let build: HomeBuildItem
316 390
317 var body: some View { 391 var body: some View {
318 HStack(spacing: 12) { 392 VStack(alignment: .leading, spacing: 6) {
319 JobStatusIcon(status: build.job.status) 393 HStack(spacing: 12) {
320 .frame(width: 20) 394 JobStatusIcon(status: build.job.status)
321 395 .frame(width: 20)
322 VStack(alignment: .leading, spacing: 4) { 396
323 Text(primaryTitle) 397 VStack(alignment: .leading, spacing: 4) {
324 .font(.subheadline.weight(.medium)) 398 Text(primaryTitle)
325 .lineLimit(1) 399 .font(.subheadline.weight(.medium))
326 400 .lineLimit(1)
327 HStack(spacing: 8) { 401
328 Text("Job #\(build.job.id)") 402 HStack(spacing: 8) {
329 .font(.caption) 403 Text("Job #\(build.job.id)")
330 .foregroundStyle(.secondary) 404 .font(.caption)
331 405 .foregroundStyle(.secondary)
332 Text("•") 406
333 .font(.caption) 407 Text("•")
334 .foregroundStyle(.tertiary) 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 }
335 427
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")
337 .font(.caption) 433 .font(.caption)
338 .foregroundStyle(.secondary) 434 .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()
349 } 435 }
436 .buttonStyle(.plain)
350 } 437 }
351 } 438 }
352 .padding(.vertical, 2) 439 .padding(.vertical, 2)
@@ -358,38 +445,148 @@ private struct HomeBuildRow: View {
358 } 445 }
359 return build.job.displayLabel 446 return build.job.displayLabel
360 } 447 }
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 }
361} 464}
362 465
363private struct HomeAssignedTicketRow: View { 466private struct HomeAssignedTicketRow: View {
467 @Environment(AppState.self) private var appState
364 let ticket: HomeAssignedTicket 468 let ticket: HomeAssignedTicket
365 469
366 var body: some View { 470 var body: some View {
367 HStack(alignment: .top, spacing: 12) { 471 VStack(alignment: .leading, spacing: 6) {
368 TicketStatusIcon(status: ticket.ticket.status) 472 HStack(alignment: .top, spacing: 12) {
369 .frame(width: 20) 473 TicketStatusIcon(status: ticket.ticket.status)
474 .frame(width: 20)
370 475
371 VStack(alignment: .leading, spacing: 4) { 476 VStack(alignment: .leading, spacing: 4) {
372 Text(ticket.ticket.title) 477 Text(ticket.ticket.title)
373 .font(.subheadline.weight(.medium)) 478 .font(.subheadline.weight(.medium))
374 .lineLimit(2) 479 .lineLimit(2)
375 480
376 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") 481 Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)")
377 .font(.caption) 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))
378 .foregroundStyle(.secondary) 492 .foregroundStyle(.secondary)
379 .lineLimit(1) 493 .lineLimit(1)
380 .truncationMode(.tail) 494 .fixedSize()
381 } 495 }
382 496
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 }
384 508
385 Text(ticket.ticket.status.displayName) 509 private func openTracker() {
386 .font(.caption2.weight(.medium)) 510 Task {
387 .foregroundStyle(.secondary) 511 do {
388 .lineLimit(1) 512 let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName)
389 .fixedSize() 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 }
390 } 573 }
391 .padding(.vertical, 2) 574 .padding(.vertical, 2)
392 } 575 }
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 }
393} 590}
394 591
395private struct HomeSectionLoadingRow: View { 592private struct HomeSectionLoadingRow: View {
@@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View {
431 } 628 }
432} 629}
433 630
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
434private struct HomeSectionActionHeader: View { 720private struct HomeSectionActionHeader: View {
435 let title: String 721 let title: String
436 let action: () -> Void 722 let action: () -> Void
Hutch/Views/Home/HomeViewModel.swift +295 −27
@@ -63,8 +63,31 @@ private struct HomeInboxThreadPage: Decodable, Sendable {
63} 63}
64 64
65private struct HomeInboxThreadPayload: Decodable, Sendable { 65private struct HomeInboxThreadPayload: Decodable, Sendable {
66 let created: Date
66 let updated: Date 67 let updated: Date
67 let subject: String 68 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]
68} 91}
69 92
70private struct HomeTicketPayload: Decodable, Sendable { 93private struct HomeTicketPayload: Decodable, Sendable {
@@ -135,6 +158,15 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
135 } 158 }
136 return repositoryName 159 return repositoryName
137 } 160 }
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 }
138} 170}
139 171
140@Observable 172@Observable
@@ -143,6 +175,7 @@ final class HomeViewModel {
143 private(set) var projects: [Project] = [] 175 private(set) var projects: [Project] = []
144 var assignedTickets: [HomeAssignedTicket] = [] 176 var assignedTickets: [HomeAssignedTicket] = []
145 var recentBuilds: [HomeBuildItem] = [] 177 var recentBuilds: [HomeBuildItem] = []
178 var unreadInboxThreads: [InboxThreadSummary] = []
146 private(set) var systemStatusSnapshot: SystemStatusSnapshot? 179 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
147 private(set) var isLoadingSystemStatus = false 180 private(set) var isLoadingSystemStatus = false
148 private(set) var isShowingStaleSystemStatus = false 181 private(set) var isShowingStaleSystemStatus = false
@@ -242,8 +275,20 @@ final class HomeViewModel {
242 list(rid: $rid) { 275 list(rid: $rid) {
243 threads(cursor: $cursor) { 276 threads(cursor: $cursor) {
244 results { 277 results {
278 created
245 updated 279 updated
246 subject 280 subject
281 replies
282 sender { canonicalName }
283 root {
284 id
285 subject
286 date
287 received
288 messageID
289 body
290 patch { subject }
291 }
247 } 292 }
248 cursor 293 cursor
249 } 294 }
@@ -291,7 +336,7 @@ final class HomeViewModel {
291 async let projectsTask = loadProjects() 336 async let projectsTask = loadProjects()
292 async let jobsTask = loadRecentJobs() 337 async let jobsTask = loadRecentJobs()
293 async let assignedTicketsTask = loadAssignedTickets() 338 async let assignedTicketsTask = loadAssignedTickets()
294 async let inboxUnreadTask = loadInboxUnreadCount() 339 async let inboxUnreadTask = loadInboxUnreadSnapshot()
295 async let systemStatusTask = loadSystemStatusSnapshot() 340 async let systemStatusTask = loadSystemStatusSnapshot()
296 341
297 let projectsResult = await projectsTask 342 let projectsResult = await projectsTask
@@ -328,7 +373,9 @@ final class HomeViewModel {
328 } 373 }
329 isLoadingAssignedTickets = false 374 isLoadingAssignedTickets = false
330 375
331 unreadInboxThreadCount = await inboxUnreadTask 376 let inboxUnreadSnapshot = await inboxUnreadTask
377 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
378 unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
332 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 379 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
333 let systemStatusResult = await systemStatusTask 380 let systemStatusResult = await systemStatusTask
334 switch systemStatusResult { 381 switch systemStatusResult {
@@ -344,7 +391,116 @@ final class HomeViewModel {
344 } 391 }
345 392
346 var hasDashboardContent: Bool { 393 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
348 } 504 }
349 505
350 func resolveTicket(_ ticket: HomeAssignedTicket) async { 506 func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -415,6 +571,43 @@ final class HomeViewModel {
415 } 571 }
416 } 572 }
417 573
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
418 private func loadProjects() async -> Result<[Project], Error> { 611 private func loadProjects() async -> Result<[Project], Error> {
419 do { 612 do {
420 return .success(try await projectService.fetchProjects()) 613 return .success(try await projectService.fetchProjects())
@@ -436,9 +629,9 @@ final class HomeViewModel {
436 } 629 }
437 } 630 }
438 631
439 private func loadInboxUnreadCount() async -> Int? { 632 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
440 do { 633 do {
441 return try await fetchUnreadInboxThreadCount() 634 return try await fetchUnreadInboxSnapshot()
442 } catch { 635 } catch {
443 return nil 636 return nil
444 } 637 }
@@ -452,13 +645,14 @@ final class HomeViewModel {
452 } 645 }
453 } 646 }
454 647
455 private func fetchUnreadInboxThreadCount() async throws -> Int { 648 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
456 let mailingLists = try await fetchInboxMailingLists() 649 let mailingLists = try await fetchInboxMailingLists()
457 guard !mailingLists.isEmpty else { return 0 } 650 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
458 651
459 var startIndex = mailingLists.startIndex 652 var startIndex = mailingLists.startIndex
460 var unreadCount = 0 653 var unreadCount = 0
461 var successfulFetchCount = 0 654 var successfulFetchCount = 0
655 var unreadThreads: [InboxThreadSummary] = []
462 while startIndex < mailingLists.endIndex { 656 while startIndex < mailingLists.endIndex {
463 let endIndex = mailingLists.index( 657 let endIndex = mailingLists.index(
464 startIndex, 658 startIndex,
@@ -467,31 +661,32 @@ final class HomeViewModel {
467 ) ?? mailingLists.endIndex 661 ) ?? mailingLists.endIndex
468 let batch = Array(mailingLists[startIndex..<endIndex]) 662 let batch = Array(mailingLists[startIndex..<endIndex])
469 663
470 let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in 664 let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
471 for mailingList in batch { 665 for mailingList in batch {
472 group.addTask { 666 group.addTask {
473 do { 667 do {
474 return .success(try await self.fetchUnreadThreadCount(for: mailingList)) 668 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
475 } catch { 669 } catch {
476 return .failure(error) 670 return .failure(error)
477 } 671 }
478 } 672 }
479 } 673 }
480 674
481 var counts: [Int] = [] 675 var snapshots: [HomeInboxUnreadSnapshot] = []
482 var errors: [Error] = [] 676 var errors: [Error] = []
483 for await result in group { 677 for await result in group {
484 switch result { 678 switch result {
485 case .success(let count): 679 case .success(let snapshot):
486 counts.append(count) 680 snapshots.append(snapshot)
487 case .failure(let error): 681 case .failure(let error):
488 errors.append(error) 682 errors.append(error)
489 } 683 }
490 } 684 }
491 return (counts, errors) 685 return (snapshots, errors)
492 } 686 }
493 687
494 unreadCount += batchResult.0.reduce(0, +) 688 unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
689 unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
495 successfulFetchCount += batchResult.0.count 690 successfulFetchCount += batchResult.0.count
496 691
497 startIndex = endIndex 692 startIndex = endIndex
@@ -501,7 +696,10 @@ final class HomeViewModel {
501 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) 696 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
502 } 697 }
503 698
504 return unreadCount 699 return HomeInboxUnreadSnapshot(
700 unreadCount: unreadCount,
701 threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
702 )
505 } 703 }
506 704
507 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { 705 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -532,9 +730,10 @@ final class HomeViewModel {
532 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } 730 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
533 } 731 }
534 732
535 private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { 733 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
536 var unreadCount = 0 734 var unreadCount = 0
537 var cursor: String? 735 var cursor: String?
736 var unreadThreads: [InboxThreadSummary] = []
538 737
539 while true { 738 while true {
540 var variables: [String: any Sendable] = ["rid": mailingList.rid] 739 var variables: [String: any Sendable] = ["rid": mailingList.rid]
@@ -549,15 +748,31 @@ final class HomeViewModel {
549 responseType: HomeInboxListThreadsResponse.self 748 responseType: HomeInboxListThreadsResponse.self
550 ) 749 )
551 750
552 unreadCount += response.list.threads.results.filter { thread in 751 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
553 let normalizedSubject = thread.subject 752 let summary = InboxThreadSummary(
554 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) 753 rootEmailID: thread.root.id,
555 .trimmingCharacters(in: .whitespacesAndNewlines) 754 rootMessageID: thread.root.messageID,
556 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) 755 threadRootEmailIDs: [thread.root.id],
557 .lowercased() 756 threadRootMessageIDs: [thread.root.messageID],
558 let threadID = "\(mailingList.rid)#\(normalizedSubject)" 757 listID: mailingList.id,
559 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) 758 listRID: mailingList.rid,
560 }.count 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)
561 776
562 guard let nextCursor = response.list.threads.cursor else { 777 guard let nextCursor = response.list.threads.cursor else {
563 break 778 break
@@ -565,14 +780,17 @@ final class HomeViewModel {
565 cursor = nextCursor 780 cursor = nextCursor
566 } 781 }
567 782
568 return unreadCount 783 return HomeInboxUnreadSnapshot(
784 unreadCount: unreadCount,
785 threads: unreadThreads
786 )
569 } 787 }
570 788
571 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { 789 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
572 do { 790 do {
573 let trackers = try await fetchAllTrackers() 791 let trackers = try await fetchAllTrackers()
574 let tickets = try await fetchAssignedTickets(for: trackers) 792 let tickets = try await fetchAssignedTickets(for: trackers)
575 .sorted { $0.ticket.created > $1.ticket.created } 793 .sorted(by: Self.sortAssignedTicketsForTriage)
576 return .success(tickets) 794 return .success(tickets)
577 } catch { 795 } catch {
578 return .failure(error) 796 return .failure(error)
@@ -719,6 +937,7 @@ final class HomeViewModel {
719 repositoryOwner: repository?.ownerCanonicalName 937 repositoryOwner: repository?.ownerCanonicalName
720 ) 938 )
721 } 939 }
940 .sorted(by: sortBuildItemsForTriage)
722 } 941 }
723 942
724 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { 943 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
@@ -732,6 +951,36 @@ final class HomeViewModel {
732 } 951 }
733 } 952 }
734 953
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
735 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { 984 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
736 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) 985 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
737 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) 986 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
@@ -784,6 +1033,25 @@ final class HomeViewModel {
784 return trimmed 1033 return trimmed
785 } 1034 }
786 1035
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
787 private struct StatusEventResponse: Decodable, Sendable { 1055 private struct StatusEventResponse: Decodable, Sendable {
788 struct EventRef: Decodable, Sendable { 1056 struct EventRef: Decodable, Sendable {
789 let eventType: String 1057 let eventType: String
Hutch/Views/Inbox/InboxView.swift +11
@@ -42,6 +42,17 @@ struct InboxView: View {
42 @Bindable var vm = viewModel 42 @Bindable var vm = viewModel
43 43
44 List { 44 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
45 ForEach(viewModel.filteredThreads) { thread in 56 ForEach(viewModel.filteredThreads) { thread in
46 Button { 57 Button {
47 selectThread(thread) 58 selectThread(thread)
Hutch/Views/Inbox/InboxViewModel.swift +23 −2
@@ -56,12 +56,19 @@ private struct InboxEmailPreview: Decodable, Sendable {
56 let patch: InboxPatchPreview? 56 let patch: InboxPatchPreview?
57} 57}
58 58
59enum InboxThreadFilter: String, CaseIterable, Sendable {
60 case all = "All"
61 case patches = "Patches"
62 case discussions = "Talk"
63}
64
59@Observable 65@Observable
60@MainActor 66@MainActor
61final class InboxViewModel { 67final class InboxViewModel {
62 private(set) var threads: [InboxThreadSummary] = [] 68 private(set) var threads: [InboxThreadSummary] = []
63 private(set) var isLoading = false 69 private(set) var isLoading = false
64 var error: String? 70 var error: String?
71 var filter: InboxThreadFilter = .all
65 var searchText = "" 72 var searchText = ""
66 73
67 private let client: SRHTClient 74 private let client: SRHTClient
@@ -181,9 +188,10 @@ final class InboxViewModel {
181 } 188 }
182 189
183 var filteredThreads: [InboxThreadSummary] { 190 var filteredThreads: [InboxThreadSummary] {
191 let filteredByKind = Self.filterThreads(threads, filter: filter)
184 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 192 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
185 guard !q.isEmpty else { return threads } 193 guard !q.isEmpty else { return filteredByKind }
186 return threads.filter { 194 return filteredByKind.filter {
187 $0.displaySubject.lowercased().contains(q) || 195 $0.displaySubject.lowercased().contains(q) ||
188 $0.listName.lowercased().contains(q) || 196 $0.listName.lowercased().contains(q) ||
189 $0.latestSender.canonicalName.lowercased().contains(q) 197 $0.latestSender.canonicalName.lowercased().contains(q)
@@ -389,4 +397,17 @@ final class InboxViewModel {
389 } 397 }
390 return nil 398 return nil
391 } 399 }
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 }
392} 413}
Hutch/Views/Inbox/ThreadDetailView.swift +51
@@ -18,6 +18,7 @@ struct ThreadDetailView: View {
18 @State private var hasMarkedCurrentThreadViewed = false 18 @State private var hasMarkedCurrentThreadViewed = false
19 @State private var suppressAutoMarkViewed = false 19 @State private var suppressAutoMarkViewed = false
20 @State private var isUnread: Bool 20 @State private var isUnread: Bool
21 @State private var isOpeningRepository = false
21 22
22 init( 23 init(
23 thread: InboxThreadSummary, 24 thread: InboxThreadSummary,
@@ -119,6 +120,41 @@ struct ThreadDetailView: View {
119 .padding(.vertical, 4) 120 .padding(.vertical, 4)
120 } 121 }
121 122
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
122 if let partialWarning = viewModel.partialWarning { 158 if let partialWarning = viewModel.partialWarning {
123 Section { 159 Section {
124 Text(partialWarning) 160 Text(partialWarning)
@@ -180,6 +216,21 @@ struct ThreadDetailView: View {
180 parts.append(thread.lastActivityAt.relativeDescription) 216 parts.append(thread.lastActivityAt.relativeDescription)
181 return parts.joined(separator: " • ") 217 return parts.joined(separator: " • ")
182 } 218 }
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 }
183} 234}
184 235
185private struct InboxMessageRow: View { 236private struct InboxMessageRow: View {
Hutch/Views/Tickets/TicketDetailView.swift +35
@@ -16,6 +16,7 @@ struct TicketDetailView: View {
16 @State private var showResolveSheet = false 16 @State private var showResolveSheet = false
17 @State private var showAssignSheet = false 17 @State private var showAssignSheet = false
18 @State private var showLabelsSheet = false 18 @State private var showLabelsSheet = false
19 @State private var isOpeningTracker = false
19 20
20 // Comment composer mode 21 // Comment composer mode
21 @State private var commentMode: CommentMode = .write 22 @State private var commentMode: CommentMode = .write
@@ -227,6 +228,26 @@ struct TicketDetailView: View {
227 } 228 }
228 .font(.caption) 229 .font(.caption)
229 230
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
230 if !ticket.assignees.isEmpty { 251 if !ticket.assignees.isEmpty {
231 HStack(spacing: 4) { 252 HStack(spacing: 4) {
232 Image(systemName: "person.fill") 253 Image(systemName: "person.fill")
@@ -346,6 +367,20 @@ struct TicketDetailView: View {
346 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) 367 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
347 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed 368 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
348 } 369 }
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 }
349} 384}
350 385
351// MARK: - Self-Sizing Markdown Web View 386// MARK: - Self-Sizing Markdown Web View
HutchTests/AppStateTests.swift +42
@@ -33,4 +33,46 @@ struct AppStateTests {
33 #expect(appState.selectedTab == .more) 33 #expect(appState.selectedTab == .more)
34 #expect(appState.pendingTabNavigation == .systemStatus) 34 #expect(appState.pendingTabNavigation == .systemStatus)
35 } 35 }
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 }
36} 78}
HutchTests/BuildListViewModelTests.swift +18 −2
@@ -28,6 +28,22 @@ struct BuildListViewModelTests {
28 #expect(filtered.map(\.id) == [42]) 28 #expect(filtered.map(\.id) == [42])
29 } 29 }
30 30
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
31 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { 47 private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] {
32 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 48 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
33 guard !q.isEmpty else { return jobs } 49 guard !q.isEmpty else { return jobs }
@@ -39,12 +55,12 @@ struct BuildListViewModelTests {
39 } 55 }
40 } 56 }
41 57
42 private func makeJob(id: Int, tags: [String]) -> JobSummary { 58 private func makeJob(id: Int, status: JobStatus = .success, tags: [String]) -> JobSummary {
43 JobSummary( 59 JobSummary(
44 id: id, 60 id: id,
45 created: Date(), 61 created: Date(),
46 updated: Date(), 62 updated: Date(),
47 status: .success, 63 status: status,
48 note: nil, 64 note: nil,
49 tags: tags, 65 tags: tags,
50 visibility: nil, 66 visibility: nil,
HutchTests/HomeViewModelTests.swift +13
@@ -59,6 +59,19 @@ struct HomeViewModelTests {
59 #expect(repository?.name == "hutch") 59 #expect(repository?.name == "hutch")
60 } 60 }
61 61
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
62 private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload { 75 private func makeJob(id: Int, status: JobStatus, created: Date) -> HomeJobPayload {
63 HomeJobPayload( 76 HomeJobPayload(
64 id: id, 77 id: id,
HutchTests/InboxViewModelTests.swift +49
@@ -165,6 +165,55 @@ struct InboxViewModelTests {
165 #expect(senderMatches.map(\.rootEmailID) == [11]) 165 #expect(senderMatches.map(\.rootEmailID) == [11])
166 } 166 }
167 167
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
168 @Test 217 @Test
169 func segmentsPatchBodyAndTreatsSignatureAsPlainText() { 218 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
170 let body = """ 219 let body = """