Commit fbba92ac45
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +9 −9
| @@ -307,8 +307,8 @@ | |||
| 307 | projectRoot = ""; | 307 | projectRoot = ""; |
| 308 | targets = ( | 308 | targets = ( |
| 309 | 8B4B28D02F6704280045FA19 /* Hutch */, | 309 | 8B4B28D02F6704280045FA19 /* Hutch */, |
| 310 | 8B8182B62F6B73F3000AE049 /* HutchTests */, | ||
| 311 | 8BE081F72F80000100000001 /* HutchWidgetExtension */, | 310 | 8BE081F72F80000100000001 /* HutchWidgetExtension */, |
| 311 | 8B8182B62F6B73F3000AE049 /* HutchTests */, | ||
| 312 | ); | 312 | ); |
| 313 | }; | 313 | }; |
| 314 | /* End PBXProject section */ | 314 | /* End PBXProject section */ |
| @@ -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 = 35; | 518 | CURRENT_PROJECT_VERSION = 36; |
| 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.15.3; | 535 | MARKETING_VERSION = 2.16.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 = 35; | 555 | CURRENT_PROJECT_VERSION = 36; |
| 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.15.3; | 572 | MARKETING_VERSION = 2.16.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 = 35; | 635 | CURRENT_PROJECT_VERSION = 36; |
| 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.15.3; | 645 | MARKETING_VERSION = 2.16.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 = 35; | 664 | CURRENT_PROJECT_VERSION = 36; |
| 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.15.3; | 674 | MARKETING_VERSION = 2.16.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 +6
| @@ -20,6 +20,7 @@ final class AppState { | |||
| 20 | case tracker(TrackerSummary) | 20 | case tracker(TrackerSummary) |
| 21 | case mailingList(InboxMailingListReference) | 21 | case mailingList(InboxMailingListReference) |
| 22 | case systemStatus | 22 | case systemStatus |
| 23 | case builds | ||
| 23 | } | 24 | } |
| 24 | 25 | ||
| 25 | enum AuthPhase { | 26 | enum AuthPhase { |
| @@ -309,6 +310,11 @@ final class AppState { | |||
| 309 | selectedTab = .more | 310 | selectedTab = .more |
| 310 | } | 311 | } |
| 311 | 312 | ||
| 313 | func navigateToBuildsList() { | ||
| 314 | pendingTabNavigation = .builds | ||
| 315 | selectedTab = .builds | ||
| 316 | } | ||
| 317 | |||
| 312 | func presentRepositoryDeepLinkError() { | 318 | func presentRepositoryDeepLinkError() { |
| 313 | deepLinkError = "The repository could not be found or is inaccessible." | 319 | deepLinkError = "The repository could not be found or is inaccessible." |
| 314 | } | 320 | } |
Hutch/App/AppStorageKeys.swift +3
| @@ -8,4 +8,7 @@ enum AppStorageKeys { | |||
| 8 | static let hutchStatsBaseURL = "hutchStatsBaseURL" | 8 | static let hutchStatsBaseURL = "hutchStatsBaseURL" |
| 9 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" | 9 | static let systemStatusSnapshotCache = "systemStatusSnapshotCache" |
| 10 | static let systemStatusIncidentCache = "systemStatusIncidentCache" | 10 | static let systemStatusIncidentCache = "systemStatusIncidentCache" |
| 11 | static let homeProjectsExpanded = "homeProjectsExpanded" | ||
| 12 | static let homeAssignedTicketsExpanded = "homeAssignedTicketsExpanded" | ||
| 13 | static let homeBuildsExpanded = "homeBuildsExpanded" | ||
| 11 | } | 14 | } |
Hutch/App/RootView.swift +3
| @@ -242,6 +242,9 @@ struct RootView: View { | |||
| 242 | await settleNavigationTransition() | 242 | await settleNavigationTransition() |
| 243 | morePath.append(MoreRoute.systemStatus) | 243 | morePath.append(MoreRoute.systemStatus) |
| 244 | } | 244 | } |
| 245 | case .builds: | ||
| 246 | buildsPath = NavigationPath() | ||
| 247 | appState.selectedTab = .builds | ||
| 245 | } | 248 | } |
| 246 | } | 249 | } |
| 247 | 250 | ||
Hutch/Views/Builds/BuildRowView.swift +38
| @@ -36,6 +36,44 @@ struct BuildRowView: View { | |||
| 36 | } | 36 | } |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | struct JobStatusBadge: View { | ||
| 40 | let status: JobStatus | ||
| 41 | |||
| 42 | var body: some View { | ||
| 43 | HStack(spacing: 4) { | ||
| 44 | JobStatusIcon(status: status) | ||
| 45 | .frame(width: 12, height: 12) | ||
| 46 | Text(status.displayTitle) | ||
| 47 | .font(.caption2.weight(.medium)) | ||
| 48 | } | ||
| 49 | .foregroundStyle(.secondary) | ||
| 50 | .padding(.horizontal, 8) | ||
| 51 | .padding(.vertical, 4) | ||
| 52 | .background(Color(.secondarySystemFill), in: Capsule()) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | extension JobStatus { | ||
| 57 | var displayTitle: String { | ||
| 58 | switch self { | ||
| 59 | case .pending: | ||
| 60 | "Pending" | ||
| 61 | case .queued: | ||
| 62 | "Queued" | ||
| 63 | case .running: | ||
| 64 | "Running" | ||
| 65 | case .success: | ||
| 66 | "Succeeded" | ||
| 67 | case .failed: | ||
| 68 | "Failed" | ||
| 69 | case .cancelled: | ||
| 70 | "Cancelled" | ||
| 71 | case .timeout: | ||
| 72 | "Timed Out" | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 39 | // MARK: - Job Status Icon | 77 | // MARK: - Job Status Icon |
| 40 | 78 | ||
| 41 | struct JobStatusIcon: View { | 79 | struct JobStatusIcon: View { |
Hutch/Views/Home/HomeSectionView.swift added +63
| @@ -0,0 +1,63 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct HomeSectionView<Accessory: View, Content: View>: View { | ||
| 4 | let title: String | ||
| 5 | @Binding var isExpanded: Bool | ||
| 6 | let accessory: Accessory | ||
| 7 | let content: Content | ||
| 8 | |||
| 9 | init( | ||
| 10 | _ title: String, | ||
| 11 | isExpanded: Binding<Bool>, | ||
| 12 | @ViewBuilder accessory: () -> Accessory, | ||
| 13 | @ViewBuilder content: () -> Content | ||
| 14 | ) { | ||
| 15 | self.title = title | ||
| 16 | self._isExpanded = isExpanded | ||
| 17 | self.accessory = accessory() | ||
| 18 | self.content = content() | ||
| 19 | } | ||
| 20 | |||
| 21 | var body: some View { | ||
| 22 | Section { | ||
| 23 | if isExpanded { | ||
| 24 | content | ||
| 25 | } | ||
| 26 | } header: { | ||
| 27 | HomeSectionHeader( | ||
| 28 | title: title, | ||
| 29 | isExpanded: $isExpanded, | ||
| 30 | accessory: accessory | ||
| 31 | ) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | private struct HomeSectionHeader<Accessory: View>: View { | ||
| 37 | let title: String | ||
| 38 | @Binding var isExpanded: Bool | ||
| 39 | let accessory: Accessory | ||
| 40 | |||
| 41 | var body: some View { | ||
| 42 | HStack(spacing: 12) { | ||
| 43 | Button { | ||
| 44 | isExpanded.toggle() | ||
| 45 | } label: { | ||
| 46 | HStack(spacing: 8) { | ||
| 47 | Image(systemName: isExpanded ? "chevron.down" : "chevron.right") | ||
| 48 | .font(.caption.weight(.semibold)) | ||
| 49 | .foregroundStyle(.secondary) | ||
| 50 | Text(title) | ||
| 51 | } | ||
| 52 | .contentShape(Rectangle()) | ||
| 53 | } | ||
| 54 | .buttonStyle(.plain) | ||
| 55 | .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)") | ||
| 56 | |||
| 57 | Spacer(minLength: 8) | ||
| 58 | |||
| 59 | accessory | ||
| 60 | } | ||
| 61 | .textCase(nil) | ||
| 62 | } | ||
| 63 | } | ||
Hutch/Views/Home/HomeView.swift +147 −60
| @@ -2,6 +2,9 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct HomeView: View { | 3 | struct HomeView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true |
| 5 | @AppStorage(AppStorageKeys.homeProjectsExpanded) private var projectsExpanded = true | ||
| 6 | @AppStorage(AppStorageKeys.homeAssignedTicketsExpanded) private var assignedTicketsExpanded = true | ||
| 7 | @AppStorage(AppStorageKeys.homeBuildsExpanded) private var buildsExpanded = true | ||
| 5 | @Environment(AppState.self) private var appState | 8 | @Environment(AppState.self) private var appState |
| 6 | @Environment(\.scenePhase) private var scenePhase | 9 | @Environment(\.scenePhase) private var scenePhase |
| 7 | @State private var viewModel: HomeViewModel? | 10 | @State private var viewModel: HomeViewModel? |
| @@ -116,7 +119,7 @@ struct HomeView: View { | |||
| 116 | summary: viewModel.buildsSummaryText, | 119 | summary: viewModel.buildsSummaryText, |
| 117 | countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), | 120 | countText: String(viewModel.failedBuildCount + viewModel.activeBuildCount), |
| 118 | action: { | 121 | action: { |
| 119 | appState.selectedTab = .builds | 122 | appState.navigateToBuildsList() |
| 120 | } | 123 | } |
| 121 | ) | 124 | ) |
| 122 | } | 125 | } |
| @@ -176,7 +179,15 @@ struct HomeView: View { | |||
| 176 | @ViewBuilder | 179 | @ViewBuilder |
| 177 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { | 180 | private func projectsSection(_ viewModel: HomeViewModel) -> some View { |
| 178 | if !viewModel.projects.isEmpty { | 181 | if !viewModel.projects.isEmpty { |
| 179 | Section { | 182 | HomeSectionView("Projects", isExpanded: $projectsExpanded) { |
| 183 | NavigationLink { | ||
| 184 | HomeProjectsListView(viewModel: viewModel) | ||
| 185 | } label: { | ||
| 186 | Text("See All") | ||
| 187 | .font(.caption.weight(.medium)) | ||
| 188 | } | ||
| 189 | .buttonStyle(.plain) | ||
| 190 | } content: { | ||
| 180 | ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in | 191 | ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in |
| 181 | NavigationLink { | 192 | NavigationLink { |
| 182 | ProjectDetailView(project: project) | 193 | ProjectDetailView(project: project) |
| @@ -184,17 +195,21 @@ struct HomeView: View { | |||
| 184 | HomeProjectRow(project: project) | 195 | HomeProjectRow(project: project) |
| 185 | } | 196 | } |
| 186 | } | 197 | } |
| 187 | } header: { | ||
| 188 | HomeSectionHeader("Projects") { | ||
| 189 | HomeProjectsListView(viewModel: viewModel) | ||
| 190 | } | ||
| 191 | } | 198 | } |
| 192 | } | 199 | } |
| 193 | } | 200 | } |
| 194 | 201 | ||
| 195 | @ViewBuilder | 202 | @ViewBuilder |
| 196 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { | 203 | private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { |
| 197 | Section { | 204 | HomeSectionView("Tickets", isExpanded: $assignedTicketsExpanded) { |
| 205 | NavigationLink { | ||
| 206 | HomeAssignedTicketsListView(viewModel: viewModel) | ||
| 207 | } label: { | ||
| 208 | Text("See All") | ||
| 209 | .font(.caption.weight(.medium)) | ||
| 210 | } | ||
| 211 | .buttonStyle(.plain) | ||
| 212 | } content: { | ||
| 198 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | 213 | if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { |
| 199 | HomeSectionLoadingRow(label: "Loading assigned tickets") | 214 | HomeSectionLoadingRow(label: "Loading assigned tickets") |
| 200 | } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { | 215 | } else if let error = viewModel.assignedTicketsError, viewModel.assignedTickets.isEmpty { |
| @@ -241,16 +256,18 @@ struct HomeView: View { | |||
| 241 | } | 256 | } |
| 242 | } | 257 | } |
| 243 | } | 258 | } |
| 244 | } header: { | ||
| 245 | HomeSectionHeader("Assigned Tickets") { | ||
| 246 | HomeAssignedTicketsListView(viewModel: viewModel) | ||
| 247 | } | ||
| 248 | } | 259 | } |
| 249 | } | 260 | } |
| 250 | 261 | ||
| 251 | @ViewBuilder | 262 | @ViewBuilder |
| 252 | private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { | 263 | private func recentBuildsSection(_ viewModel: HomeViewModel) -> some View { |
| 253 | Section { | 264 | HomeSectionView("Builds", isExpanded: $buildsExpanded) { |
| 265 | Button("See All") { | ||
| 266 | appState.navigateToBuildsList() | ||
| 267 | } | ||
| 268 | .font(.caption.weight(.medium)) | ||
| 269 | .buttonStyle(.plain) | ||
| 270 | } content: { | ||
| 254 | if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { | 271 | if viewModel.isLoadingRecentBuilds && viewModel.recentBuilds.isEmpty { |
| 255 | HomeSectionLoadingRow(label: "Loading recent builds") | 272 | HomeSectionLoadingRow(label: "Loading recent builds") |
| 256 | } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { | 273 | } else if let error = viewModel.recentBuildsError, viewModel.recentBuilds.isEmpty { |
| @@ -266,32 +283,67 @@ struct HomeView: View { | |||
| 266 | systemImage: "clock" | 283 | systemImage: "clock" |
| 267 | ) | 284 | ) |
| 268 | } else { | 285 | } else { |
| 269 | ForEach(viewModel.recentBuilds.prefix(previewLimit)) { build in | 286 | ForEach(buildGroups(for: viewModel.recentBuilds)) { group in |
| 270 | NavigationLink { | 287 | if let repositoryDisplayName = group.repositoryDisplayName { |
| 271 | BuildDetailView(jobId: build.job.id) | 288 | HomeBuildGroupHeader( |
| 272 | } label: { | 289 | repositoryDisplayName: repositoryDisplayName, |
| 273 | HomeBuildRow(build: build) | 290 | buildCount: group.builds.count, |
| 291 | latestStatus: group.latestStatus | ||
| 292 | ) | ||
| 274 | } | 293 | } |
| 275 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | 294 | |
| 276 | if swipeActionsEnabled, build.job.status.isCancellable { | 295 | ForEach(group.builds) { build in |
| 277 | Button { | 296 | NavigationLink { |
| 278 | Task { | 297 | BuildDetailView(jobId: build.job.id) |
| 279 | await viewModel.cancelBuild(build) | 298 | } label: { |
| 299 | HomeBuildRow( | ||
| 300 | build: build, | ||
| 301 | showsRepositoryLink: group.repositoryDisplayName == nil | ||
| 302 | ) | ||
| 303 | } | ||
| 304 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 305 | if swipeActionsEnabled, build.job.status.isCancellable { | ||
| 306 | Button { | ||
| 307 | Task { | ||
| 308 | await viewModel.cancelBuild(build) | ||
| 309 | } | ||
| 280 | } | 310 | } |
| 311 | label: { | ||
| 312 | Label("Cancel", systemImage: "xmark.circle") | ||
| 313 | } | ||
| 314 | .tint(.red) | ||
| 281 | } | 315 | } |
| 282 | label: { | ||
| 283 | Label("Cancel", systemImage: "xmark.circle") | ||
| 284 | } | ||
| 285 | .tint(.red) | ||
| 286 | } | 316 | } |
| 287 | } | 317 | } |
| 288 | } | 318 | } |
| 289 | } | 319 | } |
| 290 | } header: { | 320 | } |
| 291 | HomeSectionActionHeader("Recent Builds") { | 321 | } |
| 292 | appState.selectedTab = .builds | 322 | |
| 323 | private func buildGroups(for builds: [HomeBuildItem]) -> [HomeBuildGroup] { | ||
| 324 | let previewBuilds = Array(builds.prefix(previewLimit)) | ||
| 325 | guard let firstBuild = previewBuilds.first else { return [] } | ||
| 326 | |||
| 327 | var groups: [HomeBuildGroup] = [] | ||
| 328 | var currentIdentity = HomeBuildGroup.Identity(build: firstBuild) | ||
| 329 | var currentBuilds: [HomeBuildItem] = [] | ||
| 330 | |||
| 331 | for build in previewBuilds { | ||
| 332 | let identity = HomeBuildGroup.Identity(build: build) | ||
| 333 | if identity == currentIdentity { | ||
| 334 | currentBuilds.append(build) | ||
| 335 | } else { | ||
| 336 | groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) | ||
| 337 | currentIdentity = identity | ||
| 338 | currentBuilds = [build] | ||
| 293 | } | 339 | } |
| 294 | } | 340 | } |
| 341 | |||
| 342 | if !currentBuilds.isEmpty { | ||
| 343 | groups.append(HomeBuildGroup(identity: currentIdentity, builds: currentBuilds)) | ||
| 344 | } | ||
| 345 | |||
| 346 | return groups | ||
| 295 | } | 347 | } |
| 296 | 348 | ||
| 297 | @ViewBuilder | 349 | @ViewBuilder |
| @@ -387,6 +439,7 @@ private struct HomeProjectsListView: View { | |||
| 387 | private struct HomeBuildRow: View { | 439 | private struct HomeBuildRow: View { |
| 388 | @Environment(AppState.self) private var appState | 440 | @Environment(AppState.self) private var appState |
| 389 | let build: HomeBuildItem | 441 | let build: HomeBuildItem |
| 442 | var showsRepositoryLink = true | ||
| 390 | 443 | ||
| 391 | var body: some View { | 444 | var body: some View { |
| 392 | VStack(alignment: .leading, spacing: 6) { | 445 | VStack(alignment: .leading, spacing: 6) { |
| @@ -395,7 +448,7 @@ private struct HomeBuildRow: View { | |||
| 395 | .frame(width: 20) | 448 | .frame(width: 20) |
| 396 | 449 | ||
| 397 | VStack(alignment: .leading, spacing: 4) { | 450 | VStack(alignment: .leading, spacing: 4) { |
| 398 | Text(primaryTitle) | 451 | Text(build.job.displayLabel) |
| 399 | .font(.subheadline.weight(.medium)) | 452 | .font(.subheadline.weight(.medium)) |
| 400 | .lineLimit(1) | 453 | .lineLimit(1) |
| 401 | 454 | ||
| @@ -408,7 +461,7 @@ private struct HomeBuildRow: View { | |||
| 408 | .font(.caption) | 461 | .font(.caption) |
| 409 | .foregroundStyle(.tertiary) | 462 | .foregroundStyle(.tertiary) |
| 410 | 463 | ||
| 411 | Text(build.job.status.rawValue.capitalized) | 464 | Text(build.job.status.displayTitle) |
| 412 | .font(.caption) | 465 | .font(.caption) |
| 413 | .foregroundStyle(.secondary) | 466 | .foregroundStyle(.secondary) |
| 414 | 467 | ||
| @@ -425,7 +478,7 @@ private struct HomeBuildRow: View { | |||
| 425 | } | 478 | } |
| 426 | } | 479 | } |
| 427 | 480 | ||
| 428 | if let repositoryDisplayName = build.repositoryDisplayName { | 481 | if showsRepositoryLink, let repositoryDisplayName = build.repositoryDisplayName { |
| 429 | Button { | 482 | Button { |
| 430 | openRepository() | 483 | openRepository() |
| 431 | } label: { | 484 | } label: { |
| @@ -439,13 +492,6 @@ private struct HomeBuildRow: View { | |||
| 439 | .padding(.vertical, 2) | 492 | .padding(.vertical, 2) |
| 440 | } | 493 | } |
| 441 | 494 | ||
| 442 | private var primaryTitle: String { | ||
| 443 | if let repositoryDisplayName = build.repositoryDisplayName { | ||
| 444 | return repositoryDisplayName | ||
| 445 | } | ||
| 446 | return build.job.displayLabel | ||
| 447 | } | ||
| 448 | |||
| 449 | private func openRepository() { | 495 | private func openRepository() { |
| 450 | guard let repositoryName = build.repositoryName, | 496 | guard let repositoryName = build.repositoryName, |
| 451 | let repositoryOwner = build.repositoryOwner else { return } | 497 | let repositoryOwner = build.repositoryOwner else { return } |
| @@ -463,6 +509,68 @@ private struct HomeBuildRow: View { | |||
| 463 | } | 509 | } |
| 464 | } | 510 | } |
| 465 | 511 | ||
| 512 | private struct HomeBuildGroup: Identifiable { | ||
| 513 | enum Identity: Hashable { | ||
| 514 | case repository(owner: String?, name: String) | ||
| 515 | case standalone(Int) | ||
| 516 | |||
| 517 | init(build: HomeBuildItem) { | ||
| 518 | if let repositoryName = build.repositoryName { | ||
| 519 | self = .repository(owner: build.repositoryOwner, name: repositoryName) | ||
| 520 | } else { | ||
| 521 | self = .standalone(build.id) | ||
| 522 | } | ||
| 523 | } | ||
| 524 | } | ||
| 525 | |||
| 526 | let identity: Identity | ||
| 527 | let builds: [HomeBuildItem] | ||
| 528 | |||
| 529 | var id: String { | ||
| 530 | switch identity { | ||
| 531 | case .repository(let owner, let name): | ||
| 532 | return "\(owner ?? "_")/\(name)#\(builds.first?.id ?? 0)" | ||
| 533 | case .standalone(let jobId): | ||
| 534 | return "job-\(jobId)" | ||
| 535 | } | ||
| 536 | } | ||
| 537 | |||
| 538 | var repositoryDisplayName: String? { | ||
| 539 | builds.first?.repositoryDisplayName | ||
| 540 | } | ||
| 541 | |||
| 542 | var latestStatus: JobStatus { | ||
| 543 | builds.first?.job.status ?? .pending | ||
| 544 | } | ||
| 545 | } | ||
| 546 | |||
| 547 | private struct HomeBuildGroupHeader: View { | ||
| 548 | let repositoryDisplayName: String | ||
| 549 | let buildCount: Int | ||
| 550 | let latestStatus: JobStatus | ||
| 551 | |||
| 552 | var body: some View { | ||
| 553 | HStack(spacing: 12) { | ||
| 554 | Label(repositoryDisplayName, systemImage: "book.closed") | ||
| 555 | .font(.caption.weight(.medium)) | ||
| 556 | .foregroundStyle(.secondary) | ||
| 557 | .lineLimit(1) | ||
| 558 | |||
| 559 | Spacer(minLength: 8) | ||
| 560 | |||
| 561 | Text("\(buildCount) \(buildCount == 1 ? "build" : "builds")") | ||
| 562 | .font(.caption2.weight(.medium)) | ||
| 563 | .foregroundStyle(.tertiary) | ||
| 564 | |||
| 565 | JobStatusBadge(status: latestStatus) | ||
| 566 | } | ||
| 567 | .padding(.top, 4) | ||
| 568 | .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 0, trailing: 20)) | ||
| 569 | .listRowSeparator(.hidden) | ||
| 570 | .accessibilityElement(children: .combine) | ||
| 571 | } | ||
| 572 | } | ||
| 573 | |||
| 466 | private struct HomeAssignedTicketRow: View { | 574 | private struct HomeAssignedTicketRow: View { |
| 467 | @Environment(AppState.self) private var appState | 575 | @Environment(AppState.self) private var appState |
| 468 | let ticket: HomeAssignedTicket | 576 | let ticket: HomeAssignedTicket |
| @@ -717,27 +825,6 @@ private struct HomeAttentionLinkRow<Destination: View>: View { | |||
| 717 | } | 825 | } |
| 718 | } | 826 | } |
| 719 | 827 | ||
| 720 | private struct HomeSectionActionHeader: View { | ||
| 721 | let title: String | ||
| 722 | let action: () -> Void | ||
| 723 | |||
| 724 | init(_ title: String, action: @escaping () -> Void) { | ||
| 725 | self.title = title | ||
| 726 | self.action = action | ||
| 727 | } | ||
| 728 | |||
| 729 | var body: some View { | ||
| 730 | HStack { | ||
| 731 | Text(title) | ||
| 732 | Spacer() | ||
| 733 | Button("See All", action: action) | ||
| 734 | .font(.caption.weight(.medium)) | ||
| 735 | .buttonStyle(.plain) | ||
| 736 | } | ||
| 737 | .textCase(nil) | ||
| 738 | } | ||
| 739 | } | ||
| 740 | |||
| 741 | private struct HomeAssignedTicketsListView: View { | 828 | private struct HomeAssignedTicketsListView: View { |
| 742 | let viewModel: HomeViewModel | 829 | let viewModel: HomeViewModel |
| 743 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 830 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true |
labels.sh added +96
| @@ -0,0 +1,96 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | TRACKER="hutch" | ||
| 5 | ERR_FILE="$(mktemp)" | ||
| 6 | trap 'rm -f "$ERR_FILE"' EXIT | ||
| 7 | |||
| 8 | label_ticket() { | ||
| 9 | local ticket_id="$1" | ||
| 10 | shift | ||
| 11 | |||
| 12 | for label in "$@"; do | ||
| 13 | echo "Applying label '$label' to ticket #$ticket_id" | ||
| 14 | if ! hut todo ticket label "$ticket_id" -t "$TRACKER" -l "$label" 2>"$ERR_FILE"; then | ||
| 15 | if grep -q "already assigned to this ticket" "$ERR_FILE"; then | ||
| 16 | echo " already present, skipping" | ||
| 17 | else | ||
| 18 | cat "$ERR_FILE" >&2 | ||
| 19 | exit 1 | ||
| 20 | fi | ||
| 21 | fi | ||
| 22 | done | ||
| 23 | } | ||
| 24 | |||
| 25 | # v2.16.x | ||
| 26 | label_ticket 14 enhancement ui ux # Home: Collapsible Sections | ||
| 27 | label_ticket 15 enhancement ui # Home: See All Navigation | ||
| 28 | label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping | ||
| 29 | |||
| 30 | label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle | ||
| 31 | label_ticket 18 enhancement builds # Builds: Per-Repo Filter | ||
| 32 | label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish | ||
| 33 | |||
| 34 | label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions | ||
| 35 | label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility | ||
| 36 | |||
| 37 | label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes | ||
| 38 | label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish | ||
| 39 | |||
| 40 | label_ticket 24 new-feature theming ui # Theming: AMOLED Mode | ||
| 41 | label_ticket 25 new-feature theming ui # Theming: High-Density Mode | ||
| 42 | |||
| 43 | # v2.17.x | ||
| 44 | label_ticket 26 new-feature projects # Projects: Project List View | ||
| 45 | label_ticket 27 new-feature projects # Projects: Project Detail View | ||
| 46 | label_ticket 28 enhancement projects ui # Projects: Pin to Home | ||
| 47 | |||
| 48 | label_ticket 29 enhancement builds # Builds: Log Search | ||
| 49 | label_ticket 30 enhancement builds # Builds: Jump to Error | ||
| 50 | label_ticket 31 enhancement builds # Builds: Artifact List | ||
| 51 | |||
| 52 | label_ticket 32 enhancement tickets # Tickets: Saved Filters | ||
| 53 | label_ticket 33 enhancement tickets # Tickets: Label Filtering | ||
| 54 | |||
| 55 | label_ticket 34 enhancement inbox # Inbox: Basic Threading | ||
| 56 | label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs | ||
| 57 | |||
| 58 | # v2.18.x | ||
| 59 | label_ticket 36 new-feature projects # Projects: Create Project | ||
| 60 | label_ticket 37 enhancement projects # Projects: Edit Project | ||
| 61 | label_ticket 38 enhancement projects # Projects: Manage Linked Resources | ||
| 62 | |||
| 63 | label_ticket 39 new-feature acl repo # ACL: View Permissions | ||
| 64 | label_ticket 40 enhancement acl repo # ACL: Add and Remove Users | ||
| 65 | label_ticket 41 enhancement acl repo # ACL: Edit Permissions | ||
| 66 | |||
| 67 | label_ticket 42 enhancement repo # Repo Settings: Edit Metadata | ||
| 68 | label_ticket 43 enhancement repo # Repo Settings: Set Default Branch | ||
| 69 | label_ticket 44 enhancement repo # Repo Settings: Visibility Changes | ||
| 70 | |||
| 71 | label_ticket 45 new-feature tickets # Tickets: Label CRUD | ||
| 72 | label_ticket 46 enhancement tickets # Tickets: Bulk Actions | ||
| 73 | |||
| 74 | # v2.19.x | ||
| 75 | label_ticket 47 new-feature search # Search: Per-Type Search | ||
| 76 | label_ticket 48 enhancement search # Search: Recent Searches | ||
| 77 | |||
| 78 | label_ticket 49 new-feature accounts # Accounts: Multi-Account Support | ||
| 79 | label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI | ||
| 80 | label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching | ||
| 81 | |||
| 82 | label_ticket 52 enhancement power # Power: Open in Browser | ||
| 83 | label_ticket 53 enhancement power # Power: Copy Actions | ||
| 84 | label_ticket 54 enhancement power # Power: Debug View Toggle | ||
| 85 | |||
| 86 | # v2.20.x | ||
| 87 | label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation | ||
| 88 | |||
| 89 | label_ticket 56 enhancement performance # Performance: Caching Improvements | ||
| 90 | label_ticket 57 enhancement performance # Performance: List Virtualization | ||
| 91 | |||
| 92 | label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity | ||
| 93 | |||
| 94 | label_ticket 59 bug api # Errors: Normalize GraphQL Handling | ||
| 95 | |||
| 96 | echo "Done." | ||