Commit 707a3efe1e

707a3efe1ea575f85c7bfe580eec7d3e4c63bde3

parent: 899abef569

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 14:57 UTC

feat: add power user actions

Implements: https://todo.sr.ht/~ccleberg/hutch/52
Implements: https://todo.sr.ht/~ccleberg/hutch/53
Implements: https://todo.sr.ht/~ccleberg/hutch/54

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 @@
1import Foundation 1import Foundation
2import SwiftUI 2import SwiftUI
3import UIKit
3import WebKit 4import 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
530enum AppStateError: LocalizedError { 563enum 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 @@
1import SwiftUI
2
3struct 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
25struct 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
306private struct BuildArtifactRow: View { 372private struct BuildArtifactRow: View {
Hutch/Views/Builds/BuildDetailViewModel.swift +47 −2
@@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
28@MainActor 28@MainActor
29final class BuildDetailViewModel { 29final 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 @@
1import SwiftUI 1import SwiftUI
2import UIKit
3 2
4struct BuildTaskLogView: View { 3struct 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 @@
1import SwiftUI 1import SwiftUI
2import UIKit
3 2
4struct CommitRowView: View { 3struct 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
2import WebKit 2import WebKit
3 3
4struct ReadmeView: View { 4struct 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
155enum RenderedMarkupContent: Sendable { 178enum RenderedMarkupContent: Sendable {
Hutch/Views/Repositories/RepositoryDetailView.swift +61 −14
@@ -1,6 +1,8 @@
1import SwiftUI 1import SwiftUI
2 2
3struct RepositoryDetailView: View { 3struct 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 @@
1import SwiftUI 1import SwiftUI
2import UIKit
3 2
4struct RepositoryRowView: View { 3struct 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
74private 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
80private 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
87private struct RepositoryBuildStatusIndicator: View { 100private 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
178private struct AboutView: View { 182private 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
99final class TicketDetailViewModel { 99final 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
226private struct TrackerRowView: View { 226private 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
5struct SRHTWebURLTests { 5struct 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
422private enum ContributionGraphSampleData { 431private 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