summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 14:20:27 -0500
committerChristian Cleberg <[email protected]>2026-04-13 14:20:27 -0500
commit5e63048ab6a741ae947f7fe42efcb5206a3f3963 (patch)
treecf8eb2e3bc3b2edb20ef96a2599e684378120e5b /Hutch/Views/Repositories
parent0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50 (diff)
downloadhutch-3.0.4.tar.gz
hutch-3.0.4.tar.bz2
hutch-3.0.4.zip
fix: mercurial parity and graphql handlingv3.0.4
- implements consistent UX between repo types - ensures centralized services and routes are used between repo types - graphql error handling is centralized - error messages are consistent Implements: https://todo.sr.ht/~ccleberg/hutch/58 Implements: https://todo.sr.ht/~ccleberg/hutch/59
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift49
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift19
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsView.swift54
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift37
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift11
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift4
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift9
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift46
8 files changed, 157 insertions, 72 deletions
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
index ac7c04f..8362467 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -7,6 +7,7 @@ struct HgRepositoryDetailView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
@Environment(\.colorScheme) private var colorScheme
@AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
@@ -99,6 +100,7 @@ struct HgRepositoryDetailView: View {
async let log: () = vm.loadLog()
_ = await (summary, browse, log)
}
+ RecentActivityStore.recordRepository(repository, defaults: appState.accountDefaults)
}
}
@@ -132,6 +134,42 @@ struct HgRepositoryDetailView: View {
}
}
+ Divider()
+
+ if let repositoryURL = SRHTWebURL.repository(repository) {
+ 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(repository) {
+ Button {
+ appState.copyToPasteboard(httpsURL, label: "HTTPS clone URL")
+ } label: {
+ Label("Copy HTTPS URL", systemImage: "doc.on.doc")
+ }
+ }
+
+ Button {
+ 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")
+ }
+
if canManageRepository {
Divider()
@@ -227,10 +265,12 @@ struct HgRepositoryDetailView: View {
@ViewBuilder
private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
VStack(alignment: .leading, spacing: 10) {
- SummaryMetadataRow(
- icon: "arrow.triangle.branch",
- title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
- )
+ if let branchLabel = repositoryPrimaryBranchLabel(for: repository, hgTipBranch: viewModel.tip?.branch) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: branchLabel
+ )
+ }
if let readmePath = viewModel.readmePath {
SummaryMetadataRow(
@@ -244,6 +284,7 @@ struct HgRepositoryDetailView: View {
private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(repository.service))
SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
index 5a0298c..d8303e1 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
@@ -526,20 +526,9 @@ final class HgRepositoryDetailViewModel {
}
private func isEmptyRepositoryError(_ error: Error) -> Bool {
- if let srhtError = error as? SRHTError,
- case .graphQLErrors(let errors) = srhtError {
- return errors.contains {
- let message = $0.message.localizedLowercase
- return message.contains("missing")
- || message.contains("not found")
- || message.contains("unknown revision")
- || message.contains("unknown revision or path not in the working tree")
- }
- }
-
- let message = error.localizedDescription.localizedLowercase
- return message.contains("missing")
- || message.contains("not found")
- || message.contains("unknown revision")
+ error.matchesGraphQLErrorClassification(.notFound)
+ || error.matchesGraphQLErrorClassification(.unknownRevision)
+ || error.matchesGraphQLErrorClassification(.noRows)
+ || error.containsGraphQLErrorMessage("missing")
}
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
index 6a27465..a6aa728 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
@@ -9,7 +9,6 @@ struct HgRepositorySettingsView: View {
@State private var viewModel: HgRepositorySettingsViewModel?
@State private var showDeleteConfirmation = false
@State private var pendingACLDeletion: HgACLEntry?
- @State private var saveResultAlert: SaveResultAlert?
var body: some View {
NavigationStack {
@@ -93,23 +92,26 @@ struct HgRepositorySettingsView: View {
Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
}
}
- .alert(item: $saveResultAlert) { alert in
- Alert(
- title: Text(alert.title),
- message: Text(alert.message),
- dismissButton: .default(Text("OK"))
- )
- }
}
@ViewBuilder
private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
- Section("Info") {
- LabeledContent("Name") {
- Text(repository.name)
+ Section("Current Configuration") {
+ LabeledContent("Repository") {
+ Text("\(repository.owner.canonicalName)/\(repository.name)")
.font(.body.monospaced())
}
+ LabeledContent("Forge") {
+ Text(repositoryForgeLabel(repository.service))
+ }
+
+ LabeledContent("Visibility") {
+ Text(repositoryVisibilityLabel(viewModel.editedVisibility))
+ }
+ }
+
+ Section("Repository Details") {
TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
.lineLimit(3...6)
@@ -121,11 +123,7 @@ struct HgRepositorySettingsView: View {
Button {
Task {
- let didSave = await viewModel.saveInfo()
- saveResultAlert = SaveResultAlert(
- title: didSave ? "Settings Updated" : "Couldn't Update Settings",
- message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
- )
+ _ = await viewModel.saveInfo()
}
} label: {
if viewModel.isSavingInfo {
@@ -136,7 +134,7 @@ struct HgRepositorySettingsView: View {
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isSavingInfo)
+ .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
}
}
@@ -202,16 +200,15 @@ struct HgRepositorySettingsView: View {
@ViewBuilder
private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
- Section("Features") {
+ Section("Sensitive Settings") {
Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
+ Text("Changes stay pending until you save this section.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
Button {
Task {
- let didSave = await viewModel.saveInfo()
- saveResultAlert = SaveResultAlert(
- title: didSave ? "Settings Updated" : "Couldn't Update Settings",
- message: didSave ? "Repository settings were saved." : (viewModel.error ?? "Please try again.")
- )
+ _ = await viewModel.saveInfo()
}
} label: {
if viewModel.isSavingInfo {
@@ -222,7 +219,7 @@ struct HgRepositorySettingsView: View {
.frame(maxWidth: .infinity)
}
}
- .disabled(viewModel.isSavingInfo)
+ .disabled(viewModel.isSavingInfo || !viewModel.isInfoDirty)
}
}
@@ -261,13 +258,8 @@ struct HgRepositorySettingsView: View {
}
}
.disabled(viewModel.isDeleting)
+ } header: {
+ Text("Danger Zone")
}
}
-
- private struct SaveResultAlert: Identifiable {
- let title: String
- let message: String
-
- var id: String { "\(title)-\(message)" }
- }
}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
index a1a11ae..7b8d7f7 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -64,11 +64,14 @@ final class HgRepositorySettingsViewModel {
let repositoryRid: String
let repositoryName: String
private let client: SRHTClient
+ private var initialDescription: String
+ private var initialVisibility: Visibility
var editedDescription: String
var editedVisibility: Visibility
var editedNonPublishing: Bool
var isSavingInfo = false
+ private(set) var loadedNonPublishing = false
private(set) var acls: [HgACLEntry] = []
private(set) var isLoadingACLs = false
@@ -82,12 +85,25 @@ final class HgRepositorySettingsViewModel {
var didDelete = false
var error: String?
+ var normalizedEditedDescription: String {
+ editedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var isInfoDirty: Bool {
+ normalizedEditedDescription != initialDescription ||
+ editedVisibility != initialVisibility ||
+ editedNonPublishing != loadedNonPublishing
+ }
+
init(repository: RepositorySummary, client: SRHTClient) {
self.repositoryId = repository.id
self.repositoryRid = repository.rid
self.repositoryName = repository.name
self.client = client
- self.editedDescription = repository.description ?? ""
+ let description = repository.description ?? ""
+ self.initialDescription = description
+ self.initialVisibility = repository.visibility
+ self.editedDescription = description
self.editedVisibility = repository.visibility
self.editedNonPublishing = false
}
@@ -159,9 +175,14 @@ final class HgRepositorySettingsViewModel {
)
if let repository = result.repository {
- editedDescription = repository.description ?? ""
+ let description = repository.description ?? ""
+ initialDescription = description
+ initialVisibility = repository.visibility
+ editedDescription = description
editedVisibility = repository.visibility
- editedNonPublishing = repository.nonPublishing ?? false
+ let nonPublishing = repository.nonPublishing ?? false
+ editedNonPublishing = nonPublishing
+ loadedNonPublishing = nonPublishing
}
} catch {
self.error = error.userFacingMessage
@@ -175,7 +196,7 @@ final class HgRepositorySettingsViewModel {
do {
let input: [String: any Sendable] = [
- "description": editedDescription,
+ "description": normalizedEditedDescription,
"visibility": editedVisibility.rawValue,
"nonPublishing": editedNonPublishing
]
@@ -185,6 +206,9 @@ final class HgRepositorySettingsViewModel {
variables: ["id": repositoryId, "input": input],
responseType: HgUpdateRepositoryResponse.self
)
+ initialDescription = normalizedEditedDescription
+ initialVisibility = editedVisibility
+ loadedNonPublishing = editedNonPublishing
return true
} catch {
self.error = error.userFacingMessage
@@ -237,9 +261,8 @@ final class HgRepositorySettingsViewModel {
}
newACLEntity = ""
} catch {
- let message = error.localizedDescription
- if message.localizedCaseInsensitiveContains("No such repository or user found") {
- self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
+ if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
+ self.error = error.userFacingMessage
} else {
self.error = error.userFacingMessage
}
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index f5c0c9f..21d7a74 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -54,10 +54,12 @@ struct ReadmeView: View {
@ViewBuilder
private var metadataSection: some View {
VStack(alignment: .leading, spacing: 10) {
- SummaryMetadataRow(
- icon: "arrow.triangle.branch",
- title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
- )
+ if let branchLabel = repositoryPrimaryBranchLabel(for: viewModel.repository) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: branchLabel
+ )
+ }
if let readmePath = viewModel.readmePath {
SummaryMetadataRow(
@@ -71,6 +73,7 @@ struct ReadmeView: View {
private var repositoryDetailsSection: some View {
DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Forge", value: repositoryForgeLabel(viewModel.repository.service))
SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 72c4d1f..ebd07d2 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -372,9 +372,7 @@ final class RepositoryDetailViewModel {
}
private func isMissingGitReferenceError(_ error: Error) -> Bool {
- guard let srhtError = error as? SRHTError else { return false }
- guard case .graphQLErrors(let errors) = srhtError else { return false }
- return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ error.matchesGraphQLErrorClassification(.missingReference)
}
// MARK: - Artifacts
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index 0da6cbc..afbd740 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -15,14 +15,7 @@ struct RepositoryRowView: View {
Spacer()
- if repository.service == .hg {
- Text("HG")
- .font(.caption2.weight(.medium))
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(Color.cyan.opacity(0.15), in: Capsule())
- .foregroundStyle(.cyan)
- }
+ RepositoryForgeBadge(service: repository.service)
if buildStatus != .none {
RepositoryBuildStatusIndicator(status: buildStatus)
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index 7861b40..741ff39 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -46,6 +46,28 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
}
}
+func repositoryForgeLabel(_ service: SRHTService) -> String {
+ switch service {
+ case .git:
+ return "GIT"
+ case .hg:
+ return "HG"
+ default:
+ return service.rawValue.uppercased()
+ }
+}
+
+func repositoryPrimaryBranchLabel(for repository: RepositorySummary, hgTipBranch: String? = nil) -> String? {
+ switch repository.service {
+ case .git:
+ return repository.defaultBranchName
+ case .hg:
+ return hgTipBranch ?? repository.defaultBranchName ?? "tip"
+ default:
+ return repository.defaultBranchName
+ }
+}
+
struct SummaryMetadataRow: View {
let icon: String
let title: String
@@ -85,3 +107,27 @@ struct SummaryDetailRow: View {
}
}
}
+
+struct RepositoryForgeBadge: View {
+ let service: SRHTService
+
+ var body: some View {
+ Text(repositoryForgeLabel(service))
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.15), in: Capsule())
+ .foregroundStyle(color)
+ }
+
+ private var color: Color {
+ switch service {
+ case .git:
+ .indigo
+ case .hg:
+ .cyan
+ default:
+ .secondary
+ }
+ }
+}