Commit 6b2371ec5c
Unsigned
Layout: unified · split
Hutch/App/RootView.swift +27 −21
| @@ -291,39 +291,45 @@ struct RootView: View { | |||
| 291 | } | 291 | } |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 294 | /// Replaces the target tab's path in one assignment. | ||
| 295 | /// | ||
| 296 | /// Resetting the path and appending to it afterwards races when the target tab | ||
| 297 | /// is already the one on screen: the reset starts an animated pop of the view | ||
| 298 | /// the user is standing on, and the appends land mid-animation, leaving a blank | ||
| 299 | /// screen. That is why opening a mailing list from a pinned project on Home | ||
| 300 | /// worked while the same tap under More → Projects did not — one changes tabs | ||
| 301 | /// and the other does not. | ||
| 302 | /// | ||
| 303 | /// Building the whole path first and assigning once gives SwiftUI a single | ||
| 304 | /// diff, with nothing to race. | ||
| 294 | private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { | 305 | private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { |
| 295 | switch target { | 306 | switch target { |
| 296 | case .repository(let repository): | 307 | case .repository(let repository): |
| 297 | repoPath = NavigationPath() | 308 | var path = NavigationPath() |
| 309 | path.append(repository) | ||
| 310 | repoPath = path | ||
| 298 | appState.selectedTab = .repositories | 311 | appState.selectedTab = .repositories |
| 299 | Task { | ||
| 300 | await settleNavigationTransition() | ||
| 301 | repoPath.append(repository) | ||
| 302 | } | ||
| 303 | 312 | ||
| 304 | case .tracker(let tracker): | 313 | case .tracker(let tracker): |
| 305 | ticketsPath = NavigationPath() | 314 | var path = NavigationPath() |
| 315 | path.append(tracker) | ||
| 316 | ticketsPath = path | ||
| 306 | appState.selectedTab = .tickets | 317 | appState.selectedTab = .tickets |
| 307 | Task { | ||
| 308 | await settleNavigationTransition() | ||
| 309 | ticketsPath.append(tracker) | ||
| 310 | } | ||
| 311 | 318 | ||
| 312 | case .mailingList(let mailingList): | 319 | case .mailingList(let mailingList): |
| 313 | morePath = NavigationPath() | 320 | // .lists first so back lands on Mailing Lists rather than dead-ending. |
| 321 | var path = NavigationPath() | ||
| 322 | path.append(MoreRoute.lists) | ||
| 323 | path.append(MoreRoute.mailingList(mailingList)) | ||
| 324 | morePath = path | ||
| 314 | appState.selectedTab = .more | 325 | appState.selectedTab = .more |
| 315 | Task { | 326 | |
| 316 | await settleNavigationTransition() | ||
| 317 | morePath.append(MoreRoute.lists) | ||
| 318 | morePath.append(MoreRoute.mailingList(mailingList)) | ||
| 319 | } | ||
| 320 | case .systemStatus: | 327 | case .systemStatus: |
| 321 | morePath = NavigationPath() | 328 | var path = NavigationPath() |
| 329 | path.append(MoreRoute.systemStatus) | ||
| 330 | morePath = path | ||
| 322 | appState.selectedTab = .more | 331 | appState.selectedTab = .more |
| 323 | Task { | 332 | |
| 324 | await settleNavigationTransition() | ||
| 325 | morePath.append(MoreRoute.systemStatus) | ||
| 326 | } | ||
| 327 | case .builds: | 333 | case .builds: |
| 328 | buildsPath = NavigationPath() | 334 | buildsPath = NavigationPath() |
| 329 | appState.selectedTab = .builds | 335 | appState.selectedTab = .builds |
Hutch/Views/Lists/MailingListListView.swift +2 −1
| @@ -373,11 +373,12 @@ struct MailingListListView: View { | |||
| 373 | // the data has not actually changed. | 373 | // the data has not actually changed. |
| 374 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 374 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 375 | if isOwned(mailingList) { | 375 | if isOwned(mailingList) { |
| 376 | Button(role: .destructive) { | 376 | Button { |
| 377 | pendingDeletion = mailingList | 377 | pendingDeletion = mailingList |
| 378 | } label: { | 378 | } label: { |
| 379 | SwiftUI.Label("Delete", systemImage: "trash") | 379 | SwiftUI.Label("Delete", systemImage: "trash") |
| 380 | } | 380 | } |
| 381 | .tint(.red) | ||
| 381 | Button { | 382 | Button { |
| 382 | editingList = mailingList | 383 | editingList = mailingList |
| 383 | } label: { | 384 | } label: { |
Hutch/Views/Pastes/PasteListView.swift +2 −1
| @@ -90,11 +90,12 @@ struct PasteListView: View { | |||
| 90 | } | 90 | } |
| 91 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 91 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 92 | if swipeActionsEnabled { | 92 | if swipeActionsEnabled { |
| 93 | Button(role: .destructive) { | 93 | Button { |
| 94 | pasteToDelete = paste | 94 | pasteToDelete = paste |
| 95 | } label: { | 95 | } label: { |
| 96 | Label("Delete", systemImage: "trash") | 96 | Label("Delete", systemImage: "trash") |
| 97 | } | 97 | } |
| 98 | .tint(.red) | ||
| 98 | } | 99 | } |
| 99 | } | 100 | } |
| 100 | .task { | 101 | .task { |
Hutch/Views/Repositories/ArtifactsView.swift +32 −20
| @@ -16,6 +16,21 @@ struct ArtifactsView: View { | |||
| 16 | 16 | ||
| 17 | var body: some View { | 17 | var body: some View { |
| 18 | List { | 18 | List { |
| 19 | // In the list rather than the toolbar: this view is a segment inside | ||
| 20 | // RepositoryDetailView's tab switch, not its own navigation | ||
| 21 | // destination, and a toolbar declared from there does not reliably | ||
| 22 | // reach the navigation bar. It also has to be reachable when there are | ||
| 23 | // no artifacts at all, which is the state a new tag is in. | ||
| 24 | if isOwnedByCurrentUser { | ||
| 25 | Button { | ||
| 26 | showTagPicker = true | ||
| 27 | } label: { | ||
| 28 | SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") | ||
| 29 | } | ||
| 30 | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) | ||
| 31 | .themedRow() | ||
| 32 | } | ||
| 33 | |||
| 19 | ForEach(viewModel.referenceArtifacts) { refArtifacts in | 34 | ForEach(viewModel.referenceArtifacts) { refArtifacts in |
| 20 | Section { | 35 | Section { |
| 21 | ForEach(refArtifacts.artifacts) { artifact in | 36 | ForEach(refArtifacts.artifacts) { artifact in |
| @@ -26,11 +41,12 @@ struct ArtifactsView: View { | |||
| 26 | // action animates the row out before the confirmation. | 41 | // action animates the row out before the confirmation. |
| 27 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 42 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 28 | if isOwnedByCurrentUser { | 43 | if isOwnedByCurrentUser { |
| 29 | Button(role: .destructive) { | 44 | Button { |
| 30 | pendingDeletion = artifact | 45 | pendingDeletion = artifact |
| 31 | } label: { | 46 | } label: { |
| 32 | SwiftUI.Label("Delete", systemImage: "trash") | 47 | SwiftUI.Label("Delete", systemImage: "trash") |
| 33 | } | 48 | } |
| 49 | .tint(.red) | ||
| 34 | } | 50 | } |
| 35 | } | 51 | } |
| 36 | } | 52 | } |
| @@ -83,20 +99,6 @@ struct ArtifactsView: View { | |||
| 83 | } message: { _ in | 99 | } message: { _ in |
| 84 | Text("This permanently removes the artifact from the tag. This cannot be undone.") | 100 | Text("This permanently removes the artifact from the tag. This cannot be undone.") |
| 85 | } | 101 | } |
| 86 | // The sections above only list tags that already have an artifact, so | ||
| 87 | // without this there would be no way to attach the first one to a tag. | ||
| 88 | .toolbar { | ||
| 89 | if isOwnedByCurrentUser { | ||
| 90 | ToolbarItem(placement: .topBarTrailing) { | ||
| 91 | Button { | ||
| 92 | showTagPicker = true | ||
| 93 | } label: { | ||
| 94 | SwiftUI.Label("Upload Artifact", systemImage: "square.and.arrow.up") | ||
| 95 | } | ||
| 96 | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { | 102 | .confirmationDialog("Upload to Tag", isPresented: $showTagPicker, titleVisibility: .visible) { |
| 101 | ForEach(viewModel.tags.prefix(12), id: \.name) { tag in | 103 | ForEach(viewModel.tags.prefix(12), id: \.name) { tag in |
| 102 | Button(RepositorySummary.displayBranchName(for: tag.name)) { | 104 | Button(RepositorySummary.displayBranchName(for: tag.name)) { |
| @@ -125,11 +127,21 @@ struct ArtifactsView: View { | |||
| 125 | retryAction: { await viewModel.loadArtifacts() } | 127 | retryAction: { await viewModel.loadArtifacts() } |
| 126 | ) | 128 | ) |
| 127 | } else if viewModel.referenceArtifacts.isEmpty { | 129 | } else if viewModel.referenceArtifacts.isEmpty { |
| 128 | ContentUnavailableView( | 130 | // The overlay covers the whole list, so the upload row above is |
| 129 | "No Artifacts", | 131 | // hidden underneath it — and a repository with no artifacts is |
| 130 | systemImage: "archivebox", | 132 | // exactly the one that needs uploading. Offer it here too. |
| 131 | description: Text("This repository has no release artifacts.") | 133 | ContentUnavailableView { |
| 132 | ) | 134 | SwiftUI.Label("No Artifacts", systemImage: "archivebox") |
| 135 | } description: { | ||
| 136 | Text("This repository has no release artifacts.") | ||
| 137 | } actions: { | ||
| 138 | if isOwnedByCurrentUser { | ||
| 139 | Button("Upload Artifact…") { | ||
| 140 | showTagPicker = true | ||
| 141 | } | ||
| 142 | .disabled(viewModel.isMutatingArtifact || viewModel.tags.isEmpty) | ||
| 143 | } | ||
| 144 | } | ||
| 133 | } | 145 | } |
| 134 | } | 146 | } |
| 135 | .task { | 147 | .task { |
Hutch/Views/Tickets/TrackerListView.swift +2 −1
| @@ -145,11 +145,12 @@ struct TrackerListView: View { | |||
| 145 | TrackerRowView(tracker: tracker) | 145 | TrackerRowView(tracker: tracker) |
| 146 | } | 146 | } |
| 147 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 147 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 148 | Button(role: .destructive) { | 148 | Button { |
| 149 | pendingDeletion = tracker | 149 | pendingDeletion = tracker |
| 150 | } label: { | 150 | } label: { |
| 151 | Label("Delete", systemImage: "trash") | 151 | Label("Delete", systemImage: "trash") |
| 152 | } | 152 | } |
| 153 | .tint(.red) | ||
| 153 | 154 | ||
| 154 | Button { | 155 | Button { |
| 155 | editingTracker = tracker | 156 | editingTracker = tracker |
Hutch/Views/Tickets/TrackerManagementView.swift +4 −2
| @@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View { | |||
| 751 | TrackerPermissionSummary(permissions: entry.permissions) | 751 | TrackerPermissionSummary(permissions: entry.permissions) |
| 752 | } | 752 | } |
| 753 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 753 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 754 | Button(role: .destructive) { | 754 | Button { |
| 755 | pendingDeletion = entry | 755 | pendingDeletion = entry |
| 756 | } label: { | 756 | } label: { |
| 757 | Label("Delete", systemImage: "trash") | 757 | Label("Delete", systemImage: "trash") |
| 758 | } | 758 | } |
| 759 | .tint(.red) | ||
| 759 | 760 | ||
| 760 | Button { | 761 | Button { |
| 761 | editingACL = entry | 762 | editingACL = entry |
| @@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View { | |||
| 1132 | } | 1133 | } |
| 1133 | .tint(.blue) | 1134 | .tint(.blue) |
| 1134 | 1135 | ||
| 1135 | Button(role: .destructive) { | 1136 | Button { |
| 1136 | pendingDeletion = label | 1137 | pendingDeletion = label |
| 1137 | } label: { | 1138 | } label: { |
| 1138 | Label("Delete", systemImage: "trash") | 1139 | Label("Delete", systemImage: "trash") |
| 1139 | } | 1140 | } |
| 1141 | .tint(.red) | ||
| 1140 | } | 1142 | } |
| 1141 | } | 1143 | } |
| 1142 | .themedRow() | 1144 | .themedRow() |