krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

0e461a382257979037e8927e5f6b468635b0a7fe

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T14:57:23Z

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
 Hutch.xcodeproj/project.pbxproj                    | 16 ++---
 Hutch/App/AppState.swift                           | 33 +++++++++
 Hutch/App/AppStorageKeys.swift                     |  1 +
 Hutch/App/RootView.swift                           |  7 ++
 Hutch/Extensions/PowerUserActions.swift            | 40 +++++++++++
 Hutch/Extensions/SRHTWebURL.swift                  | 14 ++++
 Hutch/Views/Builds/BuildDetailView.swift           | 76 ++++++++++++++++++--
 Hutch/Views/Builds/BuildDetailViewModel.swift      | 49 ++++++++++++-
 Hutch/Views/Builds/BuildListView.swift             | 23 ++++++
 Hutch/Views/Builds/BuildTaskLogView.swift          |  5 +-
 Hutch/Views/Home/HomeView.swift                    |  9 ---
 Hutch/Views/Repositories/CommitDetailView.swift    | 36 +++++++++-
 Hutch/Views/Repositories/CommitLogView.swift       |  2 +-
 Hutch/Views/Repositories/CommitRowView.swift       | 17 ++++-
 Hutch/Views/Repositories/ReadmeView.swift          | 23 ++++++
 .../Views/Repositories/RepositoryDetailView.swift  | 75 +++++++++++++++----
 Hutch/Views/Repositories/RepositoryRowView.swift   | 45 +++++++-----
 Hutch/Views/Settings/SettingsView.swift            | 41 ++++++++++-
 Hutch/Views/Tickets/TicketDetailView.swift         | 84 ++++++++++++++++++++--
 Hutch/Views/Tickets/TicketDetailViewModel.swift    | 66 +++++++++++++++--
 Hutch/Views/Tickets/TicketListView.swift           | 60 +++++++++++++++-
 Hutch/Views/Tickets/TrackerListView.swift          | 30 ++++++++
 HutchTests/SRHTWebURLTests.swift                   | 34 +++++++++
 HutchWidgetExtension/ContributionGraphWidget.swift | 28 ++++++--
 24 files changed, 732 insertions(+), 82 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index a39ce78..f1e1869 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 49;
+				CURRENT_PROJECT_VERSION = 60;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.20.0;
+				MARKETING_VERSION = 2.21.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 49;
+				CURRENT_PROJECT_VERSION = 60;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.20.0;
+				MARKETING_VERSION = 2.21.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 49;
+				CURRENT_PROJECT_VERSION = 60;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.20.0;
+				MARKETING_VERSION = 2.21.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 49;
+				CURRENT_PROJECT_VERSION = 60;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.20.0;
+				MARKETING_VERSION = 2.21.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 2f14011..ebd25d1 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -1,5 +1,6 @@
 import Foundation
 import SwiftUI
+import UIKit
 import WebKit
 
 /// Central application state shared across the view hierarchy.
@@ -63,6 +64,13 @@ final class AppState {
     private(set) var systemStatusRepository: SystemStatusRepository
     private var activeSession: AccountSession?
     private(set) var sessionIdentity = UUID()
+    var isDebugModeEnabled = UserDefaults.standard.bool(forKey: AppStorageKeys.debugModeEnabled) {
+        didSet {
+            UserDefaults.standard.set(isDebugModeEnabled, forKey: AppStorageKeys.debugModeEnabled)
+        }
+    }
+    private(set) var copyConfirmationMessage: String?
+    private var copyConfirmationTask: Task<Void, Never>?
 
     var accountDefaults: UserDefaults {
         activeSession?.defaults ?? .standard
@@ -210,6 +218,7 @@ final class AppState {
         clearAllAccountArtifacts()
         authPhase = .unauthenticated
         selectedTab = .home
+        dismissCopyConfirmation()
     }
 
     func resetAppData() async {
@@ -230,6 +239,19 @@ final class AppState {
 
         authPhase = .unauthenticated
         selectedTab = .home
+        isDebugModeEnabled = false
+        dismissCopyConfirmation()
+    }
+
+    func copyToPasteboard(_ value: String, label: String) {
+        UIPasteboard.general.string = value
+        showCopyConfirmation(message: "Copied \(label)")
+    }
+
+    func dismissCopyConfirmation() {
+        copyConfirmationTask?.cancel()
+        copyConfirmationTask = nil
+        copyConfirmationMessage = nil
     }
 
     // MARK: - Deep link resolution
@@ -525,6 +547,17 @@ final class AppState {
             }
         }
     }
+
+    private func showCopyConfirmation(message: String) {
+        copyConfirmationTask?.cancel()
+        copyConfirmationMessage = message
+        copyConfirmationTask = Task { @MainActor in
+            try? await Task.sleep(for: .seconds(1.6))
+            guard !Task.isCancelled else { return }
+            copyConfirmationMessage = nil
+            copyConfirmationTask = nil
+        }
+    }
 }
 
 enum AppStateError: LocalizedError {
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index e151617..4be8004 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -19,4 +19,5 @@ enum AppStorageKeys {
     static let ticketSavedFilters = "ticketSavedFilters"
     static let appTheme = "appTheme"
     static let displayDensity = "displayDensity"
+    static let debugModeEnabled = "debugModeEnabled"
 }
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index e3baa76..f65bced 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -123,6 +123,13 @@ struct RootView: View {
             get: { appState.selectedTab },
             set: { appState.selectedTab = $0 }
         )))
+        .safeAreaInset(edge: .bottom) {
+            if let message = appState.copyConfirmationMessage {
+                CopyConfirmationBadge(message: message)
+                    .padding(.bottom, 4)
+                    .transition(.move(edge: .bottom).combined(with: .opacity))
+            }
+        }
         .overlay {
             if isResolvingDeepLink {
                 ZStack {
diff --git a/Hutch/Extensions/PowerUserActions.swift b/Hutch/Extensions/PowerUserActions.swift
new file mode 100644
index 0000000..97e4d52
--- /dev/null
+++ b/Hutch/Extensions/PowerUserActions.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct DebugTextBlock: View {
+    let title: String
+    let content: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text(title)
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(.secondary)
+                .textCase(.uppercase)
+
+            Text(content)
+                .font(.caption.monospaced())
+                .foregroundStyle(.primary)
+                .textSelection(.enabled)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(12)
+                .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
+        }
+    }
+}
+
+struct CopyConfirmationBadge: View {
+    let message: String
+
+    var body: some View {
+        HStack(spacing: 6) {
+            Image(systemName: "checkmark.circle.fill")
+                .font(.caption)
+            Text(message)
+                .font(.caption.weight(.medium))
+        }
+        .foregroundStyle(.white)
+        .padding(.horizontal, 12)
+        .padding(.vertical, 8)
+        .background(.green.gradient, in: Capsule())
+    }
+}
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index cd8e8be..bfed48b 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -12,6 +12,16 @@ enum SRHTWebURL {
         )
     }
 
+    static func httpsCloneURL(_ repositorySummary: RepositorySummary) -> String? {
+        repository(repositorySummary)?.absoluteString
+    }
+
+    static func sshCloneURL(_ repositorySummary: RepositorySummary) -> String {
+        let host = "\(repositorySummary.service.rawValue).sr.ht"
+        let user = repositorySummary.service == .hg ? "hg" : "git"
+        return "\(user)@\(host):\(repositorySummary.owner.canonicalName)/\(repositorySummary.name)"
+    }
+
     static func commit(repository: RepositorySummary, commitId: String) -> URL? {
         userScopedURL(
             host: "\(repository.service.rawValue).sr.ht",
@@ -63,6 +73,10 @@ enum SRHTWebURL {
         )
     }
 
+    static func tracker(_ trackerSummary: TrackerSummary) -> URL? {
+        tracker(ownerUsername: trackerSummary.owner.canonicalName.srhtUsername, trackerName: trackerSummary.name)
+    }
+
     static func projectSource(_ source: Project.SourceRepo) -> URL? {
         userScopedURL(
             host: "\(source.repoType.service.rawValue).sr.ht",
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 1ec9f64..f8ab90d 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -27,7 +27,42 @@ struct BuildDetailView: View {
         .navigationTitle("Job #\(jobId)")
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
-            ToolbarItem(placement: .topBarTrailing) {
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                if let browserURL = viewModel?.job.flatMap({ SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }) {
+                    Menu {
+                        Button {
+                            openURL(browserURL)
+                        } label: {
+                            Label("Open in Browser", systemImage: "safari")
+                        }
+
+                        Button {
+                            appState.copyToPasteboard(browserURL.absoluteString, label: "build URL")
+                        } label: {
+                            Label("Copy URL", systemImage: "doc.on.doc")
+                        }
+
+                        if let job = viewModel?.job {
+                            Button {
+                                appState.copyToPasteboard(String(job.id), label: "job ID")
+                            } label: {
+                                Label("Copy Job ID", systemImage: "number")
+                            }
+
+                            if let note = job.note, !note.isEmpty {
+                                Button {
+                                    appState.copyToPasteboard(note, label: "build note")
+                                } label: {
+                                    Label("Copy Note", systemImage: "text.alignleft")
+                                }
+                            }
+                        }
+                    } label: {
+                        Image(systemName: "ellipsis.circle")
+                    }
+                    .accessibilityLabel("Build actions")
+                }
+
                 SRHTShareButton(
                     url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
                     target: .build
@@ -102,7 +137,11 @@ struct BuildDetailView: View {
             if viewModel == nil {
                 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
                 viewModel = vm
-                await vm.loadJob()
+                if appState.isDebugModeEnabled {
+                    await vm.loadJobWithDebugCapture()
+                } else {
+                    await vm.loadJob()
+                }
                 vm.startAutoRefresh()
             }
         }
@@ -123,11 +162,10 @@ struct BuildDetailView: View {
             SRHTErrorStateView(
                 title: "Couldn't Load Build",
                 message: error,
-                retryAction: { await viewModel.loadJob() }
+                retryAction: { await reloadDetail(viewModel) }
             )
         } else if let job = viewModel.job {
             List {
-                // Status & metadata
                 Section("Details") {
                     HStack {
                         Text("Status")
@@ -160,6 +198,26 @@ struct BuildDetailView: View {
                     LabeledContent("Updated", value: job.updated.relativeDescription)
                 }
 
+                if appState.isDebugModeEnabled {
+                    Section("Debug") {
+                        DebugTextBlock(
+                            title: "Diagnostics",
+                            content: """
+                            jobId: \(job.id)
+                            status: \(job.status.rawValue)
+                            tasks: \(job.tasks.count)
+                            artifacts: \(job.artifacts.count)
+                            owner: \(job.owner.canonicalName)
+                            url: \(SRHTWebURL.build(jobId: job.id, ownerCanonicalName: job.owner.canonicalName)?.absoluteString ?? "unavailable")
+                            """
+                        )
+
+                        if let rawJobResponse = viewModel.rawJobResponse {
+                            DebugTextBlock(title: "Raw Response", content: rawJobResponse)
+                        }
+                    }
+                }
+
                 if let repositoryReference = HomeViewModel.primaryRepositoryReference(in: job.manifest) {
                     Section("Source") {
                         Button {
@@ -274,7 +332,7 @@ struct BuildDetailView: View {
                 }
             }
             .refreshable {
-                await viewModel.loadJob()
+                await reloadDetail(viewModel)
             }
             .srhtErrorBanner(error: Binding(
                 get: { viewModel.error },
@@ -301,6 +359,14 @@ struct BuildDetailView: View {
             }
         }
     }
+
+    private func reloadDetail(_ viewModel: BuildDetailViewModel) async {
+        if appState.isDebugModeEnabled {
+            await viewModel.loadJobWithDebugCapture()
+        } else {
+            await viewModel.loadJob()
+        }
+    }
 }
 
 private struct BuildArtifactRow: View {
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index 9566e0d..dcf70a0 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -28,6 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
 @MainActor
 final class BuildDetailViewModel {
     private static let autoRefreshInterval: Duration = .seconds(5)
+    private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" }
 
     let jobId: Int
     private let client: SRHTClient
@@ -44,6 +45,7 @@ final class BuildDetailViewModel {
     private(set) var isCancelling = false
     private(set) var isRebuilding = false
     private(set) var isSubmittingEditedBuild = false
+    private(set) var rawJobResponse: String?
     var error: String?
     /// Transient error shown for action failures (cancel, rebuild, submit).
     /// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -123,6 +125,7 @@ final class BuildDetailViewModel {
         guard !isLoading else { return }
         isLoading = true
         error = nil
+        rawJobResponse = nil
 
         do {
             let result = try await client.execute(
@@ -149,6 +152,40 @@ final class BuildDetailViewModel {
         isLoading = false
     }
 
+    func loadJobWithDebugCapture() async {
+        guard !isLoading else { return }
+        isLoading = true
+        error = nil
+
+        do {
+            let cacheKey = Self.cacheKey(for: jobId)
+            let result = try await client.executeAndCache(
+                service: .builds,
+                query: Self.detailQuery,
+                variables: ["id": jobId],
+                responseType: JobDetailResponse.self,
+                cacheKey: cacheKey
+            )
+            rawJobResponse = client.responseCache.get(forKey: cacheKey)
+                .flatMap { String(data: $0, encoding: .utf8) }
+            var loadedJob = result.job
+            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
+                task.withOrdinal(index)
+            }
+            if job != loadedJob {
+                job = loadedJob
+            }
+
+            if loadedJob.status.isTerminal {
+                stopAutoRefresh()
+            }
+        } catch {
+            self.error = error.userFacingMessage
+        }
+
+        isLoading = false
+    }
+
     func loadTaskLog(task: BuildTask) async {
         let cacheKey = task.logCacheKey
         let jobIsTerminal = job?.status.isTerminal ?? false
@@ -250,7 +287,7 @@ final class BuildDetailViewModel {
                 variables: ["id": jobId],
                 responseType: CancelResponse.self
             )
-            await loadJob()
+            await reloadJobPreservingDebugState()
         } catch {
             // Revert optimistic update on failure.
             self.job = originalJob
@@ -372,6 +409,14 @@ final class BuildDetailViewModel {
         self.autoRefreshTask = nil
     }
 
+    private func reloadJobPreservingDebugState() async {
+        if rawJobResponse != nil {
+            await loadJobWithDebugCapture()
+        } else {
+            await loadJob()
+        }
+    }
+
     private var shouldAutoRefresh: Bool {
         guard let job else { return true }
         return !job.status.isTerminal
@@ -385,7 +430,7 @@ final class BuildDetailViewModel {
             return
         }
 
-        await loadJob()
+        await reloadJobPreservingDebugState()
         await loadBuildLog()
     }
 }
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
index d99d53a..aa98066 100644
--- a/Hutch/Views/Builds/BuildListView.swift
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -136,6 +136,29 @@ struct BuildListView: View {
                 NavigationLink(value: job) {
                     BuildRowView(job: job)
                 }
+                .contextMenu {
+                    Button {
+                        appState.copyToPasteboard(String(job.id), label: "job ID")
+                    } label: {
+                        Label("Copy Job ID", systemImage: "doc.on.doc")
+                    }
+
+                    if let note = job.note, !note.isEmpty {
+                        Button {
+                            appState.copyToPasteboard(note, label: "build note")
+                        } label: {
+                            Label("Copy Note", systemImage: "text.alignleft")
+                        }
+                    }
+
+                    if !job.tags.isEmpty {
+                        Button {
+                            appState.copyToPasteboard(job.tags.joined(separator: ", "), label: "build tags")
+                        } label: {
+                            Label("Copy Tags", systemImage: "tag")
+                        }
+                    }
+                }
                 .swipeActions(edge: .leading, allowsFullSwipe: true) {
                     if swipeActionsEnabled, job.status.isCancellable {
                         Button {
diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift
index fda69f6..3011e34 100644
--- a/Hutch/Views/Builds/BuildTaskLogView.swift
+++ b/Hutch/Views/Builds/BuildTaskLogView.swift
@@ -1,7 +1,8 @@
 import SwiftUI
-import UIKit
 
 struct BuildTaskLogView: View {
+    @Environment(AppState.self) private var appState
+
     let taskName: String
     let viewModel: BuildDetailViewModel
 
@@ -32,7 +33,7 @@ struct BuildTaskLogView: View {
                             }
                             ToolbarItem(placement: .topBarTrailing) {
                                 Button {
-                                    UIPasteboard.general.string = logText
+                                    appState.copyToPasteboard(logText, label: "build log")
                                 } label: {
                                     Image(systemName: "doc.on.doc")
                                 }
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index abde827..69e47da 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -74,15 +74,6 @@ struct HomeView: View {
                 viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
                 viewModel.unreadInboxThreads.isEmpty {
                 SRHTLoadingStateView(message: "Loading Home…")
-            } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds &&
-                        viewModel.pinnedProjects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty &&
-                        viewModel.unreadInboxThreads.isEmpty &&
-                        viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil {
-                ContentUnavailableView(
-                    "All Clear",
-                    systemImage: "checkmark.circle",
-                    description: Text("There are no unread threads, assigned tickets, or urgent builds right now.")
-                )
             }
         }
         .refreshable {
diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift
index d303542..1dcbf68 100644
--- a/Hutch/Views/Repositories/CommitDetailView.swift
+++ b/Hutch/Views/Repositories/CommitDetailView.swift
@@ -5,6 +5,7 @@ struct CommitDetailView: View {
     let repository: RepositorySummary
 
     @Environment(AppState.self) private var appState
+    @Environment(\.openURL) private var openURL
     @State private var viewModel: CommitDetailViewModel?
 
     var body: some View {
@@ -18,7 +19,38 @@ struct CommitDetailView: View {
         .navigationTitle(commitSummary.shortId)
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
-            ToolbarItem(placement: .topBarTrailing) {
+            ToolbarItemGroup(placement: .topBarTrailing) {
+                Menu {
+                    if let commitURL = SRHTWebURL.commit(repository: repository, commitId: commitSummary.id) {
+                        Button {
+                            openURL(commitURL)
+                        } label: {
+                            Label("Open in Browser", systemImage: "safari")
+                        }
+
+                        Button {
+                            appState.copyToPasteboard(commitURL.absoluteString, label: "commit URL")
+                        } label: {
+                            Label("Copy URL", systemImage: "doc.on.doc")
+                        }
+                    }
+
+                    Button {
+                        appState.copyToPasteboard(commitSummary.id, label: "commit SHA")
+                    } label: {
+                        Label("Copy Full SHA", systemImage: "doc.on.doc")
+                    }
+
+                    Button {
+                        appState.copyToPasteboard(commitSummary.shortId, label: "short commit SHA")
+                    } label: {
+                        Label("Copy Short SHA", systemImage: "number")
+                    }
+                } label: {
+                    Image(systemName: "ellipsis.circle")
+                }
+                .accessibilityLabel("Commit actions")
+
                 SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
                     Image(systemName: "square.and.arrow.up")
                 }
@@ -109,7 +141,7 @@ struct CommitDetailView: View {
         VStack(alignment: .leading, spacing: 8) {
             // Full hash — tappable to copy
             Button {
-                UIPasteboard.general.string = commit.id
+                appState.copyToPasteboard(commit.id, label: "commit SHA")
             } label: {
                 HStack(spacing: 4) {
                     Text(commit.id)
diff --git a/Hutch/Views/Repositories/CommitLogView.swift b/Hutch/Views/Repositories/CommitLogView.swift
index d63c5eb..bc8ccc9 100644
--- a/Hutch/Views/Repositories/CommitLogView.swift
+++ b/Hutch/Views/Repositories/CommitLogView.swift
@@ -7,7 +7,7 @@ struct CommitLogView: View {
         List {
             ForEach(viewModel.commits) { commit in
                 NavigationLink(value: commit) {
-                    CommitRowView(commit: commit)
+                    CommitRowView(commit: commit, repository: viewModel.repository)
                 }
                 .task {
                     await viewModel.loadMoreCommitsIfNeeded(currentItem: commit)
diff --git a/Hutch/Views/Repositories/CommitRowView.swift b/Hutch/Views/Repositories/CommitRowView.swift
index 362ab4d..6f40b57 100644
--- a/Hutch/Views/Repositories/CommitRowView.swift
+++ b/Hutch/Views/Repositories/CommitRowView.swift
@@ -1,8 +1,11 @@
 import SwiftUI
-import UIKit
 
 struct CommitRowView: View {
+    @Environment(AppState.self) private var appState
+    @Environment(\.openURL) private var openURL
+
     let commit: CommitSummary
+    let repository: RepositorySummary
 
     var body: some View {
         VStack(alignment: .leading, spacing: 4) {
@@ -28,14 +31,22 @@ struct CommitRowView: View {
         }
         .padding(.vertical, 2)
         .contextMenu {
+            if let url = SRHTWebURL.commit(repository: repository, commitId: commit.id) {
+                Button {
+                    openURL(url)
+                } label: {
+                    Label("Open in Browser", systemImage: "safari")
+                }
+            }
+
             Button {
-                UIPasteboard.general.string = commit.id
+                appState.copyToPasteboard(commit.id, label: "commit SHA")
             } label: {
                 Label("Copy Full SHA", systemImage: "doc.on.doc")
             }
 
             Button {
-                UIPasteboard.general.string = commit.shortId
+                appState.copyToPasteboard(commit.shortId, label: "short commit SHA")
             } label: {
                 Label("Copy Short SHA", systemImage: "doc.on.doc.fill")
             }
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 5742811..f5c0c9f 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -2,6 +2,7 @@ import SwiftUI
 import WebKit
 
 struct ReadmeView: View {
+    @Environment(AppState.self) private var appState
     let viewModel: RepositoryDetailViewModel
 
     @Environment(\.colorScheme) private var colorScheme
@@ -15,6 +16,9 @@ struct ReadmeView: View {
                 repositoryDetailsSection
                 latestChangeSection
                 readmeSection
+                if appState.isDebugModeEnabled {
+                    debugSection
+                }
             }
             .padding()
         }
@@ -150,6 +154,25 @@ struct ReadmeView: View {
             .plainText(text)
         }
     }
+
+    private var debugSection: some View {
+        DebugTextBlock(
+            title: "Debug",
+            content: """
+            repositoryId: \(viewModel.repository.id)
+            rid: \(viewModel.repository.rid)
+            service: \(viewModel.repository.service.rawValue)
+            defaultBranch: \(viewModel.repository.defaultBranchName ?? "none")
+            webURL: \(SRHTWebURL.repository(viewModel.repository)?.absoluteString ?? "unavailable")
+            httpsClone: \(SRHTWebURL.httpsCloneURL(viewModel.repository) ?? "unavailable")
+            sshClone: \(SRHTWebURL.sshCloneURL(viewModel.repository))
+            readmePath: \(viewModel.readmePath ?? "none")
+            commitsLoaded: \(viewModel.commits.count)
+            branchesLoaded: \(viewModel.branches.count)
+            tagsLoaded: \(viewModel.tags.count)
+            """
+        )
+    }
 }
 
 enum RenderedMarkupContent: Sendable {
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 0031e47..2715134 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -1,6 +1,8 @@
 import SwiftUI
 
 struct RepositoryDetailView: View {
+    @Environment(\.openURL) private var openURL
+
     let onRepositoryUpdated: ((RepositorySummary) -> Void)?
     var onDeleted: (() -> Void)?
 
@@ -42,23 +44,11 @@ struct RepositoryDetailView: View {
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItemGroup(placement: .topBarTrailing) {
+                    repositoryActionsMenu
+
                     SRHTShareButton(url: SRHTWebURL.repository(currentRepository), target: .repository) {
                         Image(systemName: "square.and.arrow.up")
                     }
-
-                    if canManageRepository {
-                        Button {
-                            showACLs = true
-                        } label: {
-                            Image(systemName: "person.2")
-                        }
-
-                        Button {
-                            showSettings = true
-                        } label: {
-                            Image(systemName: "gear")
-                        }
-                    }
                 }
             }
             .sheet(isPresented: $showSettings) {
@@ -137,4 +127,61 @@ struct RepositoryDetailView: View {
         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
         return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
     }
+
+    private var repositoryActionsMenu: some View {
+        Menu {
+            if let repositoryURL = SRHTWebURL.repository(currentRepository) {
+                Button {
+                    openURL(repositoryURL)
+                } label: {
+                    Label("Open in Browser", systemImage: "safari")
+                }
+
+                Button {
+                    appState.copyToPasteboard(repositoryURL.absoluteString, label: "repository URL")
+                } label: {
+                    Label("Copy URL", systemImage: "doc.on.doc")
+                }
+            }
+
+            if let httpsURL = SRHTWebURL.httpsCloneURL(currentRepository) {
+                Button {
+                    appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL")
+                } label: {
+                    Label("Copy HTTPS URL", systemImage: "doc.on.doc")
+                }
+            }
+
+            Button {
+                appState.copyToPasteboard(SRHTWebURL.sshCloneURL(currentRepository), label: "SSH clone URL")
+            } label: {
+                Label("Copy SSH URL", systemImage: "terminal")
+            }
+
+            Button {
+                appState.copyToPasteboard(currentRepository.rid, label: "repository RID")
+            } label: {
+                Label("Copy RID", systemImage: "number")
+            }
+
+            if canManageRepository {
+                Divider()
+
+                Button {
+                    showACLs = true
+                } label: {
+                    Label("Manage ACLs", systemImage: "person.2")
+                }
+
+                Button {
+                    showSettings = true
+                } label: {
+                    Label("Repository Settings", systemImage: "gear")
+                }
+            }
+        } label: {
+            Image(systemName: "ellipsis.circle")
+        }
+        .accessibilityLabel("Repository actions")
+    }
 }
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index 814ea76..0da6cbc 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -1,7 +1,9 @@
 import SwiftUI
-import UIKit
 
 struct RepositoryRowView: View {
+    @Environment(AppState.self) private var appState
+    @Environment(\.openURL) private var openURL
+
     let repository: RepositorySummary
     let buildStatus: RepositoryBuildStatus
 
@@ -56,34 +58,45 @@ struct RepositoryRowView: View {
         }
         .padding(.vertical, 2)
         .contextMenu {
+            if let url = SRHTWebURL.repository(repository) {
+                Button {
+                    openURL(url)
+                } label: {
+                    Label("Open in Browser", systemImage: "safari")
+                }
+            }
+
+            Button {
+                if let url = SRHTWebURL.repository(repository)?.absoluteString {
+                    appState.copyToPasteboard(url, label: "repository URL")
+                }
+            } label: {
+                Label("Copy URL", systemImage: "doc.on.doc")
+            }
+
             Button {
-                UIPasteboard.general.string = httpsCloneURL(for: repository)
+                if let url = SRHTWebURL.httpsCloneURL(repository) {
+                    appState.copyToPasteboard(url, label: "HTTPS clone URL")
+                }
             } label: {
                 Label("Copy HTTPS URL", systemImage: "doc.on.doc")
             }
 
             Button {
-                UIPasteboard.general.string = sshCloneURL(for: repository)
+                appState.copyToPasteboard(SRHTWebURL.sshCloneURL(repository), label: "SSH clone URL")
             } label: {
                 Label("Copy SSH URL", systemImage: "terminal")
             }
+
+            Button {
+                appState.copyToPasteboard(repository.rid, label: "repository RID")
+            } label: {
+                Label("Copy RID", systemImage: "number")
+            }
         }
     }
 }
 
-private func httpsCloneURL(for repository: RepositorySummary) -> String {
-    let host = "\(repository.service.rawValue).sr.ht"
-    let owner = repository.owner.canonicalName
-    return "https://\(host)/\(owner)/\(repository.name)"
-}
-
-private func sshCloneURL(for repository: RepositorySummary) -> String {
-    let host = "\(repository.service.rawValue).sr.ht"
-    let user = repository.service == .hg ? "hg" : "git"
-    let owner = repository.owner.canonicalName
-    return "\(user)@\(host):\(owner)/\(repository.name)"
-}
-
 private struct RepositoryBuildStatusIndicator: View {
     let status: RepositoryBuildStatus
 
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index b6ff8dd..f8cc0bd 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -7,6 +7,7 @@ struct SettingsView: View {
     @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
     @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
     @State private var pendingDestructiveAction: SettingsDestructiveAction?
+    @State private var showAccountSwitcher = false
 
     var body: some View {
         Form {
@@ -17,6 +18,9 @@ struct SettingsView: View {
         }
         .themedList()
         .navigationTitle("Settings")
+        .sheet(isPresented: $showAccountSwitcher) {
+            AccountSwitcherView()
+        }
         .alert(
             pendingDestructiveAction?.title ?? "",
             isPresented: Binding(
@@ -98,8 +102,8 @@ struct SettingsView: View {
             }
             .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
 
-            NavigationLink {
-                AccountSwitcherView()
+            Button {
+                showAccountSwitcher = true
             } label: {
                 Label("Manage Accounts", systemImage: "person.2")
             }
@@ -176,6 +180,7 @@ private enum SettingsDestructiveAction {
 }
 
 private struct AboutView: View {
+    @Environment(AppState.self) private var appState
     private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
         ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
         ?? "Hutch"
@@ -183,6 +188,17 @@ private struct AboutView: View {
         ?? "Unknown"
     private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
         ?? "Unknown"
+    @State private var developerRevealCount = 0
+
+    private var developerToolsVisible: Bool {
+        appState.isDebugModeEnabled || developerRevealCount >= 5
+    }
+
+    private var developerRevealFooterText: String {
+        developerRevealCount >= 5
+            ? "Debug toggle unlocked. Scroll down to Developer to enable it."
+            : "Tap the build number 5 times to reveal the debug toggle."
+    }
 
     var body: some View {
         Form {
@@ -197,7 +213,15 @@ private struct AboutView: View {
                 .padding(.vertical, 4)
 
                 LabeledContent("Version", value: version)
+                    .onTapGesture {
+                        developerRevealCount = min(developerRevealCount + 1, 5)
+                    }
                 LabeledContent("Build", value: build)
+                    .onTapGesture {
+                        developerRevealCount = min(developerRevealCount + 1, 5)
+                    }
+            } footer: {
+                Text(developerRevealFooterText)
             }
 
             Section("Links") {
@@ -233,6 +257,19 @@ private struct AboutView: View {
                     .font(.subheadline)
                     .foregroundStyle(.secondary)
             }
+
+            if developerToolsVisible {
+                Section {
+                    Toggle("Debug Mode", isOn: Binding(
+                        get: { appState.isDebugModeEnabled },
+                        set: { appState.isDebugModeEnabled = $0 }
+                    ))
+                } header: {
+                    Text("Developer")
+                } footer: {
+                    Text("Shows raw API payloads and diagnostic details on builds and tickets screens. This stays hidden until explicitly enabled.")
+                }
+            }
         }
         .themedList()
         .navigationTitle("About")
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 4bea3ae..f85a0c1 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -10,6 +10,7 @@ struct TicketDetailView: View {
 
     @Environment(AppState.self) private var appState
     @Environment(\.colorScheme) private var colorScheme
+    @Environment(\.openURL) private var openURL
     @State private var viewModel: TicketDetailViewModel?
 
     // Sheet state
@@ -47,7 +48,7 @@ struct TicketDetailView: View {
                     Image(systemName: "square.and.arrow.up")
                 }
 
-                if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser {
+                if let viewModel, viewModel.ticket != nil {
                     actionsMenu(viewModel)
                 }
             }
@@ -63,7 +64,7 @@ struct TicketDetailView: View {
                     client: appState.client
                 )
                 viewModel = vm
-                await vm.loadTicket()
+                await reloadDetail(vm)
             }
         }
     }
@@ -73,6 +74,35 @@ struct TicketDetailView: View {
     @ViewBuilder
     private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
         Menu {
+            if let ticketURL = SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) {
+                Button {
+                    openURL(ticketURL)
+                } label: {
+                    SwiftUI.Label("Open in Browser", systemImage: "safari")
+                }
+
+                Button {
+                    appState.copyToPasteboard(ticketURL.absoluteString, label: "ticket URL")
+                } label: {
+                    SwiftUI.Label("Copy URL", systemImage: "doc.on.doc")
+                }
+            }
+
+            Button {
+                appState.copyToPasteboard(String(ticketId), label: "ticket ID")
+            } label: {
+                SwiftUI.Label("Copy Ticket ID", systemImage: "number")
+            }
+
+            Button {
+                appState.copyToPasteboard(trackerRid, label: "tracker RID")
+            } label: {
+                SwiftUI.Label("Copy Tracker RID", systemImage: "number")
+            }
+
+            if isOwnedByCurrentUser {
+                Divider()
+
             if let ticket = viewModel.ticket {
                 if ticket.status == .resolved {
                     Button {
@@ -103,6 +133,7 @@ struct TicketDetailView: View {
             } label: {
                 SwiftUI.Label("Manage Labels", systemImage: "tag")
             }
+            }
         } label: {
             Image(systemName: "ellipsis.circle")
         }
@@ -133,7 +164,7 @@ struct TicketDetailView: View {
             SRHTErrorStateView(
                 title: "Couldn't Load Ticket",
                 message: error,
-                retryAction: { await viewModel.loadTicket() }
+                retryAction: { await reloadDetail(viewModel) }
             )
         } else if let ticket = viewModel.ticket {
             ScrollView {
@@ -179,13 +210,19 @@ struct TicketDetailView: View {
                             .padding(.vertical, 12)
                     }
 
-                    // Comment input
+                    if appState.isDebugModeEnabled {
+                        Divider()
+                            .padding(.vertical, 12)
+
+                        debugSection(viewModel: viewModel, ticket: ticket)
+                    }
+
                     commentInput(viewModel)
                 }
             }
             .srhtErrorBanner(error: $vm.error)
             .refreshable {
-                await viewModel.loadTicket()
+                await reloadDetail(viewModel)
             }
         }
     }
@@ -363,6 +400,35 @@ struct TicketDetailView: View {
         .padding()
     }
 
+    @ViewBuilder
+    private func debugSection(viewModel: TicketDetailViewModel, ticket: TicketDetail) -> some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Text("Debug")
+                .font(.headline)
+                .padding(.horizontal)
+
+            VStack(alignment: .leading, spacing: 12) {
+                DebugTextBlock(
+                    title: "Diagnostics",
+                    content: """
+                    ticketId: \(ticket.id)
+                    trackerId: \(trackerId)
+                    trackerRid: \(trackerRid)
+                    status: \(ticket.status.rawValue)
+                    events: \(viewModel.events.count)
+                    url: \(SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticket.id)?.absoluteString ?? "unavailable")
+                    """
+                )
+
+                if let rawTicketResponse = viewModel.rawTicketResponse {
+                    DebugTextBlock(title: "Raw Response", content: rawTicketResponse)
+                }
+            }
+            .padding(.horizontal)
+            .padding(.bottom, 16)
+        }
+    }
+
     private func normalizedUsername(_ value: String) -> String {
         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
         return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
@@ -381,6 +447,14 @@ struct TicketDetailView: View {
             }
         }
     }
+
+    private func reloadDetail(_ viewModel: TicketDetailViewModel) async {
+        if appState.isDebugModeEnabled {
+            await viewModel.loadTicketWithDebugCapture()
+        } else {
+            await viewModel.loadTicket()
+        }
+    }
 }
 
 // MARK: - Self-Sizing Markdown Web View
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 999374b..483f749 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -97,6 +97,9 @@ private struct LabelsPage: Decodable, Sendable {
 @Observable
 @MainActor
 final class TicketDetailViewModel {
+    private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
+        "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)"
+    }
 
     let ownerUsername: String
     let trackerName: String
@@ -110,6 +113,7 @@ final class TicketDetailViewModel {
     private(set) var isSubmitting = false
     private(set) var isPerformingAction = false
     private(set) var trackerLabels: [TicketLabel] = []
+    private(set) var rawTicketResponse: String?
     var commentText = ""
     var error: String?
 
@@ -284,6 +288,7 @@ final class TicketDetailViewModel {
         guard !isLoading else { return }
         isLoading = true
         error = nil
+        rawTicketResponse = nil
 
         do {
             let result = try await client.execute(
@@ -317,6 +322,47 @@ final class TicketDetailViewModel {
         isLoading = false
     }
 
+    func loadTicketWithDebugCapture() async {
+        guard !isLoading else { return }
+        isLoading = true
+        error = nil
+
+        do {
+            let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
+            let result = try await client.executeAndCache(
+                service: .todo,
+                query: Self.detailQuery,
+                variables: [
+                    "rid": trackerRid,
+                    "ticketId": ticketId
+                ],
+                responseType: TicketDetailResponse.self,
+                cacheKey: cacheKey
+            )
+            rawTicketResponse = client.responseCache.get(forKey: cacheKey)
+                .flatMap { String(data: $0, encoding: .utf8) }
+            let payload = result.tracker.ticket
+            ticket = TicketDetail(
+                id: payload.id,
+                created: payload.created,
+                updated: payload.updated,
+                title: payload.title,
+                description: payload.description,
+                status: payload.status,
+                resolution: payload.resolution,
+                authenticity: payload.authenticity,
+                submitter: payload.submitter,
+                assignees: payload.assignees,
+                labels: payload.labels
+            )
+            events = payload.events.results.sorted(by: Self.timelineOrder)
+        } catch {
+            self.error = error.userFacingMessage
+        }
+
+        isLoading = false
+    }
+
     func submitComment() async {
         let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !text.isEmpty, !isSubmitting else { return }
@@ -378,7 +424,7 @@ final class TicketDetailViewModel {
                 responseType: UpdateStatusResponse.self
             )
             // Re-fetch the ticket to get updated status/resolution
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -412,7 +458,7 @@ final class TicketDetailViewModel {
                 responseType: AssignUserResponse.self
             )
             // Reload to reflect the change
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -457,7 +503,7 @@ final class TicketDetailViewModel {
                 ],
                 responseType: AssignUserResponse.self
             )
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             ticket = TicketDetail(
                 id: currentTicket.id,
@@ -505,7 +551,7 @@ final class TicketDetailViewModel {
                 responseType: UnassignUserResponse.self
             )
             // Reload to reflect the change
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -529,7 +575,7 @@ final class TicketDetailViewModel {
                 ],
                 responseType: LabelTicketResponse.self
             )
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -553,7 +599,7 @@ final class TicketDetailViewModel {
                 ],
                 responseType: UnlabelTicketResponse.self
             )
-            await loadTicket()
+            await reloadTicketPreservingDebugState()
         } catch {
             self.error = error.userFacingMessage
         }
@@ -600,6 +646,14 @@ final class TicketDetailViewModel {
         isPerformingAction = false
     }
 
+    private func reloadTicketPreservingDebugState() async {
+        if rawTicketResponse != nil {
+            await loadTicketWithDebugCapture()
+        } else {
+            await loadTicket()
+        }
+    }
+
     static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
         let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
         let userCanonical = normalizedCanonicalName(user.canonicalName)
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index ccd06fe..f7529fb 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -7,6 +7,7 @@ struct TicketListView: View {
     @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
     @Environment(AppState.self) private var appState
     @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
     @State private var tracker: TrackerSummary
     @State private var viewModel: TicketListViewModel?
     @State private var trackerManagementViewModel: TrackerManagementViewModel?
@@ -86,9 +87,7 @@ struct TicketListView: View {
                             viewModel?.setSelectionMode(true)
                         }
 
-                        if isOwnedByCurrentUser {
-                            trackerActionsMenu
-                        }
+                        trackerActionsMenu
                     }
                 }
             }
@@ -296,6 +295,27 @@ struct TicketListView: View {
                             } label: {
                                 TicketRowView(ticket: ticket)
                             }
+                            .contextMenu {
+                                if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) {
+                                    Button {
+                                        openURL(url)
+                                    } label: {
+                                        Label("Open in Browser", systemImage: "safari")
+                                    }
+
+                                    Button {
+                                        appState.copyToPasteboard(url.absoluteString, label: "ticket URL")
+                                    } label: {
+                                        Label("Copy URL", systemImage: "doc.on.doc")
+                                    }
+                                }
+
+                                Button {
+                                    appState.copyToPasteboard(String(ticket.id), label: "ticket ID")
+                                } label: {
+                                    Label("Copy Ticket ID", systemImage: "number")
+                                }
+                            }
                             .swipeActions(edge: .leading, allowsFullSwipe: true) {
                                 if swipeActionsEnabled {
                                     ticketAssignSwipeAction(ticket, viewModel: viewModel)
@@ -409,6 +429,35 @@ struct TicketListView: View {
 
     private var trackerActionsMenu: some View {
         Menu {
+            if let trackerURL = SRHTWebURL.tracker(tracker) {
+                Button {
+                    openURL(trackerURL)
+                } label: {
+                    Label("Open in Browser", systemImage: "safari")
+                }
+
+                Button {
+                    appState.copyToPasteboard(trackerURL.absoluteString, label: "tracker URL")
+                } label: {
+                    Label("Copy URL", systemImage: "doc.on.doc")
+                }
+            }
+
+            Button {
+                appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
+            } label: {
+                Label("Copy Tracker ID", systemImage: "number")
+            }
+
+            Button {
+                appState.copyToPasteboard(tracker.rid, label: "tracker RID")
+            } label: {
+                Label("Copy RID", systemImage: "number")
+            }
+
+            if isOwnedByCurrentUser {
+                Divider()
+
             Button {
                 showTrackerEditor = true
             } label: {
@@ -432,12 +481,17 @@ struct TicketListView: View {
             } label: {
                 Label("Delete Tracker", systemImage: "trash")
             }
+            }
         } label: {
             Image(systemName: "ellipsis.circle")
         }
         .accessibilityLabel("Tracker actions")
     }
 
+    private func ownerUsername(for tracker: TrackerSummary) -> String {
+        tracker.owner.canonicalName.srhtUsername
+    }
+
     @ViewBuilder
     private func ticketAssignSwipeAction(
         _ ticket: TicketSummary,
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index 50604b8..db0fdf2 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -224,6 +224,9 @@ struct TrackerListView: View {
 // MARK: - Tracker Row
 
 private struct TrackerRowView: View {
+    @Environment(AppState.self) private var appState
+    @Environment(\.openURL) private var openURL
+
     let tracker: TrackerSummary
 
     var body: some View {
@@ -255,5 +258,32 @@ private struct TrackerRowView: View {
                 .foregroundStyle(.tertiary)
         }
         .padding(.vertical, 2)
+        .contextMenu {
+            if let url = SRHTWebURL.tracker(tracker) {
+                Button {
+                    openURL(url)
+                } label: {
+                    Label("Open in Browser", systemImage: "safari")
+                }
+
+                Button {
+                    appState.copyToPasteboard(url.absoluteString, label: "tracker URL")
+                } label: {
+                    Label("Copy URL", systemImage: "doc.on.doc")
+                }
+            }
+
+            Button {
+                appState.copyToPasteboard(String(tracker.id), label: "tracker ID")
+            } label: {
+                Label("Copy Tracker ID", systemImage: "number")
+            }
+
+            Button {
+                appState.copyToPasteboard(tracker.rid, label: "tracker RID")
+            } label: {
+                Label("Copy RID", systemImage: "number")
+            }
+        }
     }
 }
diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift
index 15f242d..a183f00 100644
--- a/HutchTests/SRHTWebURLTests.swift
+++ b/HutchTests/SRHTWebURLTests.swift
@@ -3,10 +3,44 @@ import Testing
 @testable import Hutch
 
 struct SRHTWebURLTests {
+    private let repository = RepositorySummary(
+        id: 1,
+        rid: "repo-1",
+        service: .git,
+        name: "hutch",
+        description: nil,
+        visibility: .public,
+        updated: .distantPast,
+        owner: Entity(canonicalName: "~ccleberg"),
+        head: nil
+    )
+    private let tracker = TrackerSummary(
+        id: 2,
+        rid: "tracker-1",
+        name: "todo",
+        description: nil,
+        visibility: .public,
+        updated: .distantPast,
+        owner: Entity(canonicalName: "~ccleberg")
+    )
 
     @Test
     func browserOnlyServiceURLsUseCanonicalHosts() {
         #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
         #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
     }
+
+    @Test
+    func repositoryAndCloneURLsUseStableUserScopedPaths() {
+        #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch")
+        #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch")
+        #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch")
+    }
+
+    @Test
+    func trackerTicketAndBuildURLsUseStableUserScopedPaths() {
+        #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo")
+        #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42")
+        #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12")
+    }
 }
diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
index 5dbf6a2..4245230 100644
--- a/HutchWidgetExtension/ContributionGraphWidget.swift
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -154,15 +154,19 @@ private struct ContributionGraphWidgetView: View {
 
     private func displayedWeeks(columnCount: Int) -> [ContributionGraphWeek] {
         var baseWeeks = switch entry.state {
-        case .populated, .indexing, .empty:
+        case .populated, .indexing:
             entry.weeks
+        case .empty:
+            ContributionGraphSampleData.emptyWeeks
         case .placeholder, .disabled, .unavailable:
             ContributionGraphSampleData.placeholderWeeks
         }
 
-        // Drop any trailing week where every day has zero contributions
-        while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) {
-            baseWeeks.removeLast()
+        if entry.state != .empty {
+            // Drop any trailing week where every day has zero contributions.
+            while let last = baseWeeks.last, last.days.allSatisfy({ $0.count == 0 }) {
+                baseWeeks.removeLast()
+            }
         }
 
         return Array(baseWeeks.suffix(columnCount))
@@ -182,6 +186,11 @@ private struct ContributionGraphGridView: View {
                         RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
                             .fill((day?.intensity ?? .empty).color)
                             .frame(width: squareSize, height: squareSize)
+                            .overlay {
+                                let intensity = day?.intensity ?? .empty
+                                RoundedRectangle(cornerRadius: squareSize * 0.2, style: .continuous)
+                                    .stroke(Color.primary.opacity(intensity == .empty ? 0.08 : 0), lineWidth: 0.5)
+                            }
                     }
                 }
             }
@@ -420,6 +429,17 @@ private enum ContributionGraphDateParser {
 }
 
 private enum ContributionGraphSampleData {
+    static let emptyWeeks: [ContributionGraphWeek] = {
+        let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
+        let days = (0..<371).compactMap { offset -> ContributionGraphDay? in
+            guard let date = Calendar.contributionCalendar.date(byAdding: .day, value: -offset, to: startDate) else {
+                return nil
+            }
+            return ContributionGraphDay(date: date, count: 0, score: 0)
+        }
+        return ContributionGraphLayout.weekColumns(from: days)
+    }()
+
     static let placeholderWeeks: [ContributionGraphWeek] = {
         let startDate = Calendar.contributionCalendar.startOfDay(for: .now)
         let days = (0..<150).compactMap { offset -> ContributionGraphDay? in