Commit 707a3efe1e
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -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 = 49; | 518 | CURRENT_PROJECT_VERSION = 60; |
| 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.20.0; | 535 | MARKETING_VERSION = 2.21.1; |
| 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 = 49; | 555 | CURRENT_PROJECT_VERSION = 60; |
| 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.20.0; | 572 | MARKETING_VERSION = 2.21.1; |
| 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 = 49; | 635 | CURRENT_PROJECT_VERSION = 60; |
| 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.20.0; | 645 | MARKETING_VERSION = 2.21.1; |
| 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 = 49; | 664 | CURRENT_PROJECT_VERSION = 60; |
| 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.20.0; | 674 | MARKETING_VERSION = 2.21.1; |
| 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 +33
| @@ -1,5 +1,6 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import SwiftUI | 2 | import SwiftUI |
| 3 | import UIKit | ||
| 3 | import WebKit | 4 | import WebKit |
| 4 | 5 | ||
| 5 | /// Central application state shared across the view hierarchy. | 6 | /// Central application state shared across the view hierarchy. |
| @@ -63,6 +64,13 @@ final class AppState { | |||
| 63 | private(set) var systemStatusRepository: SystemStatusRepository | 64 | private(set) var systemStatusRepository: SystemStatusRepository |
| 64 | private var activeSession: AccountSession? | 65 | private var activeSession: AccountSession? |
| 65 | private(set) var sessionIdentity = UUID() | 66 | private(set) var sessionIdentity = UUID() |
| 67 | var isDebugModeEnabled = UserDefaults.standard.bool(forKey: AppStorageKeys.debugModeEnabled) { | ||
| 68 | didSet { | ||
| 69 | UserDefaults.standard.set(isDebugModeEnabled, forKey: AppStorageKeys.debugModeEnabled) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | private(set) var copyConfirmationMessage: String? | ||
| 73 | private var copyConfirmationTask: Task<Void, Never>? | ||
| 66 | 74 | ||
| 67 | var accountDefaults: UserDefaults { | 75 | var accountDefaults: UserDefaults { |
| 68 | activeSession?.defaults ?? .standard | 76 | activeSession?.defaults ?? .standard |
| @@ -210,6 +218,7 @@ final class AppState { | |||
| 210 | clearAllAccountArtifacts() | 218 | clearAllAccountArtifacts() |
| 211 | authPhase = .unauthenticated | 219 | authPhase = .unauthenticated |
| 212 | selectedTab = .home | 220 | selectedTab = .home |
| 221 | dismissCopyConfirmation() | ||
| 213 | } | 222 | } |
| 214 | 223 | ||
| 215 | func resetAppData() async { | 224 | func resetAppData() async { |
| @@ -230,6 +239,19 @@ final class AppState { | |||
| 230 | 239 | ||
| 231 | authPhase = .unauthenticated | 240 | authPhase = .unauthenticated |
| 232 | selectedTab = .home | 241 | selectedTab = .home |
| 242 | isDebugModeEnabled = false | ||
| 243 | dismissCopyConfirmation() | ||
| 244 | } | ||
| 245 | |||
| 246 | func copyToPasteboard(_ value: String, label: String) { | ||
| 247 | UIPasteboard.general.string = value | ||
| 248 | showCopyConfirmation(message: "Copied \(label)") | ||
| 249 | } | ||
| 250 | |||
| 251 | func dismissCopyConfirmation() { | ||
| 252 | copyConfirmationTask?.cancel() | ||
| 253 | copyConfirmationTask = nil | ||
| 254 | copyConfirmationMessage = nil | ||
| 233 | } | 255 | } |
| 234 | 256 | ||
| 235 | // MARK: - Deep link resolution | 257 | // MARK: - Deep link resolution |
| @@ -525,6 +547,17 @@ final class AppState { | |||
| 525 | } | 547 | } |
| 526 | } | 548 | } |
| 527 | } | 549 | } |
| 550 | |||
| 551 | private func showCopyConfirmation(message: String) { | ||
| 552 | copyConfirmationTask?.cancel() | ||
| 553 | copyConfirmationMessage = message | ||
| 554 | copyConfirmationTask = Task { @MainActor in | ||
| 555 | try? await Task.sleep(for: .seconds(1.6)) | ||
| 556 | guard !Task.isCancelled else { return } | ||
| 557 | copyConfirmationMessage = nil | ||
| 558 | copyConfirmationTask = nil | ||
| 559 | } | ||
| 560 | } | ||
| 528 | } | 561 | } |
| 529 | 562 | ||
| 530 | enum AppStateError: LocalizedError { | 563 | enum AppStateError: LocalizedError { |
Hutch/App/AppStorageKeys.swift +1
| @@ -19,4 +19,5 @@ enum AppStorageKeys { | |||
| 19 | static let ticketSavedFilters = "ticketSavedFilters" | 19 | static let ticketSavedFilters = "ticketSavedFilters" |
| 20 | static let appTheme = "appTheme" | 20 | static let appTheme = "appTheme" |
| 21 | static let displayDensity = "displayDensity" | 21 | static let displayDensity = "displayDensity" |
| 22 | static let debugModeEnabled = "debugModeEnabled" | ||
| 22 | } | 23 | } |
Hutch/App/RootView.swift +7
| @@ -123,6 +123,13 @@ struct RootView: View { | |||
| 123 | get: { appState.selectedTab }, | 123 | get: { appState.selectedTab }, |
| 124 | set: { appState.selectedTab = $0 } | 124 | set: { appState.selectedTab = $0 } |
| 125 | ))) | 125 | ))) |
| 126 | .safeAreaInset(edge: .bottom) { | ||
| 127 | if let message = appState.copyConfirmationMessage { | ||
| 128 | CopyConfirmationBadge(message: message) | ||
| 129 | .padding(.bottom, 4) | ||
| 130 | .transition(.move(edge: .bottom).combined(with: .opacity)) | ||
| 131 | } | ||
| 132 | } | ||
| 126 | .overlay { | 133 | .overlay { |
| 127 | if isResolvingDeepLink { | 134 | if isResolvingDeepLink { |
| 128 | ZStack { | 135 | ZStack { |
Hutch/Extensions/PowerUserActions.swift added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct DebugTextBlock: View { | ||
| 4 | let title: String | ||
| 5 | let content: String | ||
| 6 | |||
| 7 | var body: some View { | ||
| 8 | VStack(alignment: .leading, spacing: 8) { | ||
| 9 | Text(title) | ||
| 10 | .font(.caption.weight(.semibold)) | ||
| 11 | .foregroundStyle(.secondary) | ||
| 12 | .textCase(.uppercase) | ||
| 13 | |||
| 14 | Text(content) | ||
| 15 | .font(.caption.monospaced()) | ||
| 16 | .foregroundStyle(.primary) | ||
| 17 | .textSelection(.enabled) | ||
| 18 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 19 | .padding(12) | ||
| 20 | .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12)) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | struct CopyConfirmationBadge: View { | ||
| 26 | let message: String | ||
| 27 | |||
| 28 | var body: some View { | ||
| 29 | HStack(spacing: 6) { | ||
| 30 | Image(systemName: "checkmark.circle.fill") | ||
| 31 | .font(.caption) | ||
| 32 | Text(message) | ||
| 33 | .font(.caption.weight(.medium)) | ||
| 34 | } | ||
| 35 | .foregroundStyle(.white) | ||
| 36 | .padding(.horizontal, 12) | ||
| 37 | .padding(.vertical, 8) | ||
| 38 | .background(.green.gradient, in: Capsule()) | ||
| 39 | } | ||
| 40 | } | ||
Hutch/Extensions/SRHTWebURL.swift +14
| @@ -12,6 +12,16 @@ enum SRHTWebURL { | |||
| 12 | ) | 12 | ) |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | static func httpsCloneURL(_ repositorySummary: RepositorySummary) -> String? { | ||
| 16 | repository(repositorySummary)?.absoluteString | ||
| 17 | } | ||
| 18 | |||
| 19 | static func sshCloneURL(_ repositorySummary: RepositorySummary) -> String { | ||
| 20 | let host = "\(repositorySummary.service.rawValue).sr.ht" | ||
| 21 | let user = repositorySummary.service == .hg ? "hg" : "git" | ||
| 22 | return "\(user)@\(host):\(repositorySummary.owner.canonicalName)/\(repositorySummary.name)" | ||
| 23 | } | ||
| 24 | |||
| 15 | static func commit(repository: RepositorySummary, commitId: String) -> URL? { | 25 | static func commit(repository: RepositorySummary, commitId: String) -> URL? { |
| 16 | userScopedURL( | 26 | userScopedURL( |
| 17 | host: "\(repository.service.rawValue).sr.ht", | 27 | host: "\(repository.service.rawValue).sr.ht", |
| @@ -63,6 +73,10 @@ enum SRHTWebURL { | |||
| 63 | ) | 73 | ) |
| 64 | } | 74 | } |
| 65 | 75 | ||
| 76 | static func tracker(_ trackerSummary: TrackerSummary) -> URL? { | ||
| 77 | tracker(ownerUsername: trackerSummary.owner.canonicalName.srhtUsername, trackerName: trackerSummary.name) | ||
| 78 | } | ||
| 79 | |||
| 66 | static func projectSource(_ source: Project.SourceRepo) -> URL? { | 80 | static func projectSource(_ source: Project.SourceRepo) -> URL? { |
| 67 | userScopedURL( | 81 | userScopedURL( |
| 68 | host: "\(source.repoType.service.rawValue).sr.ht", | 82 | host: "\(source.repoType.service.rawValue).sr.ht", |
Hutch/Views/Builds/BuildDetailView.swift +71 −5
| @@ -27,7 +27,42 @@ struct BuildDetailView: View { | |||
| 27 | .navigationTitle("Job #\(jobId)") | 27 | .navigationTitle("Job #\(jobId)") |
| 28 | .navigationBarTitleDisplayMode(.inline) | 28 | .navigationBarTitleDisplayMode(.inline) |
| 29 | .toolbar { | 29 | .toolbar { |
| 30 | ToolbarItem(placement: .topBarTrailing) { | 30 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 31 | if let browserURL = viewModel?.job.flatMap({ SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }) { | ||
| 32 | Menu { | ||
| 33 | Button { | ||
| 34 | openURL(browserURL) | ||
| 35 | } label: { | ||
| 36 | Label("Open in Browser", systemImage: "safari") | ||
| 37 | } | ||
| 38 | |||
| 39 | Button { | ||
| 40 | appState.copyToPasteboard(browserURL.absoluteString, label: "build URL") | ||
| 41 | } label: { | ||
| 42 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 43 | } | ||
| 44 | |||
| 45 | if let job = viewModel?.job { | ||
| 46 | Button { | ||
| 47 | appState.copyToPasteboard(String(job.id), label: "job ID") | ||
| 48 | } label: { | ||
| 49 | Label("Copy Job ID", systemImage: "number") | ||
| 50 | } | ||
| 51 | |||
| 52 | if let note = job.note, !note.isEmpty { | ||
| 53 | Button { | ||
| 54 | appState.copyToPasteboard(note, label: "build note") | ||
| 55 | } label: { | ||
| 56 | Label("Copy Note", systemImage: "text.alignleft") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } label: { | ||
| 61 | Image(systemName: "ellipsis.circle") | ||
| 62 | } | ||
| 63 | .accessibilityLabel("Build actions") | ||
| 64 | } | ||
| 65 | |||
| 31 | SRHTShareButton( | 66 | SRHTShareButton( |
| 32 | url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, | 67 | url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, |
| 33 | target: .build | 68 | target: .build |
| @@ -102,7 +137,11 @@ struct BuildDetailView: View { | |||
| 102 | if viewModel == nil { | 137 | if viewModel == nil { |
| 103 | let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) | 138 | let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) |
| 104 | viewModel = vm | 139 | viewModel = vm |
| 105 | await vm.loadJob() | 140 | if appState.isDebugModeEnabled { |
| 141 | await vm.loadJobWithDebugCapture() | ||
| 142 | } else { | ||
| 143 | await vm.loadJob() | ||
| 144 | } | ||
| 106 | vm.startAutoRefresh() | 145 | vm.startAutoRefresh() |
| 107 | } | 146 | } |
| 108 | } | 147 | } |
| @@ -123,11 +162,10 @@ struct BuildDetailView: View { | |||
| 123 | SRHTErrorStateView( | 162 | SRHTErrorStateView( |
| 124 | title: "Couldn't Load Build", | 163 | title: "Couldn't Load Build", |
| 125 | message: error, | 164 | message: error, |
| 126 | retryAction: { await viewModel.loadJob() } | 165 | retryAction: { await reloadDetail(viewModel) } |
| 127 | ) | 166 | ) |
| 128 | } else if let job = viewModel.job { | 167 | } else if let job = viewModel.job { |
| 129 | List { | 168 | List { |
| 130 | // Status & metadata | ||
| 131 | Section("Details") { | 169 | Section("Details") { |
| 132 | HStack { | 170 | HStack { |
| 133 | Text("Status") | 171 | Text("Status") |
| @@ -160,6 +198,26 @@ struct BuildDetailView: View { | |||
| 160 | LabeledContent("Updated", value: job.updated.relativeDescription) | 198 | LabeledContent("Updated", value: job.updated.relativeDescription) |
| 161 | } | 199 | } |
| 162 | 200 | ||
| 201 | if appState.isDebugModeEnabled { | ||
| 202 | Section("Debug") { | ||
| 203 | DebugTextBlock( | ||
| 204 | title: "Diagnostics", | ||
| 205 | content: """ | ||
| 206 | jobId: \(job.id) | ||
| 207 | status: \(job.status.rawValue) | ||
| 208 | tasks: \(job.tasks.count) | ||
| 209 | artifacts: \(job.artifacts.count) | ||
| 210 | owner: \(job.owner.canonicalName) | ||
| 211 | url: \(SRHTWebURL.build(jobId: job.id, ownerCanonicalName: job.owner.canonicalName)?.absoluteString ?? "unavailable") | ||
| 212 | """ | ||
| 213 | ) | ||
| 214 | |||
| 215 | if let rawJobResponse = viewModel.rawJobResponse { | ||
| 216 | DebugTextBlock(title: "Raw Response", content: rawJobResponse) | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 163 | if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) { | 221 | if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) { |
| 164 | Section("Source") { | 222 | Section("Source") { |
| 165 | Button { | 223 | Button { |
| @@ -274,7 +332,7 @@ struct BuildDetailView: View { | |||
| 274 | } | 332 | } |
| 275 | } | 333 | } |
| 276 | .refreshable { | 334 | .refreshable { |
| 277 | await viewModel.loadJob() | 335 | await reloadDetail(viewModel) |
| 278 | } | 336 | } |
| 279 | .srhtErrorBanner(error: Binding( | 337 | .srhtErrorBanner(error: Binding( |
| 280 | get: { viewModel.error }, | 338 | get: { viewModel.error }, |
| @@ -301,6 +359,14 @@ struct BuildDetailView: View { | |||
| 301 | } | 359 | } |
| 302 | } | 360 | } |
| 303 | } | 361 | } |
| 362 | |||
| 363 | private func reloadDetail(_ viewModel: BuildDetailViewModel) async { | ||
| 364 | if appState.isDebugModeEnabled { | ||
| 365 | await viewModel.loadJobWithDebugCapture() | ||
| 366 | } else { | ||
| 367 | await viewModel.loadJob() | ||
| 368 | } | ||
| 369 | } | ||
| 304 | } | 370 | } |
| 305 | 371 | ||
| 306 | private struct BuildArtifactRow: View { | 372 | private struct BuildArtifactRow: View { |
Hutch/Views/Builds/BuildDetailViewModel.swift +47 −2
| @@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable { | |||
| 28 | @MainActor | 28 | @MainActor |
| 29 | final class BuildDetailViewModel { | 29 | final class BuildDetailViewModel { |
| 30 | private static let autoRefreshInterval: Duration = .seconds(5) | 30 | private static let autoRefreshInterval: Duration = .seconds(5) |
| 31 | private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" } | ||
| 31 | 32 | ||
| 32 | let jobId: Int | 33 | let jobId: Int |
| 33 | private let client: SRHTClient | 34 | private let client: SRHTClient |
| @@ -44,6 +45,7 @@ final class BuildDetailViewModel { | |||
| 44 | private(set) var isCancelling = false | 45 | private(set) var isCancelling = false |
| 45 | private(set) var isRebuilding = false | 46 | private(set) var isRebuilding = false |
| 46 | private(set) var isSubmittingEditedBuild = false | 47 | private(set) var isSubmittingEditedBuild = false |
| 48 | private(set) var rawJobResponse: String? | ||
| 47 | var error: String? | 49 | var error: String? |
| 48 | /// Transient error shown for action failures (cancel, rebuild, submit). | 50 | /// Transient error shown for action failures (cancel, rebuild, submit). |
| 49 | /// Separate from `error` so auto-refresh doesn't immediately clear it. | 51 | /// Separate from `error` so auto-refresh doesn't immediately clear it. |
| @@ -123,6 +125,7 @@ final class BuildDetailViewModel { | |||
| 123 | guard !isLoading else { return } | 125 | guard !isLoading else { return } |
| 124 | isLoading = true | 126 | isLoading = true |
| 125 | error = nil | 127 | error = nil |
| 128 | rawJobResponse = nil | ||
| 126 | 129 | ||
| 127 | do { | 130 | do { |
| 128 | let result = try await client.execute( | 131 | let result = try await client.execute( |
| @@ -149,6 +152,40 @@ final class BuildDetailViewModel { | |||
| 149 | isLoading = false | 152 | isLoading = false |
| 150 | } | 153 | } |
| 151 | 154 | ||
| 155 | func loadJobWithDebugCapture() async { | ||
| 156 | guard !isLoading else { return } | ||
| 157 | isLoading = true | ||
| 158 | error = nil | ||
| 159 | |||
| 160 | do { | ||
| 161 | let cacheKey = Self.cacheKey(for: jobId) | ||
| 162 | let result = try await client.executeAndCache( | ||
| 163 | service: .builds, | ||
| 164 | query: Self.detailQuery, | ||
| 165 | variables: ["id": jobId], | ||
| 166 | responseType: JobDetailResponse.self, | ||
| 167 | cacheKey: cacheKey | ||
| 168 | ) | ||
| 169 | rawJobResponse = client.responseCache.get(forKey: cacheKey) | ||
| 170 | .flatMap { String(data: $0, encoding: .utf8) } | ||
| 171 | var loadedJob = result.job | ||
| 172 | loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in | ||
| 173 | task.withOrdinal(index) | ||
| 174 | } | ||
| 175 | if job != loadedJob { | ||
| 176 | job = loadedJob | ||
| 177 | } | ||
| 178 | |||
| 179 | if loadedJob.status.isTerminal { | ||
| 180 | stopAutoRefresh() | ||
| 181 | } | ||
| 182 | } catch { | ||
| 183 | self.error = error.userFacingMessage | ||
| 184 | } | ||
| 185 | |||
| 186 | isLoading = false | ||
| 187 | } | ||
| 188 | |||
| 152 | func loadTaskLog(task: BuildTask) async { | 189 | func loadTaskLog(task: BuildTask) async { |
| 153 | let cacheKey = task.logCacheKey | 190 | let cacheKey = task.logCacheKey |
| 154 | let jobIsTerminal = job?.status.isTerminal ?? false | 191 | let jobIsTerminal = job?.status.isTerminal ?? false |
| @@ -250,7 +287,7 @@ final class BuildDetailViewModel { | |||
| 250 | variables: ["id": jobId], | 287 | variables: ["id": jobId], |
| 251 | responseType: CancelResponse.self | 288 | responseType: CancelResponse.self |
| 252 | ) | 289 | ) |
| 253 | await loadJob() | 290 | await reloadJobPreservingDebugState() |
| 254 | } catch { | 291 | } catch { |
| 255 | // Revert optimistic update on failure. | 292 | // Revert optimistic update on failure. |
| 256 | self.job = originalJob | 293 | self.job = originalJob |
| @@ -372,6 +409,14 @@ final class BuildDetailViewModel { | |||
| 372 | self.autoRefreshTask = nil | 409 | self.autoRefreshTask = nil |
| 373 | } | 410 | } |
| 374 | 411 | ||
| 412 | private func reloadJobPreservingDebugState() async { | ||
| 413 | if rawJobResponse != nil { | ||
| 414 | await loadJobWithDebugCapture() | ||
| 415 | } else { | ||
| 416 | await loadJob() | ||
| 417 | } | ||
| 418 | } | ||
| 419 | |||
| 375 | private var shouldAutoRefresh: Bool { | 420 | private var shouldAutoRefresh: Bool { |
| 376 | guard let job else { return true } | 421 | guard let job else { return true } |
| 377 | return !job.status.isTerminal | 422 | return !job.status.isTerminal |
| @@ -385,7 +430,7 @@ final class BuildDetailViewModel { | |||
| 385 | return | 430 | return |
| 386 | } | 431 | } |
| 387 | 432 | ||
| 388 | await loadJob() | 433 | await reloadJobPreservingDebugState() |
| 389 | await loadBuildLog() | 434 | await loadBuildLog() |
| 390 | } | 435 | } |
| 391 | } | 436 | } |
Hutch/Views/Builds/BuildListView.swift +23
| @@ -136,6 +136,29 @@ struct BuildListView: View { | |||
| 136 | NavigationLink(value: job) { | 136 | NavigationLink(value: job) { |
| 137 | BuildRowView(job: job) | 137 | BuildRowView(job: job) |
| 138 | } | 138 | } |
| 139 | .contextMenu { | ||
| 140 | Button { | ||
| 141 | appState.copyToPasteboard(String(job.id), label: "job ID") | ||
| 142 | } label: { | ||
| 143 | Label("Copy Job ID", systemImage: "doc.on.doc") | ||
| 144 | } | ||
| 145 | |||
| 146 | if let note = job.note, !note.isEmpty { | ||
| 147 | Button { | ||
| 148 | appState.copyToPasteboard(note, label: "build note") | ||
| 149 | } label: { | ||
| 150 | Label("Copy Note", systemImage: "text.alignleft") | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | if !job.tags.isEmpty { | ||
| 155 | Button { | ||
| 156 | appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags") | ||
| 157 | } label: { | ||
| 158 | Label("Copy Tags", systemImage: "tag") | ||
| 159 | } | ||
| 160 | } | ||
| 161 | } | ||
| 139 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | 162 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 140 | if swipeActionsEnabled, job.status.isCancellable { | 163 | if swipeActionsEnabled, job.status.isCancellable { |
| 141 | Button { | 164 | Button { |
Hutch/Views/Builds/BuildTaskLogView.swift +3 −2
| @@ -1,7 +1,8 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | ||
| 3 | 2 | ||
| 4 | struct BuildTaskLogView: View { | 3 | struct BuildTaskLogView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | |||
| 5 | let taskName: String | 6 | let taskName: String |
| 6 | let viewModel: BuildDetailViewModel | 7 | let viewModel: BuildDetailViewModel |
| 7 | 8 | ||
| @@ -32,7 +33,7 @@ struct BuildTaskLogView: View { | |||
| 32 | } | 33 | } |
| 33 | ToolbarItem(placement: .topBarTrailing) { | 34 | ToolbarItem(placement: .topBarTrailing) { |
| 34 | Button { | 35 | Button { |
| 35 | UIPasteboard.general.string = logText | 36 | appState.copyToPasteboard(logText, label: "build log") |
| 36 | } label: { | 37 | } label: { |
| 37 | Image(systemName: "doc.on.doc") | 38 | Image(systemName: "doc.on.doc") |
| 38 | } | 39 | } |
Hutch/Views/Home/HomeView.swift −9
| @@ -74,15 +74,6 @@ struct HomeView: View { | |||
| 74 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | 74 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && |
| 75 | viewModel.unreadInboxThreads.isEmpty { | 75 | viewModel.unreadInboxThreads.isEmpty { |
| 76 | SRHTLoadingStateView(message: "Loading Home…") | 76 | SRHTLoadingStateView(message: "Loading Home…") |
| 77 | } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && | ||
| 78 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && | ||
| 79 | viewModel.unreadInboxThreads.isEmpty && | ||
| 80 | viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { | ||
| 81 | ContentUnavailableView( | ||
| 82 | "All Clear", | ||
| 83 | systemImage: "checkmark.circle", | ||
| 84 | description: Text("There are no unread threads, assigned tickets, or urgent builds right now.") | ||
| 85 | ) | ||
| 86 | } | 77 | } |
| 87 | } | 78 | } |
| 88 | .refreshable { | 79 | .refreshable { |
Hutch/Views/Repositories/CommitDetailView.swift +34 −2
| @@ -5,6 +5,7 @@ struct CommitDetailView: View { | |||
| 5 | let repository: RepositorySummary | 5 | let repository: RepositorySummary |
| 6 | 6 | ||
| 7 | @Environment(AppState.self) private var appState | 7 | @Environment(AppState.self) private var appState |
| 8 | @Environment(\.openURL) private var openURL | ||
| 8 | @State private var viewModel: CommitDetailViewModel? | 9 | @State private var viewModel: CommitDetailViewModel? |
| 9 | 10 | ||
| 10 | var body: some View { | 11 | var body: some View { |
| @@ -18,7 +19,38 @@ struct CommitDetailView: View { | |||
| 18 | .navigationTitle(commitSummary.shortId) | 19 | .navigationTitle(commitSummary.shortId) |
| 19 | .navigationBarTitleDisplayMode(.inline) | 20 | .navigationBarTitleDisplayMode(.inline) |
| 20 | .toolbar { | 21 | .toolbar { |
| 21 | ToolbarItem(placement: .topBarTrailing) { | 22 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 23 | Menu { | ||
| 24 | if let commitURL = SRHTWebURL.commit(repository: repository, commitId: commitSummary.id) { | ||
| 25 | Button { | ||
| 26 | openURL(commitURL) | ||
| 27 | } label: { | ||
| 28 | Label("Open in Browser", systemImage: "safari") | ||
| 29 | } | ||
| 30 | |||
| 31 | Button { | ||
| 32 | appState.copyToPasteboard(commitURL.absoluteString, label: "commit URL") | ||
| 33 | } label: { | ||
| 34 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | Button { | ||
| 39 | appState.copyToPasteboard(commitSummary.id, label: "commit SHA") | ||
| 40 | } label: { | ||
| 41 | Label("Copy Full SHA", systemImage: "doc.on.doc") | ||
| 42 | } | ||
| 43 | |||
| 44 | Button { | ||
| 45 | appState.copyToPasteboard(commitSummary.shortId, label: "short commit SHA") | ||
| 46 | } label: { | ||
| 47 | Label("Copy Short SHA", systemImage: "number") | ||
| 48 | } | ||
| 49 | } label: { | ||
| 50 | Image(systemName: "ellipsis.circle") | ||
| 51 | } | ||
| 52 | .accessibilityLabel("Commit actions") | ||
| 53 | |||
| 22 | SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { | 54 | SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { |
| 23 | Image(systemName: "square.and.arrow.up") | 55 | Image(systemName: "square.and.arrow.up") |
| 24 | } | 56 | } |
| @@ -109,7 +141,7 @@ struct CommitDetailView: View { | |||
| 109 | VStack(alignment: .leading, spacing: 8) { | 141 | VStack(alignment: .leading, spacing: 8) { |
| 110 | // Full hash — tappable to copy | 142 | // Full hash — tappable to copy |
| 111 | Button { | 143 | Button { |
| 112 | UIPasteboard.general.string = commit.id | 144 | appState.copyToPasteboard(commit.id, label: "commit SHA") |
| 113 | } label: { | 145 | } label: { |
| 114 | HStack(spacing: 4) { | 146 | HStack(spacing: 4) { |
| 115 | Text(commit.id) | 147 | Text(commit.id) |
Hutch/Views/Repositories/CommitLogView.swift +1 −1
| @@ -7,7 +7,7 @@ struct CommitLogView: View { | |||
| 7 | List { | 7 | List { |
| 8 | ForEach(viewModel.commits) { commit in | 8 | ForEach(viewModel.commits) { commit in |
| 9 | NavigationLink(value: commit) { | 9 | NavigationLink(value: commit) { |
| 10 | CommitRowView(commit: commit) | 10 | CommitRowView(commit: commit, repository: viewModel.repository) |
| 11 | } | 11 | } |
| 12 | .task { | 12 | .task { |
| 13 | await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) | 13 | await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) |
Hutch/Views/Repositories/CommitRowView.swift +14 −3
| @@ -1,8 +1,11 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | ||
| 3 | 2 | ||
| 4 | struct CommitRowView: View { | 3 | struct CommitRowView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @Environment(\.openURL) private var openURL | ||
| 6 | |||
| 5 | let commit: CommitSummary | 7 | let commit: CommitSummary |
| 8 | let repository: RepositorySummary | ||
| 6 | 9 | ||
| 7 | var body: some View { | 10 | var body: some View { |
| 8 | VStack(alignment: .leading, spacing: 4) { | 11 | VStack(alignment: .leading, spacing: 4) { |
| @@ -28,14 +31,22 @@ struct CommitRowView: View { | |||
| 28 | } | 31 | } |
| 29 | .padding(.vertical, 2) | 32 | .padding(.vertical, 2) |
| 30 | .contextMenu { | 33 | .contextMenu { |
| 34 | if let url = SRHTWebURL.commit(repository: repository, commitId: commit.id) { | ||
| 35 | Button { | ||
| 36 | openURL(url) | ||
| 37 | } label: { | ||
| 38 | Label("Open in Browser", systemImage: "safari") | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 31 | Button { | 42 | Button { |
| 32 | UIPasteboard.general.string = commit.id | 43 | appState.copyToPasteboard(commit.id, label: "commit SHA") |
| 33 | } label: { | 44 | } label: { |
| 34 | Label("Copy Full SHA", systemImage: "doc.on.doc") | 45 | Label("Copy Full SHA", systemImage: "doc.on.doc") |
| 35 | } | 46 | } |
| 36 | 47 | ||
| 37 | Button { | 48 | Button { |
| 38 | UIPasteboard.general.string = commit.shortId | 49 | appState.copyToPasteboard(commit.shortId, label: "short commit SHA") |
| 39 | } label: { | 50 | } label: { |
| 40 | Label("Copy Short SHA", systemImage: "doc.on.doc.fill") | 51 | Label("Copy Short SHA", systemImage: "doc.on.doc.fill") |
| 41 | } | 52 | } |
Hutch/Views/Repositories/ReadmeView.swift +23
| @@ -2,6 +2,7 @@ import SwiftUI | |||
| 2 | import WebKit | 2 | import WebKit |
| 3 | 3 | ||
| 4 | struct ReadmeView: View { | 4 | struct ReadmeView: View { |
| 5 | @Environment(AppState.self) private var appState | ||
| 5 | let viewModel: RepositoryDetailViewModel | 6 | let viewModel: RepositoryDetailViewModel |
| 6 | 7 | ||
| 7 | @Environment(\.colorScheme) private var colorScheme | 8 | @Environment(\.colorScheme) private var colorScheme |
| @@ -15,6 +16,9 @@ struct ReadmeView: View { | |||
| 15 | repositoryDetailsSection | 16 | repositoryDetailsSection |
| 16 | latestChangeSection | 17 | latestChangeSection |
| 17 | readmeSection | 18 | readmeSection |
| 19 | if appState.isDebugModeEnabled { | ||
| 20 | debugSection | ||
| 21 | } | ||
| 18 | } | 22 | } |
| 19 | .padding() | 23 | .padding() |
| 20 | } | 24 | } |
| @@ -150,6 +154,25 @@ struct ReadmeView: View { | |||
| 150 | .plainText(text) | 154 | .plainText(text) |
| 151 | } | 155 | } |
| 152 | } | 156 | } |
| 157 | |||
| 158 | private var debugSection: some View { | ||
| 159 | DebugTextBlock( | ||
| 160 | title: "Debug", | ||
| 161 | content: """ | ||
| 162 | repositoryId: \(viewModel.repository.id) | ||
| 163 | rid: \(viewModel.repository.rid) | ||
| 164 | service: \(viewModel.repository.service.rawValue) | ||
| 165 | defaultBranch: \(viewModel.repository.defaultBranchName ?? "none") | ||
| 166 | webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable") | ||
| 167 | httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable") | ||
| 168 | sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository)) | ||
| 169 | readmePath: \(viewModel.readmePath ?? "none") | ||
| 170 | commitsLoaded: \(viewModel.commits.count) | ||
| 171 | branchesLoaded: \(viewModel.branches.count) | ||
| 172 | tagsLoaded: \(viewModel.tags.count) | ||
| 173 | """ | ||
| 174 | ) | ||
| 175 | } | ||
| 153 | } | 176 | } |
| 154 | 177 | ||
| 155 | enum RenderedMarkupContent: Sendable { | 178 | enum RenderedMarkupContent: Sendable { |
Hutch/Views/Repositories/RepositoryDetailView.swift +61 −14
| @@ -1,6 +1,8 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct RepositoryDetailView: View { | 3 | struct RepositoryDetailView: View { |
| 4 | @Environment(\.openURL) private var openURL | ||
| 5 | |||
| 4 | let onRepositoryUpdated: ((RepositorySummary) -> Void)? | 6 | let onRepositoryUpdated: ((RepositorySummary) -> Void)? |
| 5 | var onDeleted: (() -> Void)? | 7 | var onDeleted: (() -> Void)? |
| 6 | 8 | ||
| @@ -42,23 +44,11 @@ struct RepositoryDetailView: View { | |||
| 42 | .navigationBarTitleDisplayMode(.inline) | 44 | .navigationBarTitleDisplayMode(.inline) |
| 43 | .toolbar { | 45 | .toolbar { |
| 44 | ToolbarItemGroup(placement: .topBarTrailing) { | 46 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 47 | repositoryActionsMenu | ||
| 48 | |||
| 45 | SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { | 49 | SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { |
| 46 | Image(systemName: "square.and.arrow.up") | 50 | Image(systemName: "square.and.arrow.up") |
| 47 | } | 51 | } |
| 48 | |||
| 49 | if canManageRepository { | ||
| 50 | Button { | ||
| 51 | showACLs = true | ||
| 52 | } label: { | ||
| 53 | Image(systemName: "person.2") | ||
| 54 | } | ||
| 55 | |||
| 56 | Button { | ||
| 57 | showSettings = true | ||
| 58 | } label: { | ||
| 59 | Image(systemName: "gear") | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | 52 | } |
| 63 | } | 53 | } |
| 64 | .sheet(isPresented: $showSettings) { | 54 | .sheet(isPresented: $showSettings) { |
| @@ -137,4 +127,61 @@ struct RepositoryDetailView: View { | |||
| 137 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | 127 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| 138 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | 128 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed |
| 139 | } | 129 | } |
| 130 | |||
| 131 | private var repositoryActionsMenu: some View { | ||
| 132 | Menu { | ||
| 133 | if let repositoryURL = SRHTWebURL.repository(currentRepository) { | ||
| 134 | Button { | ||
| 135 | openURL(repositoryURL) | ||
| 136 | } label: { | ||
| 137 | Label("Open in Browser", systemImage: "safari") | ||
| 138 | } | ||
| 139 | |||
| 140 | Button { | ||
| 141 | appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL") | ||
| 142 | } label: { | ||
| 143 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | if let httpsURL = SRHTWebURL.httpsCloneURL(currentRepository) { | ||
| 148 | Button { | ||
| 149 | appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL") | ||
| 150 | } label: { | ||
| 151 | Label("Copy HTTPS URL", systemImage: "doc.on.doc") | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | Button { | ||
| 156 | appState.copyToPasteboard(SRHTWebURL.sshCloneURL(currentRepository), label: "SSH clone URL") | ||
| 157 | } label: { | ||
| 158 | Label("Copy SSH URL", systemImage: "terminal") | ||
| 159 | } | ||
| 160 | |||
| 161 | Button { | ||
| 162 | appState.copyToPasteboard(currentRepository.rid, label: "repository RID") | ||
| 163 | } label: { | ||
| 164 | Label("Copy RID", systemImage: "number") | ||
| 165 | } | ||
| 166 | |||
| 167 | if canManageRepository { | ||
| 168 | Divider() | ||
| 169 | |||
| 170 | Button { | ||
| 171 | showACLs = true | ||
| 172 | } label: { | ||
| 173 | Label("Manage ACLs", systemImage: "person.2") | ||
| 174 | } | ||
| 175 | |||
| 176 | Button { | ||
| 177 | showSettings = true | ||
| 178 | } label: { | ||
| 179 | Label("Repository Settings", systemImage: "gear") | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } label: { | ||
| 183 | Image(systemName: "ellipsis.circle") | ||
| 184 | } | ||
| 185 | .accessibilityLabel("Repository actions") | ||
| 186 | } | ||
| 140 | } | 187 | } |
Hutch/Views/Repositories/RepositoryRowView.swift +29 −16
| @@ -1,7 +1,9 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import UIKit | ||
| 3 | 2 | ||
| 4 | struct RepositoryRowView: View { | 3 | struct RepositoryRowView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | @Environment(\.openURL) private var openURL | ||
| 6 | |||
| 5 | let repository: RepositorySummary | 7 | let repository: RepositorySummary |
| 6 | let buildStatus: RepositoryBuildStatus | 8 | let buildStatus: RepositoryBuildStatus |
| 7 | 9 | ||
| @@ -56,34 +58,45 @@ struct RepositoryRowView: View { | |||
| 56 | } | 58 | } |
| 57 | .padding(.vertical, 2) | 59 | .padding(.vertical, 2) |
| 58 | .contextMenu { | 60 | .contextMenu { |
| 61 | if let url = SRHTWebURL.repository(repository) { | ||
| 62 | Button { | ||
| 63 | openURL(url) | ||
| 64 | } label: { | ||
| 65 | Label("Open in Browser", systemImage: "safari") | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | Button { | ||
| 70 | if let url = SRHTWebURL.repository(repository)?.absoluteString { | ||
| 71 | appState.copyToPasteboard(url, label: "repository URL") | ||
| 72 | } | ||
| 73 | } label: { | ||
| 74 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 75 | } | ||
| 76 | |||
| 59 | Button { | 77 | Button { |
| 60 | UIPasteboard.general.string = httpsCloneURL(for: repository) | 78 | if let url = SRHTWebURL.httpsCloneURL(repository) { |
| 79 | appState.copyToPasteboard(url, label: "HTTPS clone URL") | ||
| 80 | } | ||
| 61 | } label: { | 81 | } label: { |
| 62 | Label("Copy HTTPS URL", systemImage: "doc.on.doc") | 82 | Label("Copy HTTPS URL", systemImage: "doc.on.doc") |
| 63 | } | 83 | } |
| 64 | 84 | ||
| 65 | Button { | 85 | Button { |
| 66 | UIPasteboard.general.string = sshCloneURL(for: repository) | 86 | appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL") |
| 67 | } label: { | 87 | } label: { |
| 68 | Label("Copy SSH URL", systemImage: "terminal") | 88 | Label("Copy SSH URL", systemImage: "terminal") |
| 69 | } | 89 | } |
| 90 | |||
| 91 | Button { | ||
| 92 | appState.copyToPasteboard(repository.rid, label: "repository RID") | ||
| 93 | } label: { | ||
| 94 | Label("Copy RID", systemImage: "number") | ||
| 95 | } | ||
| 70 | } | 96 | } |
| 71 | } | 97 | } |
| 72 | } | 98 | } |
| 73 | 99 | ||
| 74 | private func httpsCloneURL(for repository: RepositorySummary) -> String { | ||
| 75 | let host = "\(repository.service.rawValue).sr.ht" | ||
| 76 | let owner = repository.owner.canonicalName | ||
| 77 | return "https://\(host)/\(owner)/\(repository.name)" | ||
| 78 | } | ||
| 79 | |||
| 80 | private func sshCloneURL(for repository: RepositorySummary) -> String { | ||
| 81 | let host = "\(repository.service.rawValue).sr.ht" | ||
| 82 | let user = repository.service == .hg ? "hg" : "git" | ||
| 83 | let owner = repository.owner.canonicalName | ||
| 84 | return "\(user)@\(host):\(owner)/\(repository.name)" | ||
| 85 | } | ||
| 86 | |||
| 87 | private struct RepositoryBuildStatusIndicator: View { | 100 | private struct RepositoryBuildStatusIndicator: View { |
| 88 | let status: RepositoryBuildStatus | 101 | let status: RepositoryBuildStatus |
| 89 | 102 | ||
Hutch/Views/Settings/SettingsView.swift +39 −2
| @@ -7,6 +7,7 @@ struct SettingsView: View { | |||
| 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true | 8 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true |
| 9 | @State private var pendingDestructiveAction: SettingsDestructiveAction? | 9 | @State private var pendingDestructiveAction: SettingsDestructiveAction? |
| 10 | @State private var showAccountSwitcher = false | ||
| 10 | 11 | ||
| 11 | var body: some View { | 12 | var body: some View { |
| 12 | Form { | 13 | Form { |
| @@ -17,6 +18,9 @@ struct SettingsView: View { | |||
| 17 | } | 18 | } |
| 18 | .themedList() | 19 | .themedList() |
| 19 | .navigationTitle("Settings") | 20 | .navigationTitle("Settings") |
| 21 | .sheet(isPresented: $showAccountSwitcher) { | ||
| 22 | AccountSwitcherView() | ||
| 23 | } | ||
| 20 | .alert( | 24 | .alert( |
| 21 | pendingDestructiveAction?.title ?? "", | 25 | pendingDestructiveAction?.title ?? "", |
| 22 | isPresented: Binding( | 26 | isPresented: Binding( |
| @@ -98,8 +102,8 @@ struct SettingsView: View { | |||
| 98 | } | 102 | } |
| 99 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } | 103 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 100 | 104 | ||
| 101 | NavigationLink { | 105 | Button { |
| 102 | AccountSwitcherView() | 106 | showAccountSwitcher = true |
| 103 | } label: { | 107 | } label: { |
| 104 | Label("Manage Accounts", systemImage: "person.2") | 108 | Label("Manage Accounts", systemImage: "person.2") |
| 105 | } | 109 | } |
| @@ -176,6 +180,7 @@ private enum SettingsDestructiveAction { | |||
| 176 | } | 180 | } |
| 177 | 181 | ||
| 178 | private struct AboutView: View { | 182 | private struct AboutView: View { |
| 183 | @Environment(AppState.self) private var appState | ||
| 179 | private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String | 184 | private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String |
| 180 | ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String | 185 | ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String |
| 181 | ?? "Hutch" | 186 | ?? "Hutch" |
| @@ -183,6 +188,17 @@ private struct AboutView: View { | |||
| 183 | ?? "Unknown" | 188 | ?? "Unknown" |
| 184 | private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String | 189 | private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String |
| 185 | ?? "Unknown" | 190 | ?? "Unknown" |
| 191 | @State private var developerRevealCount = 0 | ||
| 192 | |||
| 193 | private var developerToolsVisible: Bool { | ||
| 194 | appState.isDebugModeEnabled || developerRevealCount >= 5 | ||
| 195 | } | ||
| 196 | |||
| 197 | private var developerRevealFooterText: String { | ||
| 198 | developerRevealCount >= 5 | ||
| 199 | ? "Debug toggle unlocked. Scroll down to Developer to enable it." | ||
| 200 | : "Tap the build number 5 times to reveal the debug toggle." | ||
| 201 | } | ||
| 186 | 202 | ||
| 187 | var body: some View { | 203 | var body: some View { |
| 188 | Form { | 204 | Form { |
| @@ -197,7 +213,15 @@ private struct AboutView: View { | |||
| 197 | .padding(.vertical, 4) | 213 | .padding(.vertical, 4) |
| 198 | 214 | ||
| 199 | LabeledContent("Version", value: version) | 215 | LabeledContent("Version", value: version) |
| 216 | .onTapGesture { | ||
| 217 | developerRevealCount = min(developerRevealCount + 1, 5) | ||
| 218 | } | ||
| 200 | LabeledContent("Build", value: build) | 219 | LabeledContent("Build", value: build) |
| 220 | .onTapGesture { | ||
| 221 | developerRevealCount = min(developerRevealCount + 1, 5) | ||
| 222 | } | ||
| 223 | } footer: { | ||
| 224 | Text(developerRevealFooterText) | ||
| 201 | } | 225 | } |
| 202 | 226 | ||
| 203 | Section("Links") { | 227 | Section("Links") { |
| @@ -233,6 +257,19 @@ private struct AboutView: View { | |||
| 233 | .font(.subheadline) | 257 | .font(.subheadline) |
| 234 | .foregroundStyle(.secondary) | 258 | .foregroundStyle(.secondary) |
| 235 | } | 259 | } |
| 260 | |||
| 261 | if developerToolsVisible { | ||
| 262 | Section { | ||
| 263 | Toggle("Debug Mode", isOn: Binding( | ||
| 264 | get: { appState.isDebugModeEnabled }, | ||
| 265 | set: { appState.isDebugModeEnabled = $0 } | ||
| 266 | )) | ||
| 267 | } header: { | ||
| 268 | Text("Developer") | ||
| 269 | } footer: { | ||
| 270 | Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.") | ||
| 271 | } | ||
| 272 | } | ||
| 236 | } | 273 | } |
| 237 | .themedList() | 274 | .themedList() |
| 238 | .navigationTitle("About") | 275 | .navigationTitle("About") |
Hutch/Views/Tickets/TicketDetailView.swift +79 −5
| @@ -10,6 +10,7 @@ struct TicketDetailView: View { | |||
| 10 | 10 | ||
| 11 | @Environment(AppState.self) private var appState | 11 | @Environment(AppState.self) private var appState |
| 12 | @Environment(\.colorScheme) private var colorScheme | 12 | @Environment(\.colorScheme) private var colorScheme |
| 13 | @Environment(\.openURL) private var openURL | ||
| 13 | @State private var viewModel: TicketDetailViewModel? | 14 | @State private var viewModel: TicketDetailViewModel? |
| 14 | 15 | ||
| 15 | // Sheet state | 16 | // Sheet state |
| @@ -47,7 +48,7 @@ struct TicketDetailView: View { | |||
| 47 | Image(systemName: "square.and.arrow.up") | 48 | Image(systemName: "square.and.arrow.up") |
| 48 | } | 49 | } |
| 49 | 50 | ||
| 50 | if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser { | 51 | if let viewModel, viewModel.ticket != nil { |
| 51 | actionsMenu(viewModel) | 52 | actionsMenu(viewModel) |
| 52 | } | 53 | } |
| 53 | } | 54 | } |
| @@ -63,7 +64,7 @@ struct TicketDetailView: View { | |||
| 63 | client: appState.client | 64 | client: appState.client |
| 64 | ) | 65 | ) |
| 65 | viewModel = vm | 66 | viewModel = vm |
| 66 | await vm.loadTicket() | 67 | await reloadDetail(vm) |
| 67 | } | 68 | } |
| 68 | } | 69 | } |
| 69 | } | 70 | } |
| @@ -73,6 +74,35 @@ struct TicketDetailView: View { | |||
| 73 | @ViewBuilder | 74 | @ViewBuilder |
| 74 | private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { | 75 | private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { |
| 75 | Menu { | 76 | Menu { |
| 77 | if let ticketURL = SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) { | ||
| 78 | Button { | ||
| 79 | openURL(ticketURL) | ||
| 80 | } label: { | ||
| 81 | SwiftUI.Label("Open in Browser", systemImage: "safari") | ||
| 82 | } | ||
| 83 | |||
| 84 | Button { | ||
| 85 | appState.copyToPasteboard(ticketURL.absoluteString, label: "ticket URL") | ||
| 86 | } label: { | ||
| 87 | SwiftUI.Label("Copy URL", systemImage: "doc.on.doc") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | Button { | ||
| 92 | appState.copyToPasteboard(String(ticketId), label: "ticket ID") | ||
| 93 | } label: { | ||
| 94 | SwiftUI.Label("Copy Ticket ID", systemImage: "number") | ||
| 95 | } | ||
| 96 | |||
| 97 | Button { | ||
| 98 | appState.copyToPasteboard(trackerRid, label: "tracker RID") | ||
| 99 | } label: { | ||
| 100 | SwiftUI.Label("Copy Tracker RID", systemImage: "number") | ||
| 101 | } | ||
| 102 | |||
| 103 | if isOwnedByCurrentUser { | ||
| 104 | Divider() | ||
| 105 | |||
| 76 | if let ticket = viewModel.ticket { | 106 | if let ticket = viewModel.ticket { |
| 77 | if ticket.status == .resolved { | 107 | if ticket.status == .resolved { |
| 78 | Button { | 108 | Button { |
| @@ -103,6 +133,7 @@ struct TicketDetailView: View { | |||
| 103 | } label: { | 133 | } label: { |
| 104 | SwiftUI.Label("Manage Labels", systemImage: "tag") | 134 | SwiftUI.Label("Manage Labels", systemImage: "tag") |
| 105 | } | 135 | } |
| 136 | } | ||
| 106 | } label: { | 137 | } label: { |
| 107 | Image(systemName: "ellipsis.circle") | 138 | Image(systemName: "ellipsis.circle") |
| 108 | } | 139 | } |
| @@ -133,7 +164,7 @@ struct TicketDetailView: View { | |||
| 133 | SRHTErrorStateView( | 164 | SRHTErrorStateView( |
| 134 | title: "Couldn't Load Ticket", | 165 | title: "Couldn't Load Ticket", |
| 135 | message: error, | 166 | message: error, |
| 136 | retryAction: { await viewModel.loadTicket() } | 167 | retryAction: { await reloadDetail(viewModel) } |
| 137 | ) | 168 | ) |
| 138 | } else if let ticket = viewModel.ticket { | 169 | } else if let ticket = viewModel.ticket { |
| 139 | ScrollView { | 170 | ScrollView { |
| @@ -179,13 +210,19 @@ struct TicketDetailView: View { | |||
| 179 | .padding(.vertical, 12) | 210 | .padding(.vertical, 12) |
| 180 | } | 211 | } |
| 181 | 212 | ||
| 182 | // Comment input | 213 | if appState.isDebugModeEnabled { |
| 214 | Divider() | ||
| 215 | .padding(.vertical, 12) | ||
| 216 | |||
| 217 | debugSection(viewModel: viewModel, ticket: ticket) | ||
| 218 | } | ||
| 219 | |||
| 183 | commentInput(viewModel) | 220 | commentInput(viewModel) |
| 184 | } | 221 | } |
| 185 | } | 222 | } |
| 186 | .srhtErrorBanner(error: $vm.error) | 223 | .srhtErrorBanner(error: $vm.error) |
| 187 | .refreshable { | 224 | .refreshable { |
| 188 | await viewModel.loadTicket() | 225 | await reloadDetail(viewModel) |
| 189 | } | 226 | } |
| 190 | } | 227 | } |
| 191 | } | 228 | } |
| @@ -363,6 +400,35 @@ struct TicketDetailView: View { | |||
| 363 | .padding() | 400 | .padding() |
| 364 | } | 401 | } |
| 365 | 402 | ||
| 403 | @ViewBuilder | ||
| 404 | private func debugSection(viewModel: TicketDetailViewModel, ticket: TicketDetail) -> some View { | ||
| 405 | VStack(alignment: .leading, spacing: 12) { | ||
| 406 | Text("Debug") | ||
| 407 | .font(.headline) | ||
| 408 | .padding(.horizontal) | ||
| 409 | |||
| 410 | VStack(alignment: .leading, spacing: 12) { | ||
| 411 | DebugTextBlock( | ||
| 412 | title: "Diagnostics", | ||
| 413 | content: """ | ||
| 414 | ticketId: \(ticket.id) | ||
| 415 | trackerId: \(trackerId) | ||
| 416 | trackerRid: \(trackerRid) | ||
| 417 | status: \(ticket.status.rawValue) | ||
| 418 | events: \(viewModel.events.count) | ||
| 419 | url: \(SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticket.id)?.absoluteString ?? "unavailable") | ||
| 420 | """ | ||
| 421 | ) | ||
| 422 | |||
| 423 | if let rawTicketResponse = viewModel.rawTicketResponse { | ||
| 424 | DebugTextBlock(title: "Raw Response", content: rawTicketResponse) | ||
| 425 | } | ||
| 426 | } | ||
| 427 | .padding(.horizontal) | ||
| 428 | .padding(.bottom, 16) | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 366 | private func normalizedUsername(_ value: String) -> String { | 432 | private func normalizedUsername(_ value: String) -> String { |
| 367 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | 433 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| 368 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | 434 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed |
| @@ -381,6 +447,14 @@ struct TicketDetailView: View { | |||
| 381 | } | 447 | } |
| 382 | } | 448 | } |
| 383 | } | 449 | } |
| 450 | |||
| 451 | private func reloadDetail(_ viewModel: TicketDetailViewModel) async { | ||
| 452 | if appState.isDebugModeEnabled { | ||
| 453 | await viewModel.loadTicketWithDebugCapture() | ||
| 454 | } else { | ||
| 455 | await viewModel.loadTicket() | ||
| 456 | } | ||
| 457 | } | ||
| 384 | } | 458 | } |
| 385 | 459 | ||
| 386 | // MARK: - Self-Sizing Markdown Web View | 460 | // MARK: - Self-Sizing Markdown Web View |
Hutch/Views/Tickets/TicketDetailViewModel.swift +60 −6
| @@ -97,6 +97,9 @@ private struct LabelsPage: Decodable, Sendable { | |||
| 97 | @Observable | 97 | @Observable |
| 98 | @MainActor | 98 | @MainActor |
| 99 | final class TicketDetailViewModel { | 99 | final class TicketDetailViewModel { |
| 100 | private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String { | ||
| 101 | "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)" | ||
| 102 | } | ||
| 100 | 103 | ||
| 101 | let ownerUsername: String | 104 | let ownerUsername: String |
| 102 | let trackerName: String | 105 | let trackerName: String |
| @@ -110,6 +113,7 @@ final class TicketDetailViewModel { | |||
| 110 | private(set) var isSubmitting = false | 113 | private(set) var isSubmitting = false |
| 111 | private(set) var isPerformingAction = false | 114 | private(set) var isPerformingAction = false |
| 112 | private(set) var trackerLabels: [TicketLabel] = [] | 115 | private(set) var trackerLabels: [TicketLabel] = [] |
| 116 | private(set) var rawTicketResponse: String? | ||
| 113 | var commentText = "" | 117 | var commentText = "" |
| 114 | var error: String? | 118 | var error: String? |
| 115 | 119 | ||
| @@ -284,6 +288,7 @@ final class TicketDetailViewModel { | |||
| 284 | guard !isLoading else { return } | 288 | guard !isLoading else { return } |
| 285 | isLoading = true | 289 | isLoading = true |
| 286 | error = nil | 290 | error = nil |
| 291 | rawTicketResponse = nil | ||
| 287 | 292 | ||
| 288 | do { | 293 | do { |
| 289 | let result = try await client.execute( | 294 | let result = try await client.execute( |
| @@ -317,6 +322,47 @@ final class TicketDetailViewModel { | |||
| 317 | isLoading = false | 322 | isLoading = false |
| 318 | } | 323 | } |
| 319 | 324 | ||
| 325 | func loadTicketWithDebugCapture() async { | ||
| 326 | guard !isLoading else { return } | ||
| 327 | isLoading = true | ||
| 328 | error = nil | ||
| 329 | |||
| 330 | do { | ||
| 331 | let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId) | ||
| 332 | let result = try await client.executeAndCache( | ||
| 333 | service: .todo, | ||
| 334 | query: Self.detailQuery, | ||
| 335 | variables: [ | ||
| 336 | "rid": trackerRid, | ||
| 337 | "ticketId": ticketId | ||
| 338 | ], | ||
| 339 | responseType: TicketDetailResponse.self, | ||
| 340 | cacheKey: cacheKey | ||
| 341 | ) | ||
| 342 | rawTicketResponse = client.responseCache.get(forKey: cacheKey) | ||
| 343 | .flatMap { String(data: $0, encoding: .utf8) } | ||
| 344 | let payload = result.tracker.ticket | ||
| 345 | ticket = TicketDetail( | ||
| 346 | id: payload.id, | ||
| 347 | created: payload.created, | ||
| 348 | updated: payload.updated, | ||
| 349 | title: payload.title, | ||
| 350 | description: payload.description, | ||
| 351 | status: payload.status, | ||
| 352 | resolution: payload.resolution, | ||
| 353 | authenticity: payload.authenticity, | ||
| 354 | submitter: payload.submitter, | ||
| 355 | assignees: payload.assignees, | ||
| 356 | labels: payload.labels | ||
| 357 | ) | ||
| 358 | events = payload.events.results.sorted(by: Self.timelineOrder) | ||
| 359 | } catch { | ||
| 360 | self.error = error.userFacingMessage | ||
| 361 | } | ||
| 362 | |||
| 363 | isLoading = false | ||
| 364 | } | ||
| 365 | |||
| 320 | func submitComment() async { | 366 | func submitComment() async { |
| 321 | let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) | 367 | let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) |
| 322 | guard !text.isEmpty, !isSubmitting else { return } | 368 | guard !text.isEmpty, !isSubmitting else { return } |
| @@ -378,7 +424,7 @@ final class TicketDetailViewModel { | |||
| 378 | responseType: UpdateStatusResponse.self | 424 | responseType: UpdateStatusResponse.self |
| 379 | ) | 425 | ) |
| 380 | // Re-fetch the ticket to get updated status/resolution | 426 | // Re-fetch the ticket to get updated status/resolution |
| 381 | await loadTicket() | 427 | await reloadTicketPreservingDebugState() |
| 382 | } catch { | 428 | } catch { |
| 383 | self.error = error.userFacingMessage | 429 | self.error = error.userFacingMessage |
| 384 | } | 430 | } |
| @@ -412,7 +458,7 @@ final class TicketDetailViewModel { | |||
| 412 | responseType: AssignUserResponse.self | 458 | responseType: AssignUserResponse.self |
| 413 | ) | 459 | ) |
| 414 | // Reload to reflect the change | 460 | // Reload to reflect the change |
| 415 | await loadTicket() | 461 | await reloadTicketPreservingDebugState() |
| 416 | } catch { | 462 | } catch { |
| 417 | self.error = error.userFacingMessage | 463 | self.error = error.userFacingMessage |
| 418 | } | 464 | } |
| @@ -457,7 +503,7 @@ final class TicketDetailViewModel { | |||
| 457 | ], | 503 | ], |
| 458 | responseType: AssignUserResponse.self | 504 | responseType: AssignUserResponse.self |
| 459 | ) | 505 | ) |
| 460 | await loadTicket() | 506 | await reloadTicketPreservingDebugState() |
| 461 | } catch { | 507 | } catch { |
| 462 | ticket = TicketDetail( | 508 | ticket = TicketDetail( |
| 463 | id: currentTicket.id, | 509 | id: currentTicket.id, |
| @@ -505,7 +551,7 @@ final class TicketDetailViewModel { | |||
| 505 | responseType: UnassignUserResponse.self | 551 | responseType: UnassignUserResponse.self |
| 506 | ) | 552 | ) |
| 507 | // Reload to reflect the change | 553 | // Reload to reflect the change |
| 508 | await loadTicket() | 554 | await reloadTicketPreservingDebugState() |
| 509 | } catch { | 555 | } catch { |
| 510 | self.error = error.userFacingMessage | 556 | self.error = error.userFacingMessage |
| 511 | } | 557 | } |
| @@ -529,7 +575,7 @@ final class TicketDetailViewModel { | |||
| 529 | ], | 575 | ], |
| 530 | responseType: LabelTicketResponse.self | 576 | responseType: LabelTicketResponse.self |
| 531 | ) | 577 | ) |
| 532 | await loadTicket() | 578 | await reloadTicketPreservingDebugState() |
| 533 | } catch { | 579 | } catch { |
| 534 | self.error = error.userFacingMessage | 580 | self.error = error.userFacingMessage |
| 535 | } | 581 | } |
| @@ -553,7 +599,7 @@ final class TicketDetailViewModel { | |||
| 553 | ], | 599 | ], |
| 554 | responseType: UnlabelTicketResponse.self | 600 | responseType: UnlabelTicketResponse.self |
| 555 | ) | 601 | ) |
| 556 | await loadTicket() | 602 | await reloadTicketPreservingDebugState() |
| 557 | } catch { | 603 | } catch { |
| 558 | self.error = error.userFacingMessage | 604 | self.error = error.userFacingMessage |
| 559 | } | 605 | } |
| @@ -600,6 +646,14 @@ final class TicketDetailViewModel { | |||
| 600 | isPerformingAction = false | 646 | isPerformingAction = false |
| 601 | } | 647 | } |
| 602 | 648 | ||
| 649 | private func reloadTicketPreservingDebugState() async { | ||
| 650 | if rawTicketResponse != nil { | ||
| 651 | await loadTicketWithDebugCapture() | ||
| 652 | } else { | ||
| 653 | await loadTicket() | ||
| 654 | } | ||
| 655 | } | ||
| 656 | |||
| 603 | static func matchesAssignee(_ entity: Entity, user: User) -> Bool { | 657 | static func matchesAssignee(_ entity: Entity, user: User) -> Bool { |
| 604 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) | 658 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) |
| 605 | let userCanonical = normalizedCanonicalName(user.canonicalName) | 659 | let userCanonical = normalizedCanonicalName(user.canonicalName) |
Hutch/Views/Tickets/TicketListView.swift +57 −3
| @@ -7,6 +7,7 @@ struct TicketListView: View { | |||
| 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | @Environment(AppState.self) private var appState | 8 | @Environment(AppState.self) private var appState |
| 9 | @Environment(\.dismiss) private var dismiss | 9 | @Environment(\.dismiss) private var dismiss |
| 10 | @Environment(\.openURL) private var openURL | ||
| 10 | @State private var tracker: TrackerSummary | 11 | @State private var tracker: TrackerSummary |
| 11 | @State private var viewModel: TicketListViewModel? | 12 | @State private var viewModel: TicketListViewModel? |
| 12 | @State private var trackerManagementViewModel: TrackerManagementViewModel? | 13 | @State private var trackerManagementViewModel: TrackerManagementViewModel? |
| @@ -86,9 +87,7 @@ struct TicketListView: View { | |||
| 86 | viewModel?.setSelectionMode(true) | 87 | viewModel?.setSelectionMode(true) |
| 87 | } | 88 | } |
| 88 | 89 | ||
| 89 | if isOwnedByCurrentUser { | 90 | trackerActionsMenu |
| 90 | trackerActionsMenu | ||
| 91 | } | ||
| 92 | } | 91 | } |
| 93 | } | 92 | } |
| 94 | } | 93 | } |
| @@ -296,6 +295,27 @@ struct TicketListView: View { | |||
| 296 | } label: { | 295 | } label: { |
| 297 | TicketRowView(ticket: ticket) | 296 | TicketRowView(ticket: ticket) |
| 298 | } | 297 | } |
| 298 | .contextMenu { | ||
| 299 | if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) { | ||
| 300 | Button { | ||
| 301 | openURL(url) | ||
| 302 | } label: { | ||
| 303 | Label("Open in Browser", systemImage: "safari") | ||
| 304 | } | ||
| 305 | |||
| 306 | Button { | ||
| 307 | appState.copyToPasteboard(url.absoluteString, label: "ticket URL") | ||
| 308 | } label: { | ||
| 309 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 310 | } | ||
| 311 | } | ||
| 312 | |||
| 313 | Button { | ||
| 314 | appState.copyToPasteboard(String(ticket.id), label: "ticket ID") | ||
| 315 | } label: { | ||
| 316 | Label("Copy Ticket ID", systemImage: "number") | ||
| 317 | } | ||
| 318 | } | ||
| 299 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | 319 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 300 | if swipeActionsEnabled { | 320 | if swipeActionsEnabled { |
| 301 | ticketAssignSwipeAction(ticket, viewModel: viewModel) | 321 | ticketAssignSwipeAction(ticket, viewModel: viewModel) |
| @@ -409,6 +429,35 @@ struct TicketListView: View { | |||
| 409 | 429 | ||
| 410 | private var trackerActionsMenu: some View { | 430 | private var trackerActionsMenu: some View { |
| 411 | Menu { | 431 | Menu { |
| 432 | if let trackerURL = SRHTWebURL.tracker(tracker) { | ||
| 433 | Button { | ||
| 434 | openURL(trackerURL) | ||
| 435 | } label: { | ||
| 436 | Label("Open in Browser", systemImage: "safari") | ||
| 437 | } | ||
| 438 | |||
| 439 | Button { | ||
| 440 | appState.copyToPasteboard(trackerURL.absoluteString, label: "tracker URL") | ||
| 441 | } label: { | ||
| 442 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 443 | } | ||
| 444 | } | ||
| 445 | |||
| 446 | Button { | ||
| 447 | appState.copyToPasteboard(String(tracker.id), label: "tracker ID") | ||
| 448 | } label: { | ||
| 449 | Label("Copy Tracker ID", systemImage: "number") | ||
| 450 | } | ||
| 451 | |||
| 452 | Button { | ||
| 453 | appState.copyToPasteboard(tracker.rid, label: "tracker RID") | ||
| 454 | } label: { | ||
| 455 | Label("Copy RID", systemImage: "number") | ||
| 456 | } | ||
| 457 | |||
| 458 | if isOwnedByCurrentUser { | ||
| 459 | Divider() | ||
| 460 | |||
| 412 | Button { | 461 | Button { |
| 413 | showTrackerEditor = true | 462 | showTrackerEditor = true |
| 414 | } label: { | 463 | } label: { |
| @@ -432,12 +481,17 @@ struct TicketListView: View { | |||
| 432 | } label: { | 481 | } label: { |
| 433 | Label("Delete Tracker", systemImage: "trash") | 482 | Label("Delete Tracker", systemImage: "trash") |
| 434 | } | 483 | } |
| 484 | } | ||
| 435 | } label: { | 485 | } label: { |
| 436 | Image(systemName: "ellipsis.circle") | 486 | Image(systemName: "ellipsis.circle") |
| 437 | } | 487 | } |
| 438 | .accessibilityLabel("Tracker actions") | 488 | .accessibilityLabel("Tracker actions") |
| 439 | } | 489 | } |
| 440 | 490 | ||
| 491 | private func ownerUsername(for tracker: TrackerSummary) -> String { | ||
| 492 | tracker.owner.canonicalName.srhtUsername | ||
| 493 | } | ||
| 494 | |||
| 441 | @ViewBuilder | 495 | @ViewBuilder |
| 442 | private func ticketAssignSwipeAction( | 496 | private func ticketAssignSwipeAction( |
| 443 | _ ticket: TicketSummary, | 497 | _ ticket: TicketSummary, |
Hutch/Views/Tickets/TrackerListView.swift +30
| @@ -224,6 +224,9 @@ struct TrackerListView: View { | |||
| 224 | // MARK: - Tracker Row | 224 | // MARK: - Tracker Row |
| 225 | 225 | ||
| 226 | private struct TrackerRowView: View { | 226 | private struct TrackerRowView: View { |
| 227 | @Environment(AppState.self) private var appState | ||
| 228 | @Environment(\.openURL) private var openURL | ||
| 229 | |||
| 227 | let tracker: TrackerSummary | 230 | let tracker: TrackerSummary |
| 228 | 231 | ||
| 229 | var body: some View { | 232 | var body: some View { |
| @@ -255,5 +258,32 @@ private struct TrackerRowView: View { | |||
| 255 | .foregroundStyle(.tertiary) | 258 | .foregroundStyle(.tertiary) |
| 256 | } | 259 | } |
| 257 | .padding(.vertical, 2) | 260 | .padding(.vertical, 2) |
| 261 | .contextMenu { | ||
| 262 | if let url = SRHTWebURL.tracker(tracker) { | ||
| 263 | Button { | ||
| 264 | openURL(url) | ||
| 265 | } label: { | ||
| 266 | Label("Open in Browser", systemImage: "safari") | ||
| 267 | } | ||
| 268 | |||
| 269 | Button { | ||
| 270 | appState.copyToPasteboard(url.absoluteString, label: "tracker URL") | ||
| 271 | } label: { | ||
| 272 | Label("Copy URL", systemImage: "doc.on.doc") | ||
| 273 | } | ||
| 274 | } | ||
| 275 | |||
| 276 | Button { | ||
| 277 | appState.copyToPasteboard(String(tracker.id), label: "tracker ID") | ||
| 278 | } label: { | ||
| 279 | Label("Copy Tracker ID", systemImage: "number") | ||
| 280 | } | ||
| 281 | |||
| 282 | Button { | ||
| 283 | appState.copyToPasteboard(tracker.rid, label: "tracker RID") | ||
| 284 | } label: { | ||
| 285 | Label("Copy RID", systemImage: "number") | ||
| 286 | } | ||
| 287 | } | ||
| 258 | } | 288 | } |
| 259 | } | 289 | } |
HutchTests/SRHTWebURLTests.swift +34
| @@ -3,10 +3,44 @@ import Testing | |||
| 3 | @testable import Hutch | 3 | @testable import Hutch |
| 4 | 4 | ||
| 5 | struct SRHTWebURLTests { | 5 | struct SRHTWebURLTests { |
| 6 | private let repository = RepositorySummary( | ||
| 7 | id: 1, | ||
| 8 | rid: "repo-1", | ||
| 9 | service: .git, | ||
| 10 | name: "hutch", | ||
| 11 | description: nil, | ||
| 12 | visibility: .public, | ||
| 13 | updated: .distantPast, | ||
| 14 | owner: Entity(canonicalName: "~ccleberg"), | ||
| 15 | head: nil | ||
| 16 | ) | ||
| 17 | private let tracker = TrackerSummary( | ||
| 18 | id: 2, | ||
| 19 | rid: "tracker-1", | ||
| 20 | name: "todo", | ||
| 21 | description: nil, | ||
| 22 | visibility: .public, | ||
| 23 | updated: .distantPast, | ||
| 24 | owner: Entity(canonicalName: "~ccleberg") | ||
| 25 | ) | ||
| 6 | 26 | ||
| 7 | @Test | 27 | @Test |
| 8 | func browserOnlyServiceURLsUseCanonicalHosts() { | 28 | func browserOnlyServiceURLsUseCanonicalHosts() { |
| 9 | #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") | 29 | #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") |
| 10 | #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht") | 30 | #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht") |
| 11 | } | 31 | } |
| 32 | |||
| 33 | @Test | ||
| 34 | func repositoryAndCloneURLsUseStableUserScopedPaths() { | ||
| 35 | #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch") | ||
| 36 | #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch") | ||
| 37 | #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch") | ||
| 38 | } | ||
| 39 | |||
| 40 | @Test | ||
| 41 | func trackerTicketAndBuildURLsUseStableUserScopedPaths() { | ||
| 42 | #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo") | ||
| 43 | #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42") | ||
| 44 | #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12") | ||
| 45 | } | ||
| 12 | } | 46 | } |
HutchWidgetExtension/ContributionGraphWidget.swift +24 −4
| @@ -154,15 +154,19 @@ private struct ContributionGraphWidgetView: View { | |||
| 154 | 154 | ||
| 155 | private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] { | 155 | private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] { |
| 156 | var baseWeeks = switch entry.state { | 156 | var baseWeeks = switch entry.state { |
| 157 | case .populated, .indexing, .empty: | 157 | case .populated, .indexing: |
| 158 | entry.weeks | 158 | entry.weeks |
| 159 | case .empty: | ||
| 160 | ContributionGraphSampleData.emptyWeeks | ||
| 159 | case .placeholder, .disabled, .unavailable: | 161 | case .placeholder, .disabled, .unavailable: |
| 160 | ContributionGraphSampleData.placeholderWeeks | 162 | ContributionGraphSampleData.placeholderWeeks |
| 161 | } | 163 | } |
| 162 | 164 | ||
| 163 | // Drop any trailing week where every day has zero contributions | 165 | if entry.state != .empty { |
| 164 | while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) { | 166 | // Drop any trailing week where every day has zero contributions. |
| 165 | baseWeeks.removeLast() | 167 | while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) { |
| 168 | baseWeeks.removeLast() | ||
| 169 | } | ||
| 166 | } | 170 | } |
| 167 | 171 | ||
| 168 | return Array(baseWeeks.suffix(columnCount)) | 172 | return Array(baseWeeks.suffix(columnCount)) |
| @@ -182,6 +186,11 @@ private struct ContributionGraphGridView: View { | |||
| 182 | RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous) | 186 | RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous) |
| 183 | .fill((day?.intensity ?? .empty).color) | 187 | .fill((day?.intensity ?? .empty).color) |
| 184 | .frame(width: squareSize, height: squareSize) | 188 | .frame(width: squareSize, height: squareSize) |
| 189 | .overlay { | ||
| 190 | let intensity = day?.intensity ?? .empty | ||
| 191 | RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous) | ||
| 192 | .stroke(Color.primary.opacity(intensity == .empty ? 0.08 : 0), lineWidth: 0.5) | ||
| 193 | } | ||
| 185 | } | 194 | } |
| 186 | } | 195 | } |
| 187 | } | 196 | } |
| @@ -420,6 +429,17 @@ private enum ContributionGraphDateParser { | |||
| 420 | } | 429 | } |
| 421 | 430 | ||
| 422 | private enum ContributionGraphSampleData { | 431 | private enum ContributionGraphSampleData { |
| 432 | static let emptyWeeks: [ContributionGraphWeek] = { | ||
| 433 | let startDate = Calendar.contributionCalendar.startOfDay(for: .now) | ||
| 434 | let days = (0..<371).compactMap { offset -> ContributionGraphDay? in | ||
| 435 | guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else { | ||
| 436 | return nil | ||
| 437 | } | ||
| 438 | return ContributionGraphDay(date: date, count: 0, score: 0) | ||
| 439 | } | ||
| 440 | return ContributionGraphLayout.weekColumns(from: days) | ||
| 441 | }() | ||
| 442 | |||
| 423 | static let placeholderWeeks: [ContributionGraphWeek] = { | 443 | static let placeholderWeeks: [ContributionGraphWeek] = { |
| 424 | let startDate = Calendar.contributionCalendar.startOfDay(for: .now) | 444 | let startDate = Calendar.contributionCalendar.startOfDay(for: .now) |
| 425 | let days = (0..<150).compactMap { offset -> ContributionGraphDay? in | 445 | let days = (0..<150).compactMap { offset -> ContributionGraphDay? in |