Commit 707a3efe1e
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 49; | |
| 518 | CURRENT_PROJECT_VERSION = 60; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.20.0; | |
| 535 | MARKETING_VERSION = 2.21.1; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 49; | |
| 555 | CURRENT_PROJECT_VERSION = 60; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.20.0; | |
| 572 | MARKETING_VERSION = 2.21.1; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 49; | |
| 635 | CURRENT_PROJECT_VERSION = 60; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.20.0; | |
| 645 | MARKETING_VERSION = 2.21.1; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 49; | |
| 664 | CURRENT_PROJECT_VERSION = 60; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.20.0; | |
| 674 | MARKETING_VERSION = 2.21.1; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +33
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import SwiftUI |
| 3 | import UIKit | |
| 3 | 4 | import WebKit |
| 4 | 5 | |
| 5 | 6 | /// Central application state shared across the view hierarchy. |
| @@ -63,6 +64,13 @@ final class AppState { | ||
| 63 | 64 | private(set) var systemStatusRepository: SystemStatusRepository |
| 64 | 65 | private var activeSession: AccountSession? |
| 65 | 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 | 75 | var accountDefaults: UserDefaults { |
| 68 | 76 | activeSession?.defaults ?? .standard |
| @@ -210,6 +218,7 @@ final class AppState { | ||
| 210 | 218 | clearAllAccountArtifacts() |
| 211 | 219 | authPhase = .unauthenticated |
| 212 | 220 | selectedTab = .home |
| 221 | dismissCopyConfirmation() | |
| 213 | 222 | } |
| 214 | 223 | |
| 215 | 224 | func resetAppData() async { |
| @@ -230,6 +239,19 @@ final class AppState { | ||
| 230 | 239 | |
| 231 | 240 | authPhase = .unauthenticated |
| 232 | 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 | 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 | 563 | enum AppStateError: LocalizedError { |
Hutch/App/AppStorageKeys.swift +1
| @@ -19,4 +19,5 @@ enum AppStorageKeys { | ||
| 19 | 19 | static let ticketSavedFilters = "ticketSavedFilters" |
| 20 | 20 | static let appTheme = "appTheme" |
| 21 | 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 | 123 | get: { appState.selectedTab }, |
| 124 | 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 | 133 | .overlay { |
| 127 | 134 | if isResolvingDeepLink { |
| 128 | 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 | 25 | static func commit(repository: RepositorySummary, commitId: String) -> URL? { |
| 16 | 26 | userScopedURL( |
| 17 | 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 | 80 | static func projectSource(_ source: Project.SourceRepo) -> URL? { |
| 67 | 81 | userScopedURL( |
| 68 | 82 | host: "\(source.repoType.service.rawValue).sr.ht", |
Hutch/Views/Builds/BuildDetailView.swift +71 −5
| @@ -27,7 +27,42 @@ struct BuildDetailView: View { | ||
| 27 | 27 | .navigationTitle("Job #\(jobId)") |
| 28 | 28 | .navigationBarTitleDisplayMode(.inline) |
| 29 | 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 | 66 | SRHTShareButton( |
| 32 | 67 | url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, |
| 33 | 68 | target: .build |
| @@ -102,7 +137,11 @@ struct BuildDetailView: View { | ||
| 102 | 137 | if viewModel == nil { |
| 103 | 138 | let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) |
| 104 | 139 | viewModel = vm |
| 105 | await vm.loadJob() | |
| 140 | if appState.isDebugModeEnabled { | |
| 141 | await vm.loadJobWithDebugCapture() | |
| 142 | } else { | |
| 143 | await vm.loadJob() | |
| 144 | } | |
| 106 | 145 | vm.startAutoRefresh() |
| 107 | 146 | } |
| 108 | 147 | } |
| @@ -123,11 +162,10 @@ struct BuildDetailView: View { | ||
| 123 | 162 | SRHTErrorStateView( |
| 124 | 163 | title: "Couldn't Load Build", |
| 125 | 164 | message: error, |
| 126 | retryAction: { await viewModel.loadJob() } | |
| 165 | retryAction: { await reloadDetail(viewModel) } | |
| 127 | 166 | ) |
| 128 | 167 | } else if let job = viewModel.job { |
| 129 | 168 | List { |
| 130 | // Status & metadata | |
| 131 | 169 | Section("Details") { |
| 132 | 170 | HStack { |
| 133 | 171 | Text("Status") |
| @@ -160,6 +198,26 @@ struct BuildDetailView: View { | ||
| 160 | 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 | 221 | if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) { |
| 164 | 222 | Section("Source") { |
| 165 | 223 | Button { |
| @@ -274,7 +332,7 @@ struct BuildDetailView: View { | ||
| 274 | 332 | } |
| 275 | 333 | } |
| 276 | 334 | .refreshable { |
| 277 | await viewModel.loadJob() | |
| 335 | await reloadDetail(viewModel) | |
| 278 | 336 | } |
| 279 | 337 | .srhtErrorBanner(error: Binding( |
| 280 | 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 | 372 | private struct BuildArtifactRow: View { |
Hutch/Views/Builds/BuildDetailViewModel.swift +47 −2
| @@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable { | ||
| 28 | 28 | @MainActor |
| 29 | 29 | final class BuildDetailViewModel { |
| 30 | 30 | private static let autoRefreshInterval: Duration = .seconds(5) |
| 31 | private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" } | |
| 31 | 32 | |
| 32 | 33 | let jobId: Int |
| 33 | 34 | private let client: SRHTClient |
| @@ -44,6 +45,7 @@ final class BuildDetailViewModel { | ||
| 44 | 45 | private(set) var isCancelling = false |
| 45 | 46 | private(set) var isRebuilding = false |
| 46 | 47 | private(set) var isSubmittingEditedBuild = false |
| 48 | private(set) var rawJobResponse: String? | |
| 47 | 49 | var error: String? |
| 48 | 50 | /// Transient error shown for action failures (cancel, rebuild, submit). |
| 49 | 51 | /// Separate from `error` so auto-refresh doesn't immediately clear it. |
| @@ -123,6 +125,7 @@ final class BuildDetailViewModel { | ||
| 123 | 125 | guard !isLoading else { return } |
| 124 | 126 | isLoading = true |
| 125 | 127 | error = nil |
| 128 | rawJobResponse = nil | |
| 126 | 129 | |
| 127 | 130 | do { |
| 128 | 131 | let result = try await client.execute( |
| @@ -149,6 +152,40 @@ final class BuildDetailViewModel { | ||
| 149 | 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 | 189 | func loadTaskLog(task: BuildTask) async { |
| 153 | 190 | let cacheKey = task.logCacheKey |
| 154 | 191 | let jobIsTerminal = job?.status.isTerminal ?? false |
| @@ -250,7 +287,7 @@ final class BuildDetailViewModel { | ||
| 250 | 287 | variables: ["id": jobId], |
| 251 | 288 | responseType: CancelResponse.self |
| 252 | 289 | ) |
| 253 | await loadJob() | |
| 290 | await reloadJobPreservingDebugState() | |
| 254 | 291 | } catch { |
| 255 | 292 | // Revert optimistic update on failure. |
| 256 | 293 | self.job = originalJob |
| @@ -372,6 +409,14 @@ final class BuildDetailViewModel { | ||
| 372 | 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 | 420 | private var shouldAutoRefresh: Bool { |
| 376 | 421 | guard let job else { return true } |
| 377 | 422 | return !job.status.isTerminal |
| @@ -385,7 +430,7 @@ final class BuildDetailViewModel { | ||
| 385 | 430 | return |
| 386 | 431 | } |
| 387 | 432 | |
| 388 | await loadJob() | |
| 433 | await reloadJobPreservingDebugState() | |
| 389 | 434 | await loadBuildLog() |
| 390 | 435 | } |
| 391 | 436 | } |
Hutch/Views/Builds/BuildListView.swift +23
| @@ -136,6 +136,29 @@ struct BuildListView: View { | ||
| 136 | 136 | NavigationLink(value: job) { |
| 137 | 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 | 162 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 140 | 163 | if swipeActionsEnabled, job.status.isCancellable { |
| 141 | 164 | Button { |
Hutch/Views/Builds/BuildTaskLogView.swift +3 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import UIKit | |
| 3 | 2 | |
| 4 | 3 | struct BuildTaskLogView: View { |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | ||
| 5 | 6 | let taskName: String |
| 6 | 7 | let viewModel: BuildDetailViewModel |
| 7 | 8 | |
| @@ -32,7 +33,7 @@ struct BuildTaskLogView: View { | ||
| 32 | 33 | } |
| 33 | 34 | ToolbarItem(placement: .topBarTrailing) { |
| 34 | 35 | Button { |
| 35 | UIPasteboard.general.string = logText | |
| 36 | appState.copyToPasteboard(logText, label: "build log") | |
| 36 | 37 | } label: { |
| 37 | 38 | Image(systemName: "doc.on.doc") |
| 38 | 39 | } |
Hutch/Views/Home/HomeView.swift −9
| @@ -74,15 +74,6 @@ struct HomeView: View { | ||
| 74 | 74 | viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && |
| 75 | 75 | viewModel.unreadInboxThreads.isEmpty { |
| 76 | 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 | 79 | .refreshable { |
Hutch/Views/Repositories/CommitDetailView.swift +34 −2
| @@ -5,6 +5,7 @@ struct CommitDetailView: View { | ||
| 5 | 5 | let repository: RepositorySummary |
| 6 | 6 | |
| 7 | 7 | @Environment(AppState.self) private var appState |
| 8 | @Environment(\.openURL) private var openURL | |
| 8 | 9 | @State private var viewModel: CommitDetailViewModel? |
| 9 | 10 | |
| 10 | 11 | var body: some View { |
| @@ -18,7 +19,38 @@ struct CommitDetailView: View { | ||
| 18 | 19 | .navigationTitle(commitSummary.shortId) |
| 19 | 20 | .navigationBarTitleDisplayMode(.inline) |
| 20 | 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 | 54 | SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { |
| 23 | 55 | Image(systemName: "square.and.arrow.up") |
| 24 | 56 | } |
| @@ -109,7 +141,7 @@ struct CommitDetailView: View { | ||
| 109 | 141 | VStack(alignment: .leading, spacing: 8) { |
| 110 | 142 | // Full hash — tappable to copy |
| 111 | 143 | Button { |
| 112 | UIPasteboard.general.string = commit.id | |
| 144 | appState.copyToPasteboard(commit.id, label: "commit SHA") | |
| 113 | 145 | } label: { |
| 114 | 146 | HStack(spacing: 4) { |
| 115 | 147 | Text(commit.id) |
Hutch/Views/Repositories/CommitLogView.swift +1 −1
| @@ -7,7 +7,7 @@ struct CommitLogView: View { | ||
| 7 | 7 | List { |
| 8 | 8 | ForEach(viewModel.commits) { commit in |
| 9 | 9 | NavigationLink(value: commit) { |
| 10 | CommitRowView(commit: commit) | |
| 10 | CommitRowView(commit: commit, repository: viewModel.repository) | |
| 11 | 11 | } |
| 12 | 12 | .task { |
| 13 | 13 | await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) |
Hutch/Views/Repositories/CommitRowView.swift +14 −3
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import UIKit | |
| 3 | 2 | |
| 4 | 3 | struct CommitRowView: View { |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @Environment(\.openURL) private var openURL | |
| 6 | ||
| 5 | 7 | let commit: CommitSummary |
| 8 | let repository: RepositorySummary | |
| 6 | 9 | |
| 7 | 10 | var body: some View { |
| 8 | 11 | VStack(alignment: .leading, spacing: 4) { |
| @@ -28,14 +31,22 @@ struct CommitRowView: View { | ||
| 28 | 31 | } |
| 29 | 32 | .padding(.vertical, 2) |
| 30 | 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 | 42 | Button { |
| 32 | UIPasteboard.general.string = commit.id | |
| 43 | appState.copyToPasteboard(commit.id, label: "commit SHA") | |
| 33 | 44 | } label: { |
| 34 | 45 | Label("Copy Full SHA", systemImage: "doc.on.doc") |
| 35 | 46 | } |
| 36 | 47 | |
| 37 | 48 | Button { |
| 38 | UIPasteboard.general.string = commit.shortId | |
| 49 | appState.copyToPasteboard(commit.shortId, label: "short commit SHA") | |
| 39 | 50 | } label: { |
| 40 | 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 | 2 | import WebKit |
| 3 | 3 | |
| 4 | 4 | struct ReadmeView: View { |
| 5 | @Environment(AppState.self) private var appState | |
| 5 | 6 | let viewModel: RepositoryDetailViewModel |
| 6 | 7 | |
| 7 | 8 | @Environment(\.colorScheme) private var colorScheme |
| @@ -15,6 +16,9 @@ struct ReadmeView: View { | ||
| 15 | 16 | repositoryDetailsSection |
| 16 | 17 | latestChangeSection |
| 17 | 18 | readmeSection |
| 19 | if appState.isDebugModeEnabled { | |
| 20 | debugSection | |
| 21 | } | |
| 18 | 22 | } |
| 19 | 23 | .padding() |
| 20 | 24 | } |
| @@ -150,6 +154,25 @@ struct ReadmeView: View { | ||
| 150 | 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 | 178 | enum RenderedMarkupContent: Sendable { |
Hutch/Views/Repositories/RepositoryDetailView.swift +61 −14
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct RepositoryDetailView: View { |
| 4 | @Environment(\.openURL) private var openURL | |
| 5 | ||
| 4 | 6 | let onRepositoryUpdated: ((RepositorySummary) -> Void)? |
| 5 | 7 | var onDeleted: (() -> Void)? |
| 6 | 8 | |
| @@ -42,23 +44,11 @@ struct RepositoryDetailView: View { | ||
| 42 | 44 | .navigationBarTitleDisplayMode(.inline) |
| 43 | 45 | .toolbar { |
| 44 | 46 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 47 | repositoryActionsMenu | |
| 48 | ||
| 45 | 49 | SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) { |
| 46 | 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 | 54 | .sheet(isPresented: $showSettings) { |
| @@ -137,4 +127,61 @@ struct RepositoryDetailView: View { | ||
| 137 | 127 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| 138 | 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 | 1 | import SwiftUI |
| 2 | import UIKit | |
| 3 | 2 | |
| 4 | 3 | struct RepositoryRowView: View { |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @Environment(\.openURL) private var openURL | |
| 6 | ||
| 5 | 7 | let repository: RepositorySummary |
| 6 | 8 | let buildStatus: RepositoryBuildStatus |
| 7 | 9 | |
| @@ -56,34 +58,45 @@ struct RepositoryRowView: View { | ||
| 56 | 58 | } |
| 57 | 59 | .padding(.vertical, 2) |
| 58 | 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 | 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 | 81 | } label: { |
| 62 | 82 | Label("Copy HTTPS URL", systemImage: "doc.on.doc") |
| 63 | 83 | } |
| 64 | 84 | |
| 65 | 85 | Button { |
| 66 | UIPasteboard.general.string = sshCloneURL(for: repository) | |
| 86 | appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL") | |
| 67 | 87 | } label: { |
| 68 | 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 | 100 | private struct RepositoryBuildStatusIndicator: View { |
| 88 | 101 | let status: RepositoryBuildStatus |
| 89 | 102 | |
Hutch/Views/Settings/SettingsView.swift +39 −2
| @@ -7,6 +7,7 @@ struct SettingsView: View { | ||
| 7 | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | 8 | @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true |
| 9 | 9 | @State private var pendingDestructiveAction: SettingsDestructiveAction? |
| 10 | @State private var showAccountSwitcher = false | |
| 10 | 11 | |
| 11 | 12 | var body: some View { |
| 12 | 13 | Form { |
| @@ -17,6 +18,9 @@ struct SettingsView: View { | ||
| 17 | 18 | } |
| 18 | 19 | .themedList() |
| 19 | 20 | .navigationTitle("Settings") |
| 21 | .sheet(isPresented: $showAccountSwitcher) { | |
| 22 | AccountSwitcherView() | |
| 23 | } | |
| 20 | 24 | .alert( |
| 21 | 25 | pendingDestructiveAction?.title ?? "", |
| 22 | 26 | isPresented: Binding( |
| @@ -98,8 +102,8 @@ struct SettingsView: View { | ||
| 98 | 102 | } |
| 99 | 103 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 100 | 104 | |
| 101 | NavigationLink { | |
| 102 | AccountSwitcherView() | |
| 105 | Button { | |
| 106 | showAccountSwitcher = true | |
| 103 | 107 | } label: { |
| 104 | 108 | Label("Manage Accounts", systemImage: "person.2") |
| 105 | 109 | } |
| @@ -176,6 +180,7 @@ private enum SettingsDestructiveAction { | ||
| 176 | 180 | } |
| 177 | 181 | |
| 178 | 182 | private struct AboutView: View { |
| 183 | @Environment(AppState.self) private var appState | |
| 179 | 184 | private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String |
| 180 | 185 | ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String |
| 181 | 186 | ?? "Hutch" |
| @@ -183,6 +188,17 @@ private struct AboutView: View { | ||
| 183 | 188 | ?? "Unknown" |
| 184 | 189 | private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String |
| 185 | 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 | 203 | var body: some View { |
| 188 | 204 | Form { |
| @@ -197,7 +213,15 @@ private struct AboutView: View { | ||
| 197 | 213 | .padding(.vertical, 4) |
| 198 | 214 | |
| 199 | 215 | LabeledContent("Version", value: version) |
| 216 | .onTapGesture { | |
| 217 | developerRevealCount = min(developerRevealCount + 1, 5) | |
| 218 | } | |
| 200 | 219 | LabeledContent("Build", value: build) |
| 220 | .onTapGesture { | |
| 221 | developerRevealCount = min(developerRevealCount + 1, 5) | |
| 222 | } | |
| 223 | } footer: { | |
| 224 | Text(developerRevealFooterText) | |
| 201 | 225 | } |
| 202 | 226 | |
| 203 | 227 | Section("Links") { |
| @@ -233,6 +257,19 @@ private struct AboutView: View { | ||
| 233 | 257 | .font(.subheadline) |
| 234 | 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 | 274 | .themedList() |
| 238 | 275 | .navigationTitle("About") |
Hutch/Views/Tickets/TicketDetailView.swift +79 −5
| @@ -10,6 +10,7 @@ struct TicketDetailView: View { | ||
| 10 | 10 | |
| 11 | 11 | @Environment(AppState.self) private var appState |
| 12 | 12 | @Environment(\.colorScheme) private var colorScheme |
| 13 | @Environment(\.openURL) private var openURL | |
| 13 | 14 | @State private var viewModel: TicketDetailViewModel? |
| 14 | 15 | |
| 15 | 16 | // Sheet state |
| @@ -47,7 +48,7 @@ struct TicketDetailView: View { | ||
| 47 | 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 | 52 | actionsMenu(viewModel) |
| 52 | 53 | } |
| 53 | 54 | } |
| @@ -63,7 +64,7 @@ struct TicketDetailView: View { | ||
| 63 | 64 | client: appState.client |
| 64 | 65 | ) |
| 65 | 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 | 74 | @ViewBuilder |
| 74 | 75 | private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { |
| 75 | 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 | 106 | if let ticket = viewModel.ticket { |
| 77 | 107 | if ticket.status == .resolved { |
| 78 | 108 | Button { |
| @@ -103,6 +133,7 @@ struct TicketDetailView: View { | ||
| 103 | 133 | } label: { |
| 104 | 134 | SwiftUI.Label("Manage Labels", systemImage: "tag") |
| 105 | 135 | } |
| 136 | } | |
| 106 | 137 | } label: { |
| 107 | 138 | Image(systemName: "ellipsis.circle") |
| 108 | 139 | } |
| @@ -133,7 +164,7 @@ struct TicketDetailView: View { | ||
| 133 | 164 | SRHTErrorStateView( |
| 134 | 165 | title: "Couldn't Load Ticket", |
| 135 | 166 | message: error, |
| 136 | retryAction: { await viewModel.loadTicket() } | |
| 167 | retryAction: { await reloadDetail(viewModel) } | |
| 137 | 168 | ) |
| 138 | 169 | } else if let ticket = viewModel.ticket { |
| 139 | 170 | ScrollView { |
| @@ -179,13 +210,19 @@ struct TicketDetailView: View { | ||
| 179 | 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 | 220 | commentInput(viewModel) |
| 184 | 221 | } |
| 185 | 222 | } |
| 186 | 223 | .srhtErrorBanner(error: $vm.error) |
| 187 | 224 | .refreshable { |
| 188 | await viewModel.loadTicket() | |
| 225 | await reloadDetail(viewModel) | |
| 189 | 226 | } |
| 190 | 227 | } |
| 191 | 228 | } |
| @@ -363,6 +400,35 @@ struct TicketDetailView: View { | ||
| 363 | 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 | 432 | private func normalizedUsername(_ value: String) -> String { |
| 367 | 433 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| 368 | 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 | 460 | // MARK: - Self-Sizing Markdown Web View |
Hutch/Views/Tickets/TicketDetailViewModel.swift +60 −6
| @@ -97,6 +97,9 @@ private struct LabelsPage: Decodable, Sendable { | ||
| 97 | 97 | @Observable |
| 98 | 98 | @MainActor |
| 99 | 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 | 104 | let ownerUsername: String |
| 102 | 105 | let trackerName: String |
| @@ -110,6 +113,7 @@ final class TicketDetailViewModel { | ||
| 110 | 113 | private(set) var isSubmitting = false |
| 111 | 114 | private(set) var isPerformingAction = false |
| 112 | 115 | private(set) var trackerLabels: [TicketLabel] = [] |
| 116 | private(set) var rawTicketResponse: String? | |
| 113 | 117 | var commentText = "" |
| 114 | 118 | var error: String? |
| 115 | 119 | |
| @@ -284,6 +288,7 @@ final class TicketDetailViewModel { | ||
| 284 | 288 | guard !isLoading else { return } |
| 285 | 289 | isLoading = true |
| 286 | 290 | error = nil |
| 291 | rawTicketResponse = nil | |
| 287 | 292 | |
| 288 | 293 | do { |
| 289 | 294 | let result = try await client.execute( |
| @@ -317,6 +322,47 @@ final class TicketDetailViewModel { | ||
| 317 | 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 | 366 | func submitComment() async { |
| 321 | 367 | let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) |
| 322 | 368 | guard !text.isEmpty, !isSubmitting else { return } |
| @@ -378,7 +424,7 @@ final class TicketDetailViewModel { | ||
| 378 | 424 | responseType: UpdateStatusResponse.self |
| 379 | 425 | ) |
| 380 | 426 | // Re-fetch the ticket to get updated status/resolution |
| 381 | await loadTicket() | |
| 427 | await reloadTicketPreservingDebugState() | |
| 382 | 428 | } catch { |
| 383 | 429 | self.error = error.userFacingMessage |
| 384 | 430 | } |
| @@ -412,7 +458,7 @@ final class TicketDetailViewModel { | ||
| 412 | 458 | responseType: AssignUserResponse.self |
| 413 | 459 | ) |
| 414 | 460 | // Reload to reflect the change |
| 415 | await loadTicket() | |
| 461 | await reloadTicketPreservingDebugState() | |
| 416 | 462 | } catch { |
| 417 | 463 | self.error = error.userFacingMessage |
| 418 | 464 | } |
| @@ -457,7 +503,7 @@ final class TicketDetailViewModel { | ||
| 457 | 503 | ], |
| 458 | 504 | responseType: AssignUserResponse.self |
| 459 | 505 | ) |
| 460 | await loadTicket() | |
| 506 | await reloadTicketPreservingDebugState() | |
| 461 | 507 | } catch { |
| 462 | 508 | ticket = TicketDetail( |
| 463 | 509 | id: currentTicket.id, |
| @@ -505,7 +551,7 @@ final class TicketDetailViewModel { | ||
| 505 | 551 | responseType: UnassignUserResponse.self |
| 506 | 552 | ) |
| 507 | 553 | // Reload to reflect the change |
| 508 | await loadTicket() | |
| 554 | await reloadTicketPreservingDebugState() | |
| 509 | 555 | } catch { |
| 510 | 556 | self.error = error.userFacingMessage |
| 511 | 557 | } |
| @@ -529,7 +575,7 @@ final class TicketDetailViewModel { | ||
| 529 | 575 | ], |
| 530 | 576 | responseType: LabelTicketResponse.self |
| 531 | 577 | ) |
| 532 | await loadTicket() | |
| 578 | await reloadTicketPreservingDebugState() | |
| 533 | 579 | } catch { |
| 534 | 580 | self.error = error.userFacingMessage |
| 535 | 581 | } |
| @@ -553,7 +599,7 @@ final class TicketDetailViewModel { | ||
| 553 | 599 | ], |
| 554 | 600 | responseType: UnlabelTicketResponse.self |
| 555 | 601 | ) |
| 556 | await loadTicket() | |
| 602 | await reloadTicketPreservingDebugState() | |
| 557 | 603 | } catch { |
| 558 | 604 | self.error = error.userFacingMessage |
| 559 | 605 | } |
| @@ -600,6 +646,14 @@ final class TicketDetailViewModel { | ||
| 600 | 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 | 657 | static func matchesAssignee(_ entity: Entity, user: User) -> Bool { |
| 604 | 658 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) |
| 605 | 659 | let userCanonical = normalizedCanonicalName(user.canonicalName) |
Hutch/Views/Tickets/TicketListView.swift +57 −3
| @@ -7,6 +7,7 @@ struct TicketListView: View { | ||
| 7 | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true |
| 8 | 8 | @Environment(AppState.self) private var appState |
| 9 | 9 | @Environment(\.dismiss) private var dismiss |
| 10 | @Environment(\.openURL) private var openURL | |
| 10 | 11 | @State private var tracker: TrackerSummary |
| 11 | 12 | @State private var viewModel: TicketListViewModel? |
| 12 | 13 | @State private var trackerManagementViewModel: TrackerManagementViewModel? |
| @@ -86,9 +87,7 @@ struct TicketListView: View { | ||
| 86 | 87 | viewModel?.setSelectionMode(true) |
| 87 | 88 | } |
| 88 | 89 | |
| 89 | if isOwnedByCurrentUser { | |
| 90 | trackerActionsMenu | |
| 91 | } | |
| 90 | trackerActionsMenu | |
| 92 | 91 | } |
| 93 | 92 | } |
| 94 | 93 | } |
| @@ -296,6 +295,27 @@ struct TicketListView: View { | ||
| 296 | 295 | } label: { |
| 297 | 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 | 319 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 300 | 320 | if swipeActionsEnabled { |
| 301 | 321 | ticketAssignSwipeAction(ticket, viewModel: viewModel) |
| @@ -409,6 +429,35 @@ struct TicketListView: View { | ||
| 409 | 429 | |
| 410 | 430 | private var trackerActionsMenu: some View { |
| 411 | 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 | 461 | Button { |
| 413 | 462 | showTrackerEditor = true |
| 414 | 463 | } label: { |
| @@ -432,12 +481,17 @@ struct TicketListView: View { | ||
| 432 | 481 | } label: { |
| 433 | 482 | Label("Delete Tracker", systemImage: "trash") |
| 434 | 483 | } |
| 484 | } | |
| 435 | 485 | } label: { |
| 436 | 486 | Image(systemName: "ellipsis.circle") |
| 437 | 487 | } |
| 438 | 488 | .accessibilityLabel("Tracker actions") |
| 439 | 489 | } |
| 440 | 490 | |
| 491 | private func ownerUsername(for tracker: TrackerSummary) -> String { | |
| 492 | tracker.owner.canonicalName.srhtUsername | |
| 493 | } | |
| 494 | ||
| 441 | 495 | @ViewBuilder |
| 442 | 496 | private func ticketAssignSwipeAction( |
| 443 | 497 | _ ticket: TicketSummary, |
Hutch/Views/Tickets/TrackerListView.swift +30
| @@ -224,6 +224,9 @@ struct TrackerListView: View { | ||
| 224 | 224 | // MARK: - Tracker Row |
| 225 | 225 | |
| 226 | 226 | private struct TrackerRowView: View { |
| 227 | @Environment(AppState.self) private var appState | |
| 228 | @Environment(\.openURL) private var openURL | |
| 229 | ||
| 227 | 230 | let tracker: TrackerSummary |
| 228 | 231 | |
| 229 | 232 | var body: some View { |
| @@ -255,5 +258,32 @@ private struct TrackerRowView: View { | ||
| 255 | 258 | .foregroundStyle(.tertiary) |
| 256 | 259 | } |
| 257 | 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 | 3 | @testable import Hutch |
| 4 | 4 | |
| 5 | 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 | 27 | @Test |
| 8 | 28 | func browserOnlyServiceURLsUseCanonicalHosts() { |
| 9 | 29 | #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht") |
| 10 | 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 | 155 | private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] { |
| 156 | 156 | var baseWeeks = switch entry.state { |
| 157 | case .populated, .indexing, .empty: | |
| 157 | case .populated, .indexing: | |
| 158 | 158 | entry.weeks |
| 159 | case .empty: | |
| 160 | ContributionGraphSampleData.emptyWeeks | |
| 159 | 161 | case .placeholder, .disabled, .unavailable: |
| 160 | 162 | ContributionGraphSampleData.placeholderWeeks |
| 161 | 163 | } |
| 162 | 164 | |
| 163 | // Drop any trailing week where every day has zero contributions | |
| 164 | while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) { | |
| 165 | baseWeeks.removeLast() | |
| 165 | if entry.state != .empty { | |
| 166 | // Drop any trailing week where every day has zero contributions. | |
| 167 | while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) { | |
| 168 | baseWeeks.removeLast() | |
| 169 | } | |
| 166 | 170 | } |
| 167 | 171 | |
| 168 | 172 | return Array(baseWeeks.suffix(columnCount)) |
| @@ -182,6 +186,11 @@ private struct ContributionGraphGridView: View { | ||
| 182 | 186 | RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous) |
| 183 | 187 | .fill((day?.intensity ?? .empty).color) |
| 184 | 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 | 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 | 443 | static let placeholderWeeks: [ContributionGraphWeek] = { |
| 424 | 444 | let startDate = Calendar.contributionCalendar.startOfDay(for: .now) |
| 425 | 445 | let days = (0..<150).compactMap { offset -> ContributionGraphDay? in |