summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-16 01:29:06 -0500
committerGitHub <[email protected]>2026-07-16 01:29:06 -0500
commit8e585a709d8979d493fef3ed4015db93687f48e8 (patch)
treefaebe598a4c6f7a5f3db92d465ed693aab594252 /Hutch/Views/Repositories
parentf100206cc6d6784563d8eb905c9feb15c58bcc1e (diff)
parent21be03e6ca54c1a15607faab905b119eda9a548f (diff)
downloadhutch-8e585a709d8979d493fef3ed4015db93687f48e8.tar.gz
hutch-8e585a709d8979d493fef3ed4015db93687f48e8.tar.bz2
hutch-8e585a709d8979d493fef3ed4015db93687f48e8.zip
Merge pull request #6 from zerolabsco/phase-3-api-features
Phase 3: API features
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift154
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift2
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift151
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift5
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift5
5 files changed, 306 insertions, 11 deletions
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index b8caf4c..69da0d6 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -1,24 +1,146 @@
import SwiftUI
+import UniformTypeIdentifiers
struct ArtifactsView: View {
let viewModel: RepositoryDetailViewModel
- @Environment(\.openURL) private var openURL
+ /// Passed in rather than recomputed: RepositoryDetailView already owns this
+ /// check and gates its other management surfaces on it.
+ var canManage: Bool = false
+
+ @State private var uploadTargetRef: String?
+ @State private var isImporting = false
+ @State private var pendingDeletion: ArtifactInfo?
+ @State private var downloadedFile: DownloadedArtifact?
+
+ private var isOwnedByCurrentUser: Bool { canManage }
+
+ /// A menu rather than a confirmation dialog: this view already presents one
+ /// for delete, and two .confirmationDialog modifiers on the same view leave
+ /// one of them silently dead. A menu also puts the tags one tap away.
+ @ViewBuilder
+ private var uploadMenu: some View {
+ Menu {
+ if viewModel.tags.isEmpty {
+ Text("This repository has no tags")
+ } else {
+ ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
+ Button(RepositorySummary.displayBranchName(for: tag.name)) {
+ uploadTargetRef = tag.name
+ isImporting = true
+ }
+ }
+ }
+ } label: {
+ SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
+ }
+ // Deliberately not disabled when there are no tags. The explanation for
+ // that state lives inside the menu, and disabling the control makes the
+ // explanation unreachable — the tap just dies with no reason given.
+ .disabled(viewModel.isMutatingArtifact)
+ }
var body: some View {
- List {
+ @Bindable var vm = viewModel
+
+ return List {
+ // In the list rather than the toolbar: this view is a segment inside
+ // RepositoryDetailView's tab switch, not its own navigation
+ // destination, and a toolbar declared from there does not reliably
+ // reach the navigation bar. It also has to be reachable when there are
+ // no artifacts at all, which is the state a new tag is in.
+ if isOwnedByCurrentUser {
+ uploadMenu
+ .themedRow()
+ }
+
ForEach(viewModel.referenceArtifacts) { refArtifacts in
- Section(refArtifacts.name) {
+ Section {
ForEach(refArtifacts.artifacts) { artifact in
ArtifactRow(artifact: artifact) {
- openURL(artifact.url)
+ Task {
+ // Artifact.url is on the API origin and 401s
+ // without a bearer token, so it cannot be handed
+ // to a browser. Fetch it and share the file.
+ if let fileURL = await viewModel.downloadArtifact(artifact) {
+ downloadedFile = DownloadedArtifact(url: fileURL)
+ }
+ }
+ }
+ // See MailingListListView: a full-swipe destructive
+ // action animates the row out before the confirmation.
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if isOwnedByCurrentUser {
+ Button {
+ pendingDeletion = artifact
+ } label: {
+ SwiftUI.Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
}
}
.themedRow()
+ } header: {
+ HStack {
+ Text(refArtifacts.name)
+ if isOwnedByCurrentUser {
+ Spacer()
+ // Upload targets a specific tag, so the control belongs
+ // on the tag rather than in the toolbar.
+ Button {
+ uploadTargetRef = refArtifacts.name
+ isImporting = true
+ } label: {
+ SwiftUI.Label("Upload", systemImage: "plus.circle")
+ .font(.caption)
+ }
+ .disabled(viewModel.isMutatingArtifact)
+ }
+ }
}
}
}
+ // isImporting drives presentation; uploadTargetRef carries the tag. They
+ // have to be separate: a binding derived from uploadTargetRef clears it on
+ // dismissal, and dismissal happens before the completion runs — so the
+ // completion read nil and returned without uploading anything.
+ .fileImporter(
+ isPresented: $isImporting,
+ allowedContentTypes: [.data]
+ ) { result in
+ let revspec = uploadTargetRef
+ uploadTargetRef = nil
+ guard let revspec, case .success(let fileURL) = result else { return }
+ Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
+ }
+ .confirmationDialog(
+ pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
+ isPresented: .init(
+ get: { pendingDeletion != nil },
+ set: { if !$0 { pendingDeletion = nil } }
+ ),
+ titleVisibility: .visible,
+ presenting: pendingDeletion
+ ) { artifact in
+ Button("Delete Artifact", role: .destructive) {
+ Task { await viewModel.deleteArtifact(id: artifact.id) }
+ }
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ } message: { _ in
+ Text("This permanently removes the artifact from the tag. This cannot be undone.")
+ }
.themedList()
.listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .sheet(item: $downloadedFile) { download in
+ FileContentShareSheet(activityItems: [download.url])
+ }
+ .task {
+ // Tags drive the picker above and are not otherwise needed by this tab.
+ if isOwnedByCurrentUser, viewModel.tags.isEmpty {
+ await viewModel.loadReferences()
+ }
+ }
.overlay {
if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
SRHTLoadingStateView(message: "Loading artifacts…")
@@ -29,11 +151,18 @@ struct ArtifactsView: View {
retryAction: { await viewModel.loadArtifacts() }
)
} else if viewModel.referenceArtifacts.isEmpty {
- ContentUnavailableView(
- "No Artifacts",
- systemImage: "archivebox",
- description: Text("This repository has no release artifacts.")
- )
+ // The overlay covers the whole list, so the upload row above is
+ // hidden underneath it — and a repository with no artifacts is
+ // exactly the one that needs uploading. Offer it here too.
+ ContentUnavailableView {
+ SwiftUI.Label("No Artifacts", systemImage: "archivebox")
+ } description: {
+ Text("This repository has no release artifacts.")
+ } actions: {
+ if isOwnedByCurrentUser {
+ uploadMenu
+ }
+ }
}
}
.task {
@@ -47,6 +176,13 @@ struct ArtifactsView: View {
}
}
+/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
+/// conforming a stdlib type retroactively is worse than a four-line struct.
+private struct DownloadedArtifact: Identifiable {
+ let id = UUID()
+ let url: URL
+}
+
private struct ArtifactRow: View {
let artifact: ArtifactInfo
let onDownload: () -> Void
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 6e7343f..8f466ba 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -121,7 +121,7 @@ struct RepositoryDetailView: View {
case .refs:
ReferencesListView(viewModel: viewModel)
case .artifacts:
- ArtifactsView(viewModel: viewModel)
+ ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 4839437..9b6b942 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable {
let text: String?
}
+private struct UploadArtifactResponse: Decodable, Sendable {
+ let uploadArtifact: ArtifactInfo
+}
+
+private struct DeleteArtifactResponse: Decodable, Sendable {
+ /// Nullable in the schema: sr.ht returns null when there was no artifact to
+ /// remove, which is still a success from the caller's point of view.
+ let deleteArtifact: ArtifactIDPayload?
+}
+
+private struct ArtifactIDPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct ArtifactsResponse: Decodable, Sendable {
let repository: ArtifactsRepository?
}
@@ -144,6 +158,7 @@ final class RepositoryDetailViewModel {
private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
private(set) var isLoadingArtifacts = false
+ private(set) var isMutatingArtifact = false
// MARK: - Error
@@ -457,6 +472,26 @@ final class RepositoryDetailViewModel {
// MARK: - Artifacts
+ /// `file` is a top-level Upload variable here, unlike meta's avatar upload
+ /// where it is nested inside an input object.
+ private static let uploadArtifactMutation = """
+ mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) {
+ uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) {
+ id
+ filename
+ checksum
+ size
+ url
+ }
+ }
+ """
+
+ private static let deleteArtifactMutation = """
+ mutation deleteArtifact($id: Int!) {
+ deleteArtifact(id: $id) { id }
+ }
+ """
+
private static let artifactsQuery = """
query artifacts($rid: ID!) {
repository(rid: $rid) {
@@ -480,6 +515,122 @@ final class RepositoryDetailViewModel {
}
"""
+ /// Attaches a file to the tag named by `revspec`.
+ ///
+ /// sr.ht requires the filename to be unique among the repository's artifacts,
+ /// and rejects a duplicate rather than replacing it, so the error is surfaced
+ /// as-is rather than being retried.
+ @discardableResult
+ func uploadArtifact(revspec: String, fileURL: URL) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ let needsScopedAccess = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if needsScopedAccess {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let fileData: Data
+ do {
+ fileData = try Data(contentsOf: fileURL)
+ } catch {
+ self.error = "Couldn't read \(fileURL.lastPathComponent)."
+ return false
+ }
+
+ // sr.ht streams the upload into S3, which rejects a zero-part multipart
+ // completion with "MalformedXML" — an error that says nothing about the
+ // actual problem. Catch it here where we can name it.
+ guard !fileData.isEmpty else {
+ self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts."
+ return false
+ }
+
+ do {
+ _ = try await client.executeMultipart(
+ service: service,
+ query: Self.uploadArtifactMutation,
+ variables: [
+ "repoId": repository.id,
+ "revspec": revspec,
+ "file": nil as String? as Any
+ ],
+ file: MultipartUploadFile(
+ variablePath: "file",
+ fileData: fileData,
+ fileName: fileURL.lastPathComponent,
+ mimeType: Self.mimeType(for: fileURL)
+ ),
+ responseType: UploadArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ /// Downloads an artifact and returns a local file URL to share.
+ ///
+ /// `Artifact.url` points at the API origin, not the web one, and returns an
+ /// auth error to anything without a bearer token — so it cannot be opened in
+ /// a browser. Fetch it here and hand the user the file instead.
+ func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? {
+ guard !isMutatingArtifact else { return nil }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ let data = try await client.fetchData(url: artifact.url)
+ let destination = FileManager.default.temporaryDirectory
+ .appendingPathComponent(artifact.filename)
+ try data.write(to: destination, options: .atomic)
+ return destination
+ } catch {
+ self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)"
+ return nil
+ }
+ }
+
+ @discardableResult
+ func deleteArtifact(id: Int) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ _ = try await client.execute(
+ service: service,
+ query: Self.deleteArtifactMutation,
+ variables: ["id": id],
+ responseType: DeleteArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't delete the artifact. \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ private func reloadArtifacts() async {
+ isLoadingArtifacts = false
+ await loadArtifacts()
+ }
+
+ /// Artifacts are release tarballs and signatures rather than media, so a
+ /// generic binary type is honest more often than guessing from the extension.
+ private nonisolated static func mimeType(for url: URL) -> String {
+ "application/octet-stream"
+ }
+
func loadArtifacts() async {
guard !isLoadingArtifacts else { return }
isLoadingArtifacts = true
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 695abf8..b3669e6 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -189,7 +189,10 @@ final class RepositoryListViewModel {
filteredResults = []
}
} else {
- let repositories = try await fetchAllRepositories(useCache: true)
+ // forceRefresh used to reach only the build statuses, so a pull to
+ // refresh re-served the cached list and a deleted repository stayed
+ // on screen.
+ let repositories = try await fetchAllRepositories(useCache: !forceRefresh)
updateSearchIndex(with: repositories)
filteredResults = repositories
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index dd8d7a1..0b31125 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -231,6 +231,11 @@ final class RepositorySettingsViewModel {
variables: ["id": repositoryId],
responseType: DeleteRepositoryResponse.self
)
+ // The list and Home are both served from cache, so without this the
+ // repository lingers on screen after it no longer exists. Creation
+ // already does this; deletion never did.
+ await client.invalidateCache(prefix: APICacheKeys.prefix(service.rawValue, "repositories"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
didDelete = true
} catch {
self.error = error.userFacingMessage