summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppState.swift33
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/App/RootView.swift7
-rw-r--r--Hutch/Extensions/PowerUserActions.swift40
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift14
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift76
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift49
-rw-r--r--Hutch/Views/Builds/BuildListView.swift23
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift5
-rw-r--r--Hutch/Views/Home/HomeView.swift9
-rw-r--r--Hutch/Views/Repositories/CommitDetailView.swift36
-rw-r--r--Hutch/Views/Repositories/CommitLogView.swift2
-rw-r--r--Hutch/Views/Repositories/CommitRowView.swift17
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift23
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift75
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift45
-rw-r--r--Hutch/Views/Settings/SettingsView.swift41
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift84
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift66
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift60
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift30
-rw-r--r--HutchTests/SRHTWebURLTests.swift34
-rw-r--r--HutchWidgetExtension/ContributionGraphWidget.swift28
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) == "[email protected]:~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