Commit fbba92ac45

fbba92ac452bf6d5b765ef3c7f79f53abd7ddb26

parent: 6d901d0d72

Unsigned

cmc <hello@cleberg.net> · 2026-04-12 23:53 UTC

feat: improve Home structure and navigation

implements: https://todo.sr.ht/~ccleberg/hutch/14
implements: https://todo.sr.ht/~ccleberg/hutch/15
implements: https://todo.sr.ht/~ccleberg/hutch/16

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
39struct 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
56extension 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
41struct JobStatusIcon: View { 79struct JobStatusIcon: View {
Hutch/Views/Home/HomeSectionView.swift added +63
@@ -0,0 +1,63 @@
1import SwiftUI
2
3struct 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
36private 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
3struct HomeView: View { 3struct 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 {
387private struct HomeBuildRow: View { 439private 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
512private 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
547private 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
466private struct HomeAssignedTicketRow: View { 574private 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
720private 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
741private struct HomeAssignedTicketsListView: View { 828private 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
2set -euo pipefail
3
4TRACKER="hutch"
5ERR_FILE="$(mktemp)"
6trap 'rm -f "$ERR_FILE"' EXIT
7
8label_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
26label_ticket 14 enhancement ui ux # Home: Collapsible Sections
27label_ticket 15 enhancement ui # Home: See All Navigation
28label_ticket 16 enhancement ui ux # Home: Repo-Based Grouping
29
30label_ticket 17 enhancement builds # Builds: Auto-Refresh Toggle
31label_ticket 18 enhancement builds # Builds: Per-Repo Filter
32label_ticket 19 enhancement builds ux # Builds: Retry/Cancel Polish
33
34label_ticket 20 enhancement tickets ui # Tickets: Swipe Actions
35label_ticket 21 enhancement tickets ui # Tickets: Status and Label Visibility
36
37label_ticket 22 bug inbox ui # Inbox: Patch Rendering Fixes
38label_ticket 23 enhancement inbox ux # Inbox: Reply Flow Polish
39
40label_ticket 24 new-feature theming ui # Theming: AMOLED Mode
41label_ticket 25 new-feature theming ui # Theming: High-Density Mode
42
43# v2.17.x
44label_ticket 26 new-feature projects # Projects: Project List View
45label_ticket 27 new-feature projects # Projects: Project Detail View
46label_ticket 28 enhancement projects ui # Projects: Pin to Home
47
48label_ticket 29 enhancement builds # Builds: Log Search
49label_ticket 30 enhancement builds # Builds: Jump to Error
50label_ticket 31 enhancement builds # Builds: Artifact List
51
52label_ticket 32 enhancement tickets # Tickets: Saved Filters
53label_ticket 33 enhancement tickets # Tickets: Label Filtering
54
55label_ticket 34 enhancement inbox # Inbox: Basic Threading
56label_ticket 35 enhancement inbox # Inbox: Collapse Long Diffs
57
58# v2.18.x
59label_ticket 36 new-feature projects # Projects: Create Project
60label_ticket 37 enhancement projects # Projects: Edit Project
61label_ticket 38 enhancement projects # Projects: Manage Linked Resources
62
63label_ticket 39 new-feature acl repo # ACL: View Permissions
64label_ticket 40 enhancement acl repo # ACL: Add and Remove Users
65label_ticket 41 enhancement acl repo # ACL: Edit Permissions
66
67label_ticket 42 enhancement repo # Repo Settings: Edit Metadata
68label_ticket 43 enhancement repo # Repo Settings: Set Default Branch
69label_ticket 44 enhancement repo # Repo Settings: Visibility Changes
70
71label_ticket 45 new-feature tickets # Tickets: Label CRUD
72label_ticket 46 enhancement tickets # Tickets: Bulk Actions
73
74# v2.19.x
75label_ticket 47 new-feature search # Search: Per-Type Search
76label_ticket 48 enhancement search # Search: Recent Searches
77
78label_ticket 49 new-feature accounts # Accounts: Multi-Account Support
79label_ticket 50 enhancement accounts ui # Accounts: Account Switcher UI
80label_ticket 51 enhancement accounts performance # Accounts: Isolated Caching
81
82label_ticket 52 enhancement power # Power: Open in Browser
83label_ticket 53 enhancement power # Power: Copy Actions
84label_ticket 54 enhancement power # Power: Debug View Toggle
85
86# v2.20.x
87label_ticket 55 enhancement navigation ux # Navigation: Merge Inbox and Tickets Evaluation
88
89label_ticket 56 enhancement performance # Performance: Caching Improvements
90label_ticket 57 enhancement performance # Performance: List Virtualization
91
92label_ticket 58 enhancement repo # Consistency: Git and Mercurial Parity
93
94label_ticket 59 bug api # Errors: Normalize GraphQL Handling
95
96echo "Done."