diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 00:34:54 -0500 |
| commit | 5f6d545eb3455a9e4da5a3cb4460811e3a48d939 (patch) | |
| tree | 7b7591146da4e02bd617ddb9f26e47b1c4474ef6 /Hutch | |
| parent | cb1ea5ea4f163d87053285d3fb7999b12a3558b9 (diff) | |
| download | hutch-2.14.0.tar.gz hutch-2.14.0.tar.bz2 hutch-2.14.0.zip | |
improve home attention flows and cross-linkingv2.14.0
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppState.swift | 36 | ||||
| -rw-r--r-- | Hutch/Models/Inbox.swift | 8 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 38 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 35 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 378 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 322 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxView.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxViewModel.swift | 25 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadDetailView.swift | 51 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 35 |
11 files changed, 866 insertions, 84 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 95d9fa9..c2e2744 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -261,22 +261,48 @@ final class AppState { func openProjectSource(_ source: Project.SourceRepo) async throws { let repository = try await resolveProjectSource(source) - pendingTabNavigation = .repository(repository) - selectedTab = .repositories + navigateToRepository(repository) } func openProjectTracker(_ tracker: Project.Tracker) async throws { let resolvedTracker = try await resolveProjectTracker(tracker) - pendingTabNavigation = .tracker(resolvedTracker) - selectedTab = .tickets + navigateToTracker(resolvedTracker) } func openMailingList(_ mailingList: InboxMailingListReference) { + navigateToMailingList(mailingList) + } + + func openSystemStatus() { + navigateToSystemStatus() + } + + func navigateToRepository(_ repository: RepositorySummary) { + pendingTabNavigation = .repository(repository) + selectedTab = .repositories + } + + func navigateToTracker(_ tracker: TrackerSummary) { + pendingTabNavigation = .tracker(tracker) + selectedTab = .tickets + } + + func navigateToBuild(jobId: Int) { + pendingDeepLink = .build(jobId: jobId) + selectedTab = .builds + } + + func navigateToTicket(ownerUsername: String, trackerName: String, ticketId: Int) { + pendingDeepLink = .ticket(owner: ownerUsername, tracker: trackerName, ticketId: ticketId) + selectedTab = .tickets + } + + func navigateToMailingList(_ mailingList: InboxMailingListReference) { pendingTabNavigation = .mailingList(mailingList) selectedTab = .more } - func openSystemStatus() { + func navigateToSystemStatus() { pendingTabNavigation = .systemStatus selectedTab = .more } diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index c89cb12..8ab2480 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -52,10 +52,14 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable { } var threadGroupingKey: String { - "\(listRID)#\(displaySubject.lowercased())" + "\(listRID)#\(Self.normalizationKey(for: subject))" } - private static func normalizedSubject(from subject: String) -> String { + nonisolated static func normalizationKey(for subject: String) -> String { + normalizedSubject(from: subject).lowercased() + } + + private nonisolated static func normalizedSubject(from subject: String) -> String { let collapsedWhitespace = subject .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift index 1c2e2bb..33590e2 100644 --- a/Hutch/Views/Builds/BuildDetailView.swift +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -9,6 +9,7 @@ struct BuildDetailView: View { @State private var selectedTaskName: String? @State private var showEditResubmitSheet = false @State private var showCancelConfirmation = false + @State private var isOpeningRepository = false private var isPresentingLogSheet: Bool { selectedTaskName != nil @@ -158,6 +159,28 @@ struct BuildDetailView: View { LabeledContent("Updated", value: job.updated.relativeDescription) } + if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) { + Section("Source") { + Button { + openRepository(ownerCanonicalName: repositoryReference.ownerCanonicalName, repositoryName: repositoryReference.name) + } label: { + HStack { + Label("\(repositoryReference.ownerCanonicalName)/\(repositoryReference.name)", systemImage: "book.closed") + Spacer() + if isOpeningRepository { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + .disabled(isOpeningRepository) + } + } + // Per-task logs if !job.tasks.isEmpty { ForEach(job.tasks) { task in @@ -241,6 +264,21 @@ struct BuildDetailView: View { )) } } + + private func openRepository(ownerCanonicalName: String, repositoryName: String) { + guard !isOpeningRepository else { return } + isOpeningRepository = true + Task { + defer { isOpeningRepository = false } + do { + let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName + let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct EditResubmitBuildSheet: View { diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index 6bfbd6b..705f97c 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -65,6 +65,17 @@ struct BuildListView: View { @Bindable var vm = viewModel List { + Section { + Picker("Filter", selection: $vm.filter) { + ForEach(BuildListFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + ForEach(viewModel.filteredJobs) { job in NavigationLink(value: job) { BuildRowView(job: job) diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index 0757ad8..8468cff 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -19,6 +19,12 @@ private struct SubmittedJob: Decodable, Sendable { let id: Int } +enum BuildListFilter: String, CaseIterable, Sendable { + case attention = "Attention" + case active = "Active" + case all = "All" +} + // MARK: - View Model @Observable @@ -31,6 +37,7 @@ final class BuildListViewModel { private(set) var isRefreshing = false private(set) var isSubmitting = false var error: String? + var filter: BuildListFilter = .attention var searchText = "" private var cursor: String? @@ -44,9 +51,10 @@ final class BuildListViewModel { } var filteredJobs: [JobSummary] { + let statusFiltered = Self.filterJobs(jobs, filter: filter) let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return jobs } - return jobs.filter { + guard !q.isEmpty else { return statusFiltered } + return statusFiltered.filter { String($0.id).contains(q) || $0.tags.contains { $0.lowercased().contains(q) } || ($0.note?.lowercased().contains(q) == true) || @@ -272,4 +280,27 @@ final class BuildListViewModel { let cancel: CancelResult } + + nonisolated static func filterJobs(_ jobs: [JobSummary], filter: BuildListFilter) -> [JobSummary] { + jobs.filter { job in + switch filter { + case .attention: + switch job.status { + case .failed, .timeout, .running, .queued, .pending: + return true + case .success, .cancelled: + return false + } + case .active: + switch job.status { + case .running, .queued, .pending: + return true + case .success, .failed, .cancelled, .timeout: + return false + } + case .all: + return true + } + } + } } diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 632e804..3efef7a 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -55,7 +55,9 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { + attentionSection(viewModel) systemStatusBannerSection(viewModel) + inboxSection(viewModel) projectsSection(viewModel) assignedTicketsSection(viewModel) recentBuildsSection(viewModel) @@ -63,15 +65,17 @@ struct HomeView: View { .listStyle(.insetGrouped) .overlay { if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty { SRHTLoadingStateView(message: "Loading Home…") } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + viewModel.unreadInboxThreads.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( "All Clear", systemImage: "checkmark.circle", - description: Text("There are no assigned tickets or recent builds right now.") + description: Text("There are no unread threads, assigned tickets, or urgent builds right now.") ) } } @@ -84,6 +88,41 @@ struct HomeView: View { } @ViewBuilder + private func attentionSection(_ viewModel: HomeViewModel) -> some View { + Section("Needs Attention") { + HomeAttentionSummaryRow( + title: viewModel.needsAttentionCount == 0 ? "All clear" : "\(viewModel.needsAttentionCount) things need attention", + summary: viewModel.attentionSummaryText + ) + + HomeAttentionLinkRow( + title: "Inbox", + summary: viewModel.inboxSummaryText, + countText: viewModel.unreadInboxThreadCount.map(String.init) ?? "?" + ) { + InboxView() + } + + HomeAttentionLinkRow( + title: "Assigned Tickets", + summary: viewModel.ticketsSummaryText, + countText: String(viewModel.assignedTickets.count) + ) { + HomeAssignedTicketsListView(viewModel: viewModel) + } + + HomeAttentionLinkRow( + title: "Builds", + summary: viewModel.buildsSummaryText, + countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), + action: { + appState.selectedTab = .builds + } + ) + } + } + + @ViewBuilder private func systemStatusBannerSection(_ viewModel: HomeViewModel) -> some View { Section { NavigationLink { @@ -101,6 +140,40 @@ struct HomeView: View { } @ViewBuilder + private func inboxSection(_ viewModel: HomeViewModel) -> some View { + Section { + if let unreadCount = viewModel.unreadInboxThreadCount, unreadCount == 0 { + HomeSectionMessageRow( + text: "No unread inbox threads.", + systemImage: "tray" + ) + } else if viewModel.unreadInboxThreads.isEmpty { + HomeSectionMessageRow( + text: viewModel.inboxSummaryText, + systemImage: "tray" + ) + } else { + ForEach(viewModel.unreadInboxThreads.prefix(previewLimit)) { thread in + NavigationLink { + ThreadDetailView( + thread: thread, + onViewed: { viewModel.markInboxThreadRead(thread) }, + onMarkRead: { viewModel.markInboxThreadRead(thread) }, + onMarkUnread: { viewModel.markInboxThreadUnread(thread) } + ) + } label: { + HomeInboxThreadRow(thread: thread) + } + } + } + } header: { + HomeSectionHeader("Inbox") { + InboxView() + } + } + } + + @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { if !viewModel.projects.isEmpty { Section { @@ -312,41 +385,55 @@ private struct HomeProjectsListView: View { } private struct HomeBuildRow: View { + @Environment(AppState.self) private var appState let build: HomeBuildItem var body: some View { - HStack(spacing: 12) { - JobStatusIcon(status: build.job.status) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text(primaryTitle) - .font(.subheadline.weight(.medium)) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Job #\(build.job.id)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + JobStatusIcon(status: build.job.status) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + HStack(spacing: 8) { + Text("Job #\(build.job.id)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.status.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + + Text("•") + .font(.caption) + .foregroundStyle(.tertiary) + + Text(build.job.created.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + } + } - Text(build.job.status.rawValue.capitalized) + if let repositoryDisplayName = build.repositoryDisplayName { + Button { + openRepository() + } label: { + Label(repositoryDisplayName, systemImage: "book.closed") .font(.caption) .foregroundStyle(.secondary) - - Text("•") - .font(.caption) - .foregroundStyle(.tertiary) - - Text(build.job.created.relativeDescription) - .font(.caption) - .foregroundStyle(.tertiary) - - Spacer() } + .buttonStyle(.plain) } } .padding(.vertical, 2) @@ -358,38 +445,148 @@ private struct HomeBuildRow: View { } return build.job.displayLabel } + + private func openRepository() { + guard let repositoryName = build.repositoryName, + let repositoryOwner = build.repositoryOwner else { return } + Task { + do { + let repository = try await appState.resolveRepository( + owner: repositoryOwner.hasPrefix("~") ? String(repositoryOwner.dropFirst()) : repositoryOwner, + name: repositoryName + ) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeAssignedTicketRow: View { + @Environment(AppState.self) private var appState let ticket: HomeAssignedTicket var body: some View { - HStack(alignment: .top, spacing: 12) { - TicketStatusIcon(status: ticket.ticket.status) - .frame(width: 20) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + TicketStatusIcon(status: ticket.ticket.status) + .frame(width: 20) - VStack(alignment: .leading, spacing: 4) { - Text(ticket.ticket.title) - .font(.subheadline.weight(.medium)) - .lineLimit(2) + VStack(alignment: .leading, spacing: 4) { + Text(ticket.ticket.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) - Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") - .font(.caption) + Text("\(ticket.ownerCanonicalName)/\(ticket.trackerName) • #\(ticket.ticket.id) • \(ticket.ticket.created.relativeDescription)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + Text(ticket.ticket.status.displayName) + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) - .truncationMode(.tail) + .fixedSize() } - Spacer(minLength: 8) + Button { + openTracker() + } label: { + Label("\(ticket.ownerCanonicalName)/\(ticket.trackerName)", systemImage: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 2) + } - Text(ticket.ticket.status.displayName) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .fixedSize() + private func openTracker() { + Task { + do { + let tracker = try await appState.resolveTracker(owner: ticket.ownerUsername, name: ticket.trackerName) + appState.navigateToTracker(tracker) + } catch { + appState.presentTicketDeepLinkError() + } + } + } +} + +private struct HomeInboxThreadRow: View { + @Environment(AppState.self) private var appState + let thread: InboxThreadSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(.blue) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(thread.displaySubject) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + HStack(spacing: 10) { + Button { + appState.navigateToMailingList( + InboxMailingListReference( + id: thread.listID, + rid: thread.listRID, + name: thread.listName, + owner: thread.listOwner + ) + ) + } label: { + Label(thread.listName, systemImage: "list.bullet") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if let repo = thread.repo { + Button { + openRepository(named: repo) + } label: { + Label(repo, systemImage: "book.closed") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } } .padding(.vertical, 2) } + + private func openRepository(named repositoryName: String) { + Task { + do { + let ownerUsername = thread.listOwner.canonicalName.hasPrefix("~") + ? String(thread.listOwner.canonicalName.dropFirst()) + : thread.listOwner.canonicalName + let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct HomeSectionLoadingRow: View { @@ -431,6 +628,95 @@ private struct HomeSectionHeader<Destination: View>: View { } } +private struct HomeAttentionSummaryRow: View { + let title: String + let summary: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } +} + +private struct HomeAttentionLinkRow<Destination: View>: View { + let title: String + let summary: String + let countText: String + let destination: Destination? + let action: (() -> Void)? + + init( + title: String, + summary: String, + countText: String, + @ViewBuilder destination: () -> Destination + ) { + self.title = title + self.summary = summary + self.countText = countText + self.destination = destination() + self.action = nil + } + + init( + title: String, + summary: String, + countText: String, + action: @escaping () -> Void + ) where Destination == EmptyView { + self.title = title + self.summary = summary + self.countText = countText + self.destination = nil + self.action = action + } + + var body: some View { + Group { + if let destination { + NavigationLink { + destination + } label: { + content + } + } else if let action { + Button(action: action) { + content + } + .buttonStyle(.plain) + } + } + } + + private var content: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text(countText) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill), in: Capsule()) + } + .padding(.vertical, 2) + } +} + private struct HomeSectionActionHeader: View { let title: String let action: () -> Void diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index ba00a36..8762393 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -63,8 +63,31 @@ private struct HomeInboxThreadPage: Decodable, Sendable { } private struct HomeInboxThreadPayload: Decodable, Sendable { + let created: Date let updated: Date let subject: String + let replies: Int + let sender: Entity + let root: HomeInboxEmailPreview +} + +private struct HomeInboxEmailPreview: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let received: Date + let messageID: String + let body: String + let patch: HomeInboxPatchPreview? +} + +private struct HomeInboxPatchPreview: Decodable, Sendable { + let subject: String? +} + +private struct HomeInboxUnreadSnapshot: Sendable { + let unreadCount: Int + let threads: [InboxThreadSummary] } private struct HomeTicketPayload: Decodable, Sendable { @@ -135,6 +158,15 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { } return repositoryName } + + var requiresAttention: Bool { + switch job.status { + case .failed, .timeout, .running, .queued, .pending: + true + case .success, .cancelled: + false + } + } } @Observable @@ -143,6 +175,7 @@ final class HomeViewModel { private(set) var projects: [Project] = [] var assignedTickets: [HomeAssignedTicket] = [] var recentBuilds: [HomeBuildItem] = [] + var unreadInboxThreads: [InboxThreadSummary] = [] private(set) var systemStatusSnapshot: SystemStatusSnapshot? private(set) var isLoadingSystemStatus = false private(set) var isShowingStaleSystemStatus = false @@ -242,8 +275,20 @@ final class HomeViewModel { list(rid: $rid) { threads(cursor: $cursor) { results { + created updated subject + replies + sender { canonicalName } + root { + id + subject + date + received + messageID + body + patch { subject } + } } cursor } @@ -291,7 +336,7 @@ final class HomeViewModel { async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() async let assignedTicketsTask = loadAssignedTickets() - async let inboxUnreadTask = loadInboxUnreadCount() + async let inboxUnreadTask = loadInboxUnreadSnapshot() async let systemStatusTask = loadSystemStatusSnapshot() let projectsResult = await projectsTask @@ -328,7 +373,9 @@ final class HomeViewModel { } isLoadingAssignedTickets = false - unreadInboxThreadCount = await inboxUnreadTask + let inboxUnreadSnapshot = await inboxUnreadTask + unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount + unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? []) hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 let systemStatusResult = await systemStatusTask switch systemStatusResult { @@ -344,7 +391,116 @@ final class HomeViewModel { } var hasDashboardContent: Bool { - !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || systemStatusSnapshot != nil + !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil + } + + var failedBuildCount: Int { + recentBuilds.filter { + switch $0.job.status { + case .failed, .timeout: + return true + default: + return false + } + }.count + } + + var activeBuildCount: Int { + recentBuilds.filter { + switch $0.job.status { + case .pending, .queued, .running: + return true + default: + return false + } + }.count + } + + var activeIncidentCount: Int { + systemStatusSnapshot?.activeIncidents.count ?? 0 + } + + var disruptedServiceCount: Int { + systemStatusSnapshot?.disruptedServices.count ?? 0 + } + + var needsAttentionCount: Int { + var count = 0 + if let unreadInboxThreadCount { + count += unreadInboxThreadCount + } + count += assignedTickets.count + count += failedBuildCount + count += activeBuildCount + if let snapshot = systemStatusSnapshot, snapshot.hasDisruption { + count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count) + } + return count + } + + var attentionSummaryText: String { + if needsAttentionCount == 0 { + return "All clear" + } + var parts: [String] = [] + if let unreadInboxThreadCount, unreadInboxThreadCount > 0 { + parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) + } + if !assignedTickets.isEmpty { + parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket")) + } + if failedBuildCount > 0 { + parts.append(Self.countLabel(failedBuildCount, singular: "failed build")) + } + if activeBuildCount > 0 { + parts.append(Self.countLabel(activeBuildCount, singular: "active build")) + } + if disruptedServiceCount > 0 { + parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue")) + } + return parts.joined(separator: " • ") + } + + var inboxSummaryText: String { + guard let unreadInboxThreadCount else { return "Inbox status unavailable" } + if unreadInboxThreadCount == 0 { + return "Inbox zero" + } + return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists" + } + + var ticketsSummaryText: String { + if assignedTickets.isEmpty { + return "No open tickets assigned to you" + } + return Self.countLabel(assignedTickets.count, singular: "open assigned ticket") + } + + var buildsSummaryText: String { + if recentBuilds.isEmpty { + return "No recent builds" + } + var parts: [String] = [] + if failedBuildCount > 0 { + parts.append(Self.countLabel(failedBuildCount, singular: "failed build")) + } + if activeBuildCount > 0 { + parts.append(Self.countLabel(activeBuildCount, singular: "active build")) + } + if parts.isEmpty { + return "Recent builds are clear" + } + return parts.joined(separator: " • ") + } + + var systemSummaryText: String { + guard let systemStatusSnapshot else { + return systemStatusErrorMessage ?? "System status unavailable" + } + if systemStatusSnapshot.hasDisruption { + return systemStatusSnapshot.bannerSummary + } + return systemStatusSnapshot.overallStatusText } func resolveTicket(_ ticket: HomeAssignedTicket) async { @@ -415,6 +571,43 @@ final class HomeViewModel { } } + func markInboxThreadRead(_ thread: InboxThreadSummary) { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + unreadInboxThreads.removeAll { $0.id == thread.id } + unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0) + hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 + persistNeedsAttentionSnapshot() + } + + func markInboxThreadUnread(_ thread: InboxThreadSummary) { + InboxReadStateStore.markUnread(for: thread.id) + if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false { + unreadInboxThreads.append( + InboxThreadSummary( + rootEmailID: thread.rootEmailID, + rootMessageID: thread.rootMessageID, + threadRootEmailIDs: thread.threadRootEmailIDs, + threadRootMessageIDs: thread.threadRootMessageIDs, + listID: thread.listID, + listRID: thread.listRID, + listName: thread.listName, + listOwner: thread.listOwner, + subject: thread.subject, + latestSender: thread.latestSender, + lastActivityAt: thread.lastActivityAt, + messageCount: thread.messageCount, + repo: thread.repo, + containsPatch: thread.containsPatch, + isUnread: true + ) + ) + unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage) + } + unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1 + hasUnreadInboxThreads = true + persistNeedsAttentionSnapshot() + } + private func loadProjects() async -> Result<[Project], Error> { do { return .success(try await projectService.fetchProjects()) @@ -436,9 +629,9 @@ final class HomeViewModel { } } - private func loadInboxUnreadCount() async -> Int? { + private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? { do { - return try await fetchUnreadInboxThreadCount() + return try await fetchUnreadInboxSnapshot() } catch { return nil } @@ -452,13 +645,14 @@ final class HomeViewModel { } } - private func fetchUnreadInboxThreadCount() async throws -> Int { + private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot { let mailingLists = try await fetchInboxMailingLists() - guard !mailingLists.isEmpty else { return 0 } + guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) } var startIndex = mailingLists.startIndex var unreadCount = 0 var successfulFetchCount = 0 + var unreadThreads: [InboxThreadSummary] = [] while startIndex < mailingLists.endIndex { let endIndex = mailingLists.index( startIndex, @@ -467,31 +661,32 @@ final class HomeViewModel { ) ?? mailingLists.endIndex let batch = Array(mailingLists[startIndex..<endIndex]) - let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in + let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in for mailingList in batch { group.addTask { do { - return .success(try await self.fetchUnreadThreadCount(for: mailingList)) + return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList)) } catch { return .failure(error) } } } - var counts: [Int] = [] + var snapshots: [HomeInboxUnreadSnapshot] = [] var errors: [Error] = [] for await result in group { switch result { - case .success(let count): - counts.append(count) + case .success(let snapshot): + snapshots.append(snapshot) case .failure(let error): errors.append(error) } } - return (counts, errors) + return (snapshots, errors) } - unreadCount += batchResult.0.reduce(0, +) + unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount } + unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads)) successfulFetchCount += batchResult.0.count startIndex = endIndex @@ -501,7 +696,10 @@ final class HomeViewModel { throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) } - return unreadCount + return HomeInboxUnreadSnapshot( + unreadCount: unreadCount, + threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage) + ) } private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { @@ -532,9 +730,10 @@ final class HomeViewModel { return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } } - private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { + private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot { var unreadCount = 0 var cursor: String? + var unreadThreads: [InboxThreadSummary] = [] while true { var variables: [String: any Sendable] = ["rid": mailingList.rid] @@ -549,15 +748,31 @@ final class HomeViewModel { responseType: HomeInboxListThreadsResponse.self ) - unreadCount += response.list.threads.results.filter { thread in - let normalizedSubject = thread.subject - .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) - .lowercased() - let threadID = "\(mailingList.rid)#\(normalizedSubject)" - return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) - }.count + let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in + let summary = InboxThreadSummary( + rootEmailID: thread.root.id, + rootMessageID: thread.root.messageID, + threadRootEmailIDs: [thread.root.id], + threadRootMessageIDs: [thread.root.messageID], + listID: mailingList.id, + listRID: mailingList.rid, + listName: mailingList.name, + listOwner: mailingList.owner, + subject: thread.subject, + latestSender: thread.sender, + lastActivityAt: thread.updated, + messageCount: thread.replies + 1, + repo: InboxViewModel.deriveRepositoryName(from: mailingList.name), + containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), + isUnread: InboxReadStateStore.isUnread( + threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", + lastActivityAt: thread.updated + ) + ) + return summary.isUnread ? summary : nil + } + unreadCount += unreadThreadSummaries.count + unreadThreads.append(contentsOf: unreadThreadSummaries) guard let nextCursor = response.list.threads.cursor else { break @@ -565,14 +780,17 @@ final class HomeViewModel { cursor = nextCursor } - return unreadCount + return HomeInboxUnreadSnapshot( + unreadCount: unreadCount, + threads: unreadThreads + ) } private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { do { let trackers = try await fetchAllTrackers() let tickets = try await fetchAssignedTickets(for: trackers) - .sorted { $0.ticket.created > $1.ticket.created } + .sorted(by: Self.sortAssignedTicketsForTriage) return .success(tickets) } catch { return .failure(error) @@ -719,6 +937,7 @@ final class HomeViewModel { repositoryOwner: repository?.ownerCanonicalName ) } + .sorted(by: sortBuildItemsForTriage) } nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { @@ -732,6 +951,36 @@ final class HomeViewModel { } } + nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool { + let lhsPriority = buildPriority(for: lhs.job.status) + let rhsPriority = buildPriority(for: rhs.job.status) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + if lhs.job.updated != rhs.job.updated { + return lhs.job.updated > rhs.job.updated + } + return lhs.job.id > rhs.job.id + } + + nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool { + if lhs.ticket.created != rhs.ticket.created { + return lhs.ticket.created < rhs.ticket.created + } + return lhs.ticket.id < rhs.ticket.id + } + + nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool { + if lhs.containsPatch != rhs.containsPatch { + return lhs.containsPatch && !rhs.containsPatch + } + if lhs.lastActivityAt != rhs.lastActivityAt { + return lhs.lastActivityAt > rhs.lastActivityAt + } + return InboxThreadSummary.normalizationKey(for: lhs.subject) + .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending + } + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) @@ -784,6 +1033,25 @@ final class HomeViewModel { return trimmed } + private nonisolated static func buildPriority(for status: JobStatus) -> Int { + switch status { + case .failed, .timeout: + return 0 + case .running: + return 1 + case .queued, .pending: + return 2 + case .cancelled: + return 3 + case .success: + return 4 + } + } + + private nonisolated static func countLabel(_ count: Int, singular: String) -> String { + count == 1 ? "1 \(singular)" : "\(count) \(singular)s" + } + private struct StatusEventResponse: Decodable, Sendable { struct EventRef: Decodable, Sendable { let eventType: String diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift index aa3765c..2447a6e 100644 --- a/Hutch/Views/Inbox/InboxView.swift +++ b/Hutch/Views/Inbox/InboxView.swift @@ -42,6 +42,17 @@ struct InboxView: View { @Bindable var vm = viewModel List { + Section { + Picker("Filter", selection: $vm.filter) { + ForEach(InboxThreadFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + ForEach(viewModel.filteredThreads) { thread in Button { selectThread(thread) diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift index edb7593..a01f836 100644 --- a/Hutch/Views/Inbox/InboxViewModel.swift +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -56,12 +56,19 @@ private struct InboxEmailPreview: Decodable, Sendable { let patch: InboxPatchPreview? } +enum InboxThreadFilter: String, CaseIterable, Sendable { + case all = "All" + case patches = "Patches" + case discussions = "Talk" +} + @Observable @MainActor final class InboxViewModel { private(set) var threads: [InboxThreadSummary] = [] private(set) var isLoading = false var error: String? + var filter: InboxThreadFilter = .all var searchText = "" private let client: SRHTClient @@ -181,9 +188,10 @@ final class InboxViewModel { } var filteredThreads: [InboxThreadSummary] { + let filteredByKind = Self.filterThreads(threads, filter: filter) let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return threads } - return threads.filter { + guard !q.isEmpty else { return filteredByKind } + return filteredByKind.filter { $0.displaySubject.lowercased().contains(q) || $0.listName.lowercased().contains(q) || $0.latestSender.canonicalName.lowercased().contains(q) @@ -389,4 +397,17 @@ final class InboxViewModel { } return nil } + + nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] { + threads.filter { thread in + switch filter { + case .all: + return true + case .patches: + return thread.containsPatch + case .discussions: + return !thread.containsPatch + } + } + } } diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift index c595497..03aa779 100644 --- a/Hutch/Views/Inbox/ThreadDetailView.swift +++ b/Hutch/Views/Inbox/ThreadDetailView.swift @@ -18,6 +18,7 @@ struct ThreadDetailView: View { @State private var hasMarkedCurrentThreadViewed = false @State private var suppressAutoMarkViewed = false @State private var isUnread: Bool + @State private var isOpeningRepository = false init( thread: InboxThreadSummary, @@ -119,6 +120,41 @@ struct ThreadDetailView: View { .padding(.vertical, 4) } + Section("Related") { + Button { + appState.navigateToMailingList( + InboxMailingListReference( + id: thread.listID, + rid: thread.listRID, + name: thread.listName, + owner: thread.listOwner + ) + ) + } label: { + Label(thread.listDisplayName, systemImage: "list.bullet") + } + + if let repo = self.thread.repo { + Button { + openRepository(named: repo, ownerCanonicalName: thread.listOwner.canonicalName) + } label: { + HStack { + Label("\(thread.listOwner.canonicalName)/\(repo)", systemImage: "book.closed") + Spacer() + if isOpeningRepository { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + .disabled(isOpeningRepository) + } + } + if let partialWarning = viewModel.partialWarning { Section { Text(partialWarning) @@ -180,6 +216,21 @@ struct ThreadDetailView: View { parts.append(thread.lastActivityAt.relativeDescription) return parts.joined(separator: " • ") } + + private func openRepository(named repositoryName: String, ownerCanonicalName: String) { + guard !isOpeningRepository else { return } + isOpeningRepository = true + Task { + defer { isOpeningRepository = false } + do { + let ownerUsername = ownerCanonicalName.hasPrefix("~") ? String(ownerCanonicalName.dropFirst()) : ownerCanonicalName + let repository = try await appState.resolveRepository(owner: ownerUsername, name: repositoryName) + appState.navigateToRepository(repository) + } catch { + appState.presentRepositoryDeepLinkError() + } + } + } } private struct InboxMessageRow: View { diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 22ebaa9..4bea3ae 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -16,6 +16,7 @@ struct TicketDetailView: View { @State private var showResolveSheet = false @State private var showAssignSheet = false @State private var showLabelsSheet = false + @State private var isOpeningTracker = false // Comment composer mode @State private var commentMode: CommentMode = .write @@ -227,6 +228,26 @@ struct TicketDetailView: View { } .font(.caption) + Button { + openTracker() + } label: { + HStack(spacing: 6) { + Label("\(ownerUsername)/\(trackerName)", systemImage: "checklist") + .font(.caption.weight(.medium)) + if isOpeningTracker { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(isOpeningTracker) + if !ticket.assignees.isEmpty { HStack(spacing: 4) { Image(systemName: "person.fill") @@ -346,6 +367,20 @@ struct TicketDetailView: View { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed } + + private func openTracker() { + guard !isOpeningTracker else { return } + isOpeningTracker = true + Task { + defer { isOpeningTracker = false } + do { + let tracker = try await appState.resolveTracker(owner: ownerUsername, name: trackerName) + appState.navigateToTracker(tracker) + } catch { + appState.presentTicketDeepLinkError() + } + } + } } // MARK: - Self-Sizing Markdown Web View |
