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 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 49;
518 CURRENT_PROJECT_VERSION = 60;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.20.0;
535 MARKETING_VERSION = 2.21.1;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 49;
555 CURRENT_PROJECT_VERSION = 60;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.20.0;
572 MARKETING_VERSION = 2.21.1;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 49;
635 CURRENT_PROJECT_VERSION = 60;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.20.0;
645 MARKETING_VERSION = 2.21.1;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 49;
664 CURRENT_PROJECT_VERSION = 60;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.20.0;
674 MARKETING_VERSION = 2.21.1;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +33
@@ -1,5 +1,6 @@
11import Foundation
22import SwiftUI
3import UIKit
34import WebKit
45
56/// Central application state shared across the view hierarchy.
@@ -63,6 +64,13 @@ final class AppState {
6364 private(set) var systemStatusRepository: SystemStatusRepository
6465 private var activeSession: AccountSession?
6566 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>?
6674
6775 var accountDefaults: UserDefaults {
6876 activeSession?.defaults ?? .standard
@@ -210,6 +218,7 @@ final class AppState {
210218 clearAllAccountArtifacts()
211219 authPhase = .unauthenticated
212220 selectedTab = .home
221 dismissCopyConfirmation()
213222 }
214223
215224 func resetAppData() async {
@@ -230,6 +239,19 @@ final class AppState {
230239
231240 authPhase = .unauthenticated
232241 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
233255 }
234256
235257 // MARK: - Deep link resolution
@@ -525,6 +547,17 @@ final class AppState {
525547 }
526548 }
527549 }
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 }
528561}
529562
530563enum AppStateError: LocalizedError {
Hutch/App/AppStorageKeys.swift +1
@@ -19,4 +19,5 @@ enum AppStorageKeys {
1919 static let ticketSavedFilters = "ticketSavedFilters"
2020 static let appTheme = "appTheme"
2121 static let displayDensity = "displayDensity"
22 static let debugModeEnabled = "debugModeEnabled"
2223}
Hutch/App/RootView.swift +7
@@ -123,6 +123,13 @@ struct RootView: View {
123123 get: { appState.selectedTab },
124124 set: { appState.selectedTab = $0 }
125125 )))
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 }
126133 .overlay {
127134 if isResolvingDeepLink {
128135 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 {
1212 )
1313 }
1414
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
1525 static func commit(repository: RepositorySummary, commitId: String) -> URL? {
1626 userScopedURL(
1727 host: "\(repository.service.rawValue).sr.ht",
@@ -63,6 +73,10 @@ enum SRHTWebURL {
6373 )
6474 }
6575
76 static func tracker(_ trackerSummary: TrackerSummary) -> URL? {
77 tracker(ownerUsername: trackerSummary.owner.canonicalName.srhtUsername, trackerName: trackerSummary.name)
78 }
79
6680 static func projectSource(_ source: Project.SourceRepo) -> URL? {
6781 userScopedURL(
6882 host: "\(source.repoType.service.rawValue).sr.ht",
Hutch/Views/Builds/BuildDetailView.swift +71 −5
@@ -27,7 +27,42 @@ struct BuildDetailView: View {
2727 .navigationTitle("Job #\(jobId)")
2828 .navigationBarTitleDisplayMode(.inline)
2929 .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
3166 SRHTShareButton(
3267 url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
3368 target: .build
@@ -102,7 +137,11 @@ struct BuildDetailView: View {
102137 if viewModel == nil {
103138 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
104139 viewModel = vm
105 await vm.loadJob()
140 if appState.isDebugModeEnabled {
141 await vm.loadJobWithDebugCapture()
142 } else {
143 await vm.loadJob()
144 }
106145 vm.startAutoRefresh()
107146 }
108147 }
@@ -123,11 +162,10 @@ struct BuildDetailView: View {
123162 SRHTErrorStateView(
124163 title: "Couldn't Load Build",
125164 message: error,
126 retryAction: { await viewModel.loadJob() }
165 retryAction: { await reloadDetail(viewModel) }
127166 )
128167 } else if let job = viewModel.job {
129168 List {
130 // Status & metadata
131169 Section("Details") {
132170 HStack {
133171 Text("Status")
@@ -160,6 +198,26 @@ struct BuildDetailView: View {
160198 LabeledContent("Updated", value: job.updated.relativeDescription)
161199 }
162200
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
163221 if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
164222 Section("Source") {
165223 Button {
@@ -274,7 +332,7 @@ struct BuildDetailView: View {
274332 }
275333 }
276334 .refreshable {
277 await viewModel.loadJob()
335 await reloadDetail(viewModel)
278336 }
279337 .srhtErrorBanner(error: Binding(
280338 get: { viewModel.error },
@@ -301,6 +359,14 @@ struct BuildDetailView: View {
301359 }
302360 }
303361 }
362
363 private func reloadDetail(_ viewModel: BuildDetailViewModel) async {
364 if appState.isDebugModeEnabled {
365 await viewModel.loadJobWithDebugCapture()
366 } else {
367 await viewModel.loadJob()
368 }
369 }
304370}
305371
306372private struct BuildArtifactRow: View {
Hutch/Views/Builds/BuildDetailViewModel.swift +47 −2
@@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
2828@MainActor
2929final class BuildDetailViewModel {
3030 private static let autoRefreshInterval: Duration = .seconds(5)
31 private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" }
3132
3233 let jobId: Int
3334 private let client: SRHTClient
@@ -44,6 +45,7 @@ final class BuildDetailViewModel {
4445 private(set) var isCancelling = false
4546 private(set) var isRebuilding = false
4647 private(set) var isSubmittingEditedBuild = false
48 private(set) var rawJobResponse: String?
4749 var error: String?
4850 /// Transient error shown for action failures (cancel, rebuild, submit).
4951 /// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -123,6 +125,7 @@ final class BuildDetailViewModel {
123125 guard !isLoading else { return }
124126 isLoading = true
125127 error = nil
128 rawJobResponse = nil
126129
127130 do {
128131 let result = try await client.execute(
@@ -149,6 +152,40 @@ final class BuildDetailViewModel {
149152 isLoading = false
150153 }
151154
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
152189 func loadTaskLog(task: BuildTask) async {
153190 let cacheKey = task.logCacheKey
154191 let jobIsTerminal = job?.status.isTerminal ?? false
@@ -250,7 +287,7 @@ final class BuildDetailViewModel {
250287 variables: ["id": jobId],
251288 responseType: CancelResponse.self
252289 )
253 await loadJob()
290 await reloadJobPreservingDebugState()
254291 } catch {
255292 // Revert optimistic update on failure.
256293 self.job = originalJob
@@ -372,6 +409,14 @@ final class BuildDetailViewModel {
372409 self.autoRefreshTask = nil
373410 }
374411
412 private func reloadJobPreservingDebugState() async {
413 if rawJobResponse != nil {
414 await loadJobWithDebugCapture()
415 } else {
416 await loadJob()
417 }
418 }
419
375420 private var shouldAutoRefresh: Bool {
376421 guard let job else { return true }
377422 return !job.status.isTerminal
@@ -385,7 +430,7 @@ final class BuildDetailViewModel {
385430 return
386431 }
387432
388 await loadJob()
433 await reloadJobPreservingDebugState()
389434 await loadBuildLog()
390435 }
391436}
Hutch/Views/Builds/BuildListView.swift +23
@@ -136,6 +136,29 @@ struct BuildListView: View {
136136 NavigationLink(value: job) {
137137 BuildRowView(job: job)
138138 }
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 }
139162 .swipeActions(edge: .leading, allowsFullSwipe: true) {
140163 if swipeActionsEnabled, job.status.isCancellable {
141164 Button {
Hutch/Views/Builds/BuildTaskLogView.swift +3 −2
@@ -1,7 +1,8 @@
11import SwiftUI
2import UIKit
32
43struct BuildTaskLogView: View {
4 @Environment(AppState.self) private var appState
5
56 let taskName: String
67 let viewModel: BuildDetailViewModel
78
@@ -32,7 +33,7 @@ struct BuildTaskLogView: View {
3233 }
3334 ToolbarItem(placement: .topBarTrailing) {
3435 Button {
35 UIPasteboard.general.string = logText
36 appState.copyToPasteboard(logText, label: "build log")
3637 } label: {
3738 Image(systemName: "doc.on.doc")
3839 }
Hutch/Views/Home/HomeView.swift −9
@@ -74,15 +74,6 @@ struct HomeView: View {
7474 viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
7575 viewModel.unreadInboxThreads.isEmpty {
7676 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 )
8677 }
8778 }
8879 .refreshable {
Hutch/Views/Repositories/CommitDetailView.swift +34 −2
@@ -5,6 +5,7 @@ struct CommitDetailView: View {
55 let repository: RepositorySummary
66
77 @Environment(AppState.self) private var appState
8 @Environment(\.openURL) private var openURL
89 @State private var viewModel: CommitDetailViewModel?
910
1011 var body: some View {
@@ -18,7 +19,38 @@ struct CommitDetailView: View {
1819 .navigationTitle(commitSummary.shortId)
1920 .navigationBarTitleDisplayMode(.inline)
2021 .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
2254 SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
2355 Image(systemName: "square.and.arrow.up")
2456 }
@@ -109,7 +141,7 @@ struct CommitDetailView: View {
109141 VStack(alignment: .leading, spacing: 8) {
110142 // Full hash — tappable to copy
111143 Button {
112 UIPasteboard.general.string = commit.id
144 appState.copyToPasteboard(commit.id, label: "commit SHA")
113145 } label: {
114146 HStack(spacing: 4) {
115147 Text(commit.id)
Hutch/Views/Repositories/CommitLogView.swift +1 −1
@@ -7,7 +7,7 @@ struct CommitLogView: View {
77 List {
88 ForEach(viewModel.commits) { commit in
99 NavigationLink(value: commit) {
10 CommitRowView(commit: commit)
10 CommitRowView(commit: commit, repository: viewModel.repository)
1111 }
1212 .task {
1313 await viewModel.loadMoreCommitsIfNeeded(currentItem: commit)
Hutch/Views/Repositories/CommitRowView.swift +14 −3
@@ -1,8 +1,11 @@
11import SwiftUI
2import UIKit
32
43struct CommitRowView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.openURL) private var openURL
6
57 let commit: CommitSummary
8 let repository: RepositorySummary
69
710 var body: some View {
811 VStack(alignment: .leading, spacing: 4) {
@@ -28,14 +31,22 @@ struct CommitRowView: View {
2831 }
2932 .padding(.vertical, 2)
3033 .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
3142 Button {
32 UIPasteboard.general.string = commit.id
43 appState.copyToPasteboard(commit.id, label: "commit SHA")
3344 } label: {
3445 Label("Copy Full SHA", systemImage: "doc.on.doc")
3546 }
3647
3748 Button {
38 UIPasteboard.general.string = commit.shortId
49 appState.copyToPasteboard(commit.shortId, label: "short commit SHA")
3950 } label: {
4051 Label("Copy Short SHA", systemImage: "doc.on.doc.fill")
4152 }
Hutch/Views/Repositories/ReadmeView.swift +23
@@ -2,6 +2,7 @@ import SwiftUI
22import WebKit
33
44struct ReadmeView: View {
5 @Environment(AppState.self) private var appState
56 let viewModel: RepositoryDetailViewModel
67
78 @Environment(\.colorScheme) private var colorScheme
@@ -15,6 +16,9 @@ struct ReadmeView: View {
1516 repositoryDetailsSection
1617 latestChangeSection
1718 readmeSection
19 if appState.isDebugModeEnabled {
20 debugSection
21 }
1822 }
1923 .padding()
2024 }
@@ -150,6 +154,25 @@ struct ReadmeView: View {
150154 .plainText(text)
151155 }
152156 }
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 }
153176}
154177
155178enum RenderedMarkupContent: Sendable {
Hutch/Views/Repositories/RepositoryDetailView.swift +61 −14
@@ -1,6 +1,8 @@
11import SwiftUI
22
33struct RepositoryDetailView: View {
4 @Environment(\.openURL) private var openURL
5
46 let onRepositoryUpdated: ((RepositorySummary) -> Void)?
57 var onDeleted: (() -> Void)?
68
@@ -42,23 +44,11 @@ struct RepositoryDetailView: View {
4244 .navigationBarTitleDisplayMode(.inline)
4345 .toolbar {
4446 ToolbarItemGroup(placement: .topBarTrailing) {
47 repositoryActionsMenu
48
4549 SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
4650 Image(systemName: "square.and.arrow.up")
4751 }
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 }
6252 }
6353 }
6454 .sheet(isPresented: $showSettings) {
@@ -137,4 +127,61 @@ struct RepositoryDetailView: View {
137127 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
138128 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
139129 }
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 }
140187}
Hutch/Views/Repositories/RepositoryRowView.swift +29 −16
@@ -1,7 +1,9 @@
11import SwiftUI
2import UIKit
32
43struct RepositoryRowView: View {
4 @Environment(AppState.self) private var appState
5 @Environment(\.openURL) private var openURL
6
57 let repository: RepositorySummary
68 let buildStatus: RepositoryBuildStatus
79
@@ -56,34 +58,45 @@ struct RepositoryRowView: View {
5658 }
5759 .padding(.vertical, 2)
5860 .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
5977 Button {
60 UIPasteboard.general.string = httpsCloneURL(for: repository)
78 if let url = SRHTWebURL.httpsCloneURL(repository) {
79 appState.copyToPasteboard(url, label: "HTTPS clone URL")
80 }
6181 } label: {
6282 Label("Copy HTTPS URL", systemImage: "doc.on.doc")
6383 }
6484
6585 Button {
66 UIPasteboard.general.string = sshCloneURL(for: repository)
86 appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL")
6787 } label: {
6888 Label("Copy SSH URL", systemImage: "terminal")
6989 }
90
91 Button {
92 appState.copyToPasteboard(repository.rid, label: "repository RID")
93 } label: {
94 Label("Copy RID", systemImage: "number")
95 }
7096 }
7197 }
7298}
7399
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
87100private struct RepositoryBuildStatusIndicator: View {
88101 let status: RepositoryBuildStatus
89102
Hutch/Views/Settings/SettingsView.swift +39 −2
@@ -7,6 +7,7 @@ struct SettingsView: View {
77 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
88 @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
99 @State private var pendingDestructiveAction: SettingsDestructiveAction?
10 @State private var showAccountSwitcher = false
1011
1112 var body: some View {
1213 Form {
@@ -17,6 +18,9 @@ struct SettingsView: View {
1718 }
1819 .themedList()
1920 .navigationTitle("Settings")
21 .sheet(isPresented: $showAccountSwitcher) {
22 AccountSwitcherView()
23 }
2024 .alert(
2125 pendingDestructiveAction?.title ?? "",
2226 isPresented: Binding(
@@ -98,8 +102,8 @@ struct SettingsView: View {
98102 }
99103 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
100104
101 NavigationLink {
102 AccountSwitcherView()
105 Button {
106 showAccountSwitcher = true
103107 } label: {
104108 Label("Manage Accounts", systemImage: "person.2")
105109 }
@@ -176,6 +180,7 @@ private enum SettingsDestructiveAction {
176180}
177181
178182private struct AboutView: View {
183 @Environment(AppState.self) private var appState
179184 private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
180185 ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
181186 ?? "Hutch"
@@ -183,6 +188,17 @@ private struct AboutView: View {
183188 ?? "Unknown"
184189 private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
185190 ?? "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 }
186202
187203 var body: some View {
188204 Form {
@@ -197,7 +213,15 @@ private struct AboutView: View {
197213 .padding(.vertical, 4)
198214
199215 LabeledContent("Version", value: version)
216 .onTapGesture {
217 developerRevealCount = min(developerRevealCount + 1, 5)
218 }
200219 LabeledContent("Build", value: build)
220 .onTapGesture {
221 developerRevealCount = min(developerRevealCount + 1, 5)
222 }
223 } footer: {
224 Text(developerRevealFooterText)
201225 }
202226
203227 Section("Links") {
@@ -233,6 +257,19 @@ private struct AboutView: View {
233257 .font(.subheadline)
234258 .foregroundStyle(.secondary)
235259 }
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 }
236273 }
237274 .themedList()
238275 .navigationTitle("About")
Hutch/Views/Tickets/TicketDetailView.swift +79 −5
@@ -10,6 +10,7 @@ struct TicketDetailView: View {
1010
1111 @Environment(AppState.self) private var appState
1212 @Environment(\.colorScheme) private var colorScheme
13 @Environment(\.openURL) private var openURL
1314 @State private var viewModel: TicketDetailViewModel?
1415
1516 // Sheet state
@@ -47,7 +48,7 @@ struct TicketDetailView: View {
4748 Image(systemName: "square.and.arrow.up")
4849 }
4950
50 if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser {
51 if let viewModel, viewModel.ticket != nil {
5152 actionsMenu(viewModel)
5253 }
5354 }
@@ -63,7 +64,7 @@ struct TicketDetailView: View {
6364 client: appState.client
6465 )
6566 viewModel = vm
66 await vm.loadTicket()
67 await reloadDetail(vm)
6768 }
6869 }
6970 }
@@ -73,6 +74,35 @@ struct TicketDetailView: View {
7374 @ViewBuilder
7475 private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
7576 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
76106 if let ticket = viewModel.ticket {
77107 if ticket.status == .resolved {
78108 Button {
@@ -103,6 +133,7 @@ struct TicketDetailView: View {
103133 } label: {
104134 SwiftUI.Label("Manage Labels", systemImage: "tag")
105135 }
136 }
106137 } label: {
107138 Image(systemName: "ellipsis.circle")
108139 }
@@ -133,7 +164,7 @@ struct TicketDetailView: View {
133164 SRHTErrorStateView(
134165 title: "Couldn't Load Ticket",
135166 message: error,
136 retryAction: { await viewModel.loadTicket() }
167 retryAction: { await reloadDetail(viewModel) }
137168 )
138169 } else if let ticket = viewModel.ticket {
139170 ScrollView {
@@ -179,13 +210,19 @@ struct TicketDetailView: View {
179210 .padding(.vertical, 12)
180211 }
181212
182 // Comment input
213 if appState.isDebugModeEnabled {
214 Divider()
215 .padding(.vertical, 12)
216
217 debugSection(viewModel: viewModel, ticket: ticket)
218 }
219
183220 commentInput(viewModel)
184221 }
185222 }
186223 .srhtErrorBanner(error: $vm.error)
187224 .refreshable {
188 await viewModel.loadTicket()
225 await reloadDetail(viewModel)
189226 }
190227 }
191228 }
@@ -363,6 +400,35 @@ struct TicketDetailView: View {
363400 .padding()
364401 }
365402
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
366432 private func normalizedUsername(_ value: String) -> String {
367433 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
368434 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
@@ -381,6 +447,14 @@ struct TicketDetailView: View {
381447 }
382448 }
383449 }
450
451 private func reloadDetail(_ viewModel: TicketDetailViewModel) async {
452 if appState.isDebugModeEnabled {
453 await viewModel.loadTicketWithDebugCapture()
454 } else {
455 await viewModel.loadTicket()
456 }
457 }
384458}
385459
386460// MARK: - Self-Sizing Markdown Web View
Hutch/Views/Tickets/TicketDetailViewModel.swift +60 −6
@@ -97,6 +97,9 @@ private struct LabelsPage: Decodable, Sendable {
9797@Observable
9898@MainActor
9999final class TicketDetailViewModel {
100 private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
101 "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)"
102 }
100103
101104 let ownerUsername: String
102105 let trackerName: String
@@ -110,6 +113,7 @@ final class TicketDetailViewModel {
110113 private(set) var isSubmitting = false
111114 private(set) var isPerformingAction = false
112115 private(set) var trackerLabels: [TicketLabel] = []
116 private(set) var rawTicketResponse: String?
113117 var commentText = ""
114118 var error: String?
115119
@@ -284,6 +288,7 @@ final class TicketDetailViewModel {
284288 guard !isLoading else { return }
285289 isLoading = true
286290 error = nil
291 rawTicketResponse = nil
287292
288293 do {
289294 let result = try await client.execute(
@@ -317,6 +322,47 @@ final class TicketDetailViewModel {
317322 isLoading = false
318323 }
319324
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
320366 func submitComment() async {
321367 let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
322368 guard !text.isEmpty, !isSubmitting else { return }
@@ -378,7 +424,7 @@ final class TicketDetailViewModel {
378424 responseType: UpdateStatusResponse.self
379425 )
380426 // Re-fetch the ticket to get updated status/resolution
381 await loadTicket()
427 await reloadTicketPreservingDebugState()
382428 } catch {
383429 self.error = error.userFacingMessage
384430 }
@@ -412,7 +458,7 @@ final class TicketDetailViewModel {
412458 responseType: AssignUserResponse.self
413459 )
414460 // Reload to reflect the change
415 await loadTicket()
461 await reloadTicketPreservingDebugState()
416462 } catch {
417463 self.error = error.userFacingMessage
418464 }
@@ -457,7 +503,7 @@ final class TicketDetailViewModel {
457503 ],
458504 responseType: AssignUserResponse.self
459505 )
460 await loadTicket()
506 await reloadTicketPreservingDebugState()
461507 } catch {
462508 ticket = TicketDetail(
463509 id: currentTicket.id,
@@ -505,7 +551,7 @@ final class TicketDetailViewModel {
505551 responseType: UnassignUserResponse.self
506552 )
507553 // Reload to reflect the change
508 await loadTicket()
554 await reloadTicketPreservingDebugState()
509555 } catch {
510556 self.error = error.userFacingMessage
511557 }
@@ -529,7 +575,7 @@ final class TicketDetailViewModel {
529575 ],
530576 responseType: LabelTicketResponse.self
531577 )
532 await loadTicket()
578 await reloadTicketPreservingDebugState()
533579 } catch {
534580 self.error = error.userFacingMessage
535581 }
@@ -553,7 +599,7 @@ final class TicketDetailViewModel {
553599 ],
554600 responseType: UnlabelTicketResponse.self
555601 )
556 await loadTicket()
602 await reloadTicketPreservingDebugState()
557603 } catch {
558604 self.error = error.userFacingMessage
559605 }
@@ -600,6 +646,14 @@ final class TicketDetailViewModel {
600646 isPerformingAction = false
601647 }
602648
649 private func reloadTicketPreservingDebugState() async {
650 if rawTicketResponse != nil {
651 await loadTicketWithDebugCapture()
652 } else {
653 await loadTicket()
654 }
655 }
656
603657 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
604658 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
605659 let userCanonical = normalizedCanonicalName(user.canonicalName)
Hutch/Views/Tickets/TicketListView.swift +57 −3
@@ -7,6 +7,7 @@ struct TicketListView: View {
77 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
88 @Environment(AppState.self) private var appState
99 @Environment(\.dismiss) private var dismiss
10 @Environment(\.openURL) private var openURL
1011 @State private var tracker: TrackerSummary
1112 @State private var viewModel: TicketListViewModel?
1213 @State private var trackerManagementViewModel: TrackerManagementViewModel?
@@ -86,9 +87,7 @@ struct TicketListView: View {
8687 viewModel?.setSelectionMode(true)
8788 }
8889
89 if isOwnedByCurrentUser {
90 trackerActionsMenu
91 }
90 trackerActionsMenu
9291 }
9392 }
9493 }
@@ -296,6 +295,27 @@ struct TicketListView: View {
296295 } label: {
297296 TicketRowView(ticket: ticket)
298297 }
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 }
299319 .swipeActions(edge: .leading, allowsFullSwipe: true) {
300320 if swipeActionsEnabled {
301321 ticketAssignSwipeAction(ticket, viewModel: viewModel)
@@ -409,6 +429,35 @@ struct TicketListView: View {
409429
410430 private var trackerActionsMenu: some View {
411431 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
412461 Button {
413462 showTrackerEditor = true
414463 } label: {
@@ -432,12 +481,17 @@ struct TicketListView: View {
432481 } label: {
433482 Label("Delete Tracker", systemImage: "trash")
434483 }
484 }
435485 } label: {
436486 Image(systemName: "ellipsis.circle")
437487 }
438488 .accessibilityLabel("Tracker actions")
439489 }
440490
491 private func ownerUsername(for tracker: TrackerSummary) -> String {
492 tracker.owner.canonicalName.srhtUsername
493 }
494
441495 @ViewBuilder
442496 private func ticketAssignSwipeAction(
443497 _ ticket: TicketSummary,
Hutch/Views/Tickets/TrackerListView.swift +30
@@ -224,6 +224,9 @@ struct TrackerListView: View {
224224// MARK: - Tracker Row
225225
226226private struct TrackerRowView: View {
227 @Environment(AppState.self) private var appState
228 @Environment(\.openURL) private var openURL
229
227230 let tracker: TrackerSummary
228231
229232 var body: some View {
@@ -255,5 +258,32 @@ private struct TrackerRowView: View {
255258 .foregroundStyle(.tertiary)
256259 }
257260 .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 }
258288 }
259289}
HutchTests/SRHTWebURLTests.swift +34
@@ -3,10 +3,44 @@ import Testing
33@testable import Hutch
44
55struct 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 )
626
727 @Test
828 func browserOnlyServiceURLsUseCanonicalHosts() {
929 #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
1030 #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
1131 }
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 }
1246}
HutchWidgetExtension/ContributionGraphWidget.swift +24 −4
@@ -154,15 +154,19 @@ private struct ContributionGraphWidgetView: View {
154154
155155 private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
156156 var baseWeeks = switch entry.state {
157 case .populated, .indexing, .empty:
157 case .populated, .indexing:
158158 entry.weeks
159 case .empty:
160 ContributionGraphSampleData.emptyWeeks
159161 case .placeholder, .disabled, .unavailable:
160162 ContributionGraphSampleData.placeholderWeeks
161163 }
162164
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 }
166170 }
167171
168172 return Array(baseWeeks.suffix(columnCount))
@@ -182,6 +186,11 @@ private struct ContributionGraphGridView: View {
182186 RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
183187 .fill((day?.intensity ?? .empty).color)
184188 .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 }
185194 }
186195 }
187196 }
@@ -420,6 +429,17 @@ private enum ContributionGraphDateParser {
420429}
421430
422431private 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
423443 static let placeholderWeeks: [ContributionGraphWeek] = {
424444 let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
425445 let days = (0..<150).compactMap { offset -> ContributionGraphDay? in