diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 18 | ||||
| -rw-r--r-- | Hutch/App/AppState.swift | 6 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 3 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildRowView.swift | 38 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeSectionView.swift | 63 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 207 | ||||
| -rwxr-xr-x | labels.sh | 96 |
8 files changed, 365 insertions, 69 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 0f11fbc..70c6c29 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -307,8 +307,8 @@ projectRoot = ""; targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, - 8B8182B62F6B73F3000AE049 /* HutchTests */, 8BE081F72F80000100000001 /* HutchWidgetExtension */, + 8B8182B62F6B73F3000AE049 /* HutchTests */, ); }; /* End PBXProject section */ @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.15.3; + MARKETING_VERSION = 2.16.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.15.3; + MARKETING_VERSION = 2.16.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.15.3; + MARKETING_VERSION = 2.16.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 36; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.15.3; + MARKETING_VERSION = 2.16.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 35ac77f..0f0b036 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -20,6 +20,7 @@ final class AppState { case tracker(TrackerSummary) case mailingList(InboxMailingListReference) case systemStatus + case builds } enum AuthPhase { @@ -309,6 +310,11 @@ final class AppState { selectedTab = .more } + func navigateToBuildsList() { + pendingTabNavigation = .builds + selectedTab = .builds + } + func presentRepositoryDeepLinkError() { deepLinkError = "The repository could not be found or is inaccessible." } diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index c1b39c4..68a9588 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -8,4 +8,7 @@ enum AppStorageKeys { static let hutchStatsBaseURL = "hutchStatsBaseURL" static let systemStatusSnapshotCache = "systemStatusSnapshotCache" static let systemStatusIncidentCache = "systemStatusIncidentCache" + static let homeProjectsExpanded = "homeProjectsExpanded" + static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" + static let homeBuildsExpanded = "homeBuildsExpanded" } diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 80532a0..baca7a7 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -242,6 +242,9 @@ struct RootView: View { await settleNavigationTransition() morePath.append(MoreRoute.systemStatus) } + case .builds: + buildsPath = NavigationPath() + appState.selectedTab = .builds } } diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift index e2afd27..9602e5c 100644 --- a/Hutch/Views/Builds/BuildRowView.swift +++ b/Hutch/Views/Builds/BuildRowView.swift @@ -36,6 +36,44 @@ struct BuildRowView: View { } } +struct JobStatusBadge: View { + let status: JobStatus + + var body: some View { + HStack(spacing: 4) { + JobStatusIcon(status: status) + .frame(width: 12, height: 12) + Text(status.displayTitle) + .font(.caption2.weight(.medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill), in: Capsule()) + } +} + +extension JobStatus { + var displayTitle: String { + switch self { + case .pending: + "Pending" + case .queued: + "Queued" + case .running: + "Running" + case .success: + "Succeeded" + case .failed: + "Failed" + case .cancelled: + "Cancelled" + case .timeout: + "Timed Out" + } + } +} + // MARK: - Job Status Icon struct JobStatusIcon: View { diff --git a/Hutch/Views/Home/HomeSectionView.swift b/Hutch/Views/Home/HomeSectionView.swift new file mode 100644 index 0000000..5f04d58 --- /dev/null +++ b/Hutch/Views/Home/HomeSectionView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct HomeSectionView<Accessory: View, Content: View>: View { + let title: String + @Binding var isExpanded: Bool + let accessory: Accessory + let content: Content + + init( + _ title: String, + isExpanded: Binding<Bool>, + @ViewBuilder accessory: () -> Accessory, + @ViewBuilder content: () -> Content + ) { + self.title = title + self._isExpanded = isExpanded + self.accessory = accessory() + self.content = content() + } + + var body: some View { + Section { + if isExpanded { + content + } + } header: { + HomeSectionHeader( + title: title, + isExpanded: $isExpanded, + accessory: accessory + ) + } + } +} + +private struct HomeSectionHeader<Accessory: View>: View { + let title: String + @Binding var isExpanded: Bool + let accessory: Accessory + + var body: some View { + HStack(spacing: 12) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 8) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(title) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)") + + Spacer(minLength: 8) + + accessory + } + .textCase(nil) + } +} diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 3efef7a..180f0f3 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -2,6 +2,9 @@ import SwiftUI struct HomeView: View { @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true + @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true + @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true + @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true @Environment(AppState.self) private var appState @Environment(\.scenePhase) private var scenePhase @State private var viewModel: HomeViewModel? @@ -116,7 +119,7 @@ struct HomeView: View { summary: viewModel.buildsSummaryText, countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), action: { - appState.selectedTab = .builds + appState.navigateToBuildsList() } ) } @@ -176,7 +179,15 @@ struct HomeView: View { @ViewBuilder private func projectsSection(_ viewModel: HomeViewModel) -> some View { if !viewModel.projects.isEmpty { - Section { + HomeSectionView("Projects", isExpanded: $projectsExpanded) { + NavigationLink { + HomeProjectsListView(viewModel: viewModel) + } label: { + Text("See All") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + } content: { ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in NavigationLink { ProjectDetailView(project: project) @@ -184,17 +195,21 @@ struct HomeView: View { HomeProjectRow(project: project) } } - } header: { - HomeSectionHeader("Projects") { - HomeProjectsListView(viewModel: viewModel) - } } } } @ViewBuilder private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { - Section { + HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) { + NavigationLink { + HomeAssignedTicketsListView(viewModel: viewModel) + } label: { + Text("See All") + .font(.caption.weight(.medium)) + } + .buttonStyle(.plain) + } content: { if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { HomeSectionLoadingRow(label: "Loading assigned tickets") } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { @@ -241,16 +256,18 @@ struct HomeView: View { } } } - } header: { - HomeSectionHeader("Assigned Tickets") { - HomeAssignedTicketsListView(viewModel: viewModel) - } } } @ViewBuilder private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { - Section { + HomeSectionView("Builds", isExpanded: $buildsExpanded) { + Button("See All") { + appState.navigateToBuildsList() + } + .font(.caption.weight(.medium)) + .buttonStyle(.plain) + } content: { if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { HomeSectionLoadingRow(label: "Loading recent builds") } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { @@ -266,32 +283,67 @@ struct HomeView: View { systemImage: "clock" ) } else { - ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in - NavigationLink { - BuildDetailView(jobId: build.job.id) - } label: { - HomeBuildRow(build: build) + ForEach(buildGroups(for: viewModel.recentBuilds)) { group in + if let repositoryDisplayName = group.repositoryDisplayName { + HomeBuildGroupHeader( + repositoryDisplayName: repositoryDisplayName, + buildCount: group.builds.count, + latestStatus: group.latestStatus + ) } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled, build.job.status.isCancellable { - Button { - Task { - await viewModel.cancelBuild(build) + + ForEach(group.builds) { build in + NavigationLink { + BuildDetailView(jobId: build.job.id) + } label: { + HomeBuildRow( + build: build, + showsRepositoryLink: group.repositoryDisplayName == nil + ) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled, build.job.status.isCancellable { + Button { + Task { + await viewModel.cancelBuild(build) + } } + label: { + Label("Cancel", systemImage: "xmark.circle") + } + .tint(.red) } - label: { - Label("Cancel", systemImage: "xmark.circle") - } - .tint(.red) } } } } - } header: { - HomeSectionActionHeader("Recent Builds") { - appState.selectedTab = .builds + } + } + + private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] { + let previewBuilds = Array(builds.prefix(previewLimit)) + guard let firstBuild = previewBuilds.first else { return [] } + + var groups: [HomeBuildGroup] = [] + var currentIdentity = HomeBuildGroup.Identity(build: firstBuild) + var currentBuilds: [HomeBuildItem] = [] + + for build in previewBuilds { + let identity = HomeBuildGroup.Identity(build: build) + if identity == currentIdentity { + currentBuilds.append(build) + } else { + groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) + currentIdentity = identity + currentBuilds = [build] } } + + if !currentBuilds.isEmpty { + groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) + } + + return groups } @ViewBuilder @@ -387,6 +439,7 @@ private struct HomeProjectsListView: View { private struct HomeBuildRow: View { @Environment(AppState.self) private var appState let build: HomeBuildItem + var showsRepositoryLink = true var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -395,7 +448,7 @@ private struct HomeBuildRow: View { .frame(width: 20) VStack(alignment: .leading, spacing: 4) { - Text(primaryTitle) + Text(build.job.displayLabel) .font(.subheadline.weight(.medium)) .lineLimit(1) @@ -408,7 +461,7 @@ private struct HomeBuildRow: View { .font(.caption) .foregroundStyle(.tertiary) - Text(build.job.status.rawValue.capitalized) + Text(build.job.status.displayTitle) .font(.caption) .foregroundStyle(.secondary) @@ -425,7 +478,7 @@ private struct HomeBuildRow: View { } } - if let repositoryDisplayName = build.repositoryDisplayName { + if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName { Button { openRepository() } label: { @@ -439,13 +492,6 @@ private struct HomeBuildRow: View { .padding(.vertical, 2) } - private var primaryTitle: String { - if let repositoryDisplayName = build.repositoryDisplayName { - return repositoryDisplayName - } - return build.job.displayLabel - } - private func openRepository() { guard let repositoryName = build.repositoryName, let repositoryOwner = build.repositoryOwner else { return } @@ -463,6 +509,68 @@ private struct HomeBuildRow: View { } } +private struct HomeBuildGroup: Identifiable { + enum Identity: Hashable { + case repository(owner: String?, name: String) + case standalone(Int) + + init(build: HomeBuildItem) { + if let repositoryName = build.repositoryName { + self = .repository(owner: build.repositoryOwner, name: repositoryName) + } else { + self = .standalone(build.id) + } + } + } + + let identity: Identity + let builds: [HomeBuildItem] + + var id: String { + switch identity { + case .repository(let owner, let name): + return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)" + case .standalone(let jobId): + return "job-\(jobId)" + } + } + + var repositoryDisplayName: String? { + builds.first?.repositoryDisplayName + } + + var latestStatus: JobStatus { + builds.first?.job.status ?? .pending + } +} + +private struct HomeBuildGroupHeader: View { + let repositoryDisplayName: String + let buildCount: Int + let latestStatus: JobStatus + + var body: some View { + HStack(spacing: 12) { + Label(repositoryDisplayName, systemImage: "book.closed") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 8) + + Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")") + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + + JobStatusBadge(status: latestStatus) + } + .padding(.top, 4) + .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20)) + .listRowSeparator(.hidden) + .accessibilityElement(children: .combine) + } +} + private struct HomeAssignedTicketRow: View { @Environment(AppState.self) private var appState let ticket: HomeAssignedTicket @@ -717,27 +825,6 @@ private struct HomeAttentionLinkRow<Destination: View>: View { } } -private struct HomeSectionActionHeader: View { - let title: String - let action: () -> Void - - init(_ title: String, action: @escaping () -> Void) { - self.title = title - self.action = action - } - - var body: some View { - HStack { - Text(title) - Spacer() - Button("See All", action: action) - .font(.caption.weight(.medium)) - .buttonStyle(.plain) - } - .textCase(nil) - } -} - private struct HomeAssignedTicketsListView: View { let viewModel: HomeViewModel @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true diff --git a/labels.sh b/labels.sh new file mode 100755 index 0000000..746e0c8 --- /dev/null +++ b/labels.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +TRACKER="hutch" +ERR_FILE="$(mktemp)" +trap 'rm -f "$ERR_FILE"' EXIT + +label_ticket() { + local ticket_id="$1" + shift + + for label in "$@"; do + echo "Applying label '$label' to ticket #$ticket_id" + if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then + if grep -q "already assigned to this ticket" "$ERR_FILE"; then + echo " already present, skipping" + else + cat "$ERR_FILE" >&2 + exit 1 + fi + fi + done +} + +# v2.16.x +label_ticket 14 enhancement ui ux # Home: Collapsible Sections +label_ticket 15 enhancement ui # Home: See All Navigation +label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping + +label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle +label_ticket 18 enhancement builds # Builds: Per-Repo Filter +label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish + +label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions +label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility + +label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes +label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish + +label_ticket 24 new-feature theming ui # Theming: AMOLED Mode +label_ticket 25 new-feature theming ui # Theming: High-Density Mode + +# v2.17.x +label_ticket 26 new-feature projects # Projects: Project List View +label_ticket 27 new-feature projects # Projects: Project Detail View +label_ticket 28 enhancement projects ui # Projects: Pin to Home + +label_ticket 29 enhancement builds # Builds: Log Search +label_ticket 30 enhancement builds # Builds: Jump to Error +label_ticket 31 enhancement builds # Builds: Artifact List + +label_ticket 32 enhancement tickets # Tickets: Saved Filters +label_ticket 33 enhancement tickets # Tickets: Label Filtering + +label_ticket 34 enhancement inbox # Inbox: Basic Threading +label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs + +# v2.18.x +label_ticket 36 new-feature projects # Projects: Create Project +label_ticket 37 enhancement projects # Projects: Edit Project +label_ticket 38 enhancement projects # Projects: Manage Linked Resources + +label_ticket 39 new-feature acl repo # ACL: View Permissions +label_ticket 40 enhancement acl repo # ACL: Add and Remove Users +label_ticket 41 enhancement acl repo # ACL: Edit Permissions + +label_ticket 42 enhancement repo # Repo Settings: Edit Metadata +label_ticket 43 enhancement repo # Repo Settings: Set Default Branch +label_ticket 44 enhancement repo # Repo Settings: Visibility Changes + +label_ticket 45 new-feature tickets # Tickets: Label CRUD +label_ticket 46 enhancement tickets # Tickets: Bulk Actions + +# v2.19.x +label_ticket 47 new-feature search # Search: Per-Type Search +label_ticket 48 enhancement search # Search: Recent Searches + +label_ticket 49 new-feature accounts # Accounts: Multi-Account Support +label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI +label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching + +label_ticket 52 enhancement power # Power: Open in Browser +label_ticket 53 enhancement power # Power: Copy Actions +label_ticket 54 enhancement power # Power: Debug View Toggle + +# v2.20.x +label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation + +label_ticket 56 enhancement performance # Performance: Caching Improvements +label_ticket 57 enhancement performance # Performance: List Virtualization + +label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity + +label_ticket 59 bug api # Errors: Normalize GraphQL Handling + +echo "Done." |
