diff options
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 |
