summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:22 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:22 -0500
commit8f2057c53e9009c2529c9c4849c914666c0e4b40 (patch)
tree77b5a93625fe9ea98baf4ab0d137055524705dad /Hutch/Views/Repositories
parentd27726d8755a5631fbd2105f735811a95379c4ab (diff)
downloadhutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.gz
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.bz2
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.zip
create privacy policy
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift419
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsView.swift235
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift301
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift101
4 files changed, 1056 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
new file mode 100644
index 0000000..8e47d33
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -0,0 +1,419 @@
+import SwiftUI
+
+struct HgRepositoryDetailView: View {
+ let repository: RepositorySummary
+ let onDeleted: (() -> Void)?
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.colorScheme) private var colorScheme
+
+ @State private var viewModel: HgRepositoryDetailViewModel?
+ @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
+ @State private var showSettings = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(repository.name)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showSettings = true
+ } label: {
+ Image(systemName: "gear")
+ }
+ }
+ }
+ .sheet(isPresented: $showSettings) {
+ HgRepositorySettingsView(
+ repository: repository,
+ client: appState.client,
+ onDeleted: {
+ dismiss()
+ onDeleted?()
+ }
+ )
+ }
+ .task {
+ if viewModel == nil {
+ let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
+ viewModel = vm
+ async let summary: () = vm.loadSummary()
+ async let browse: () = vm.loadBrowseRoot()
+ async let log: () = vm.loadLog()
+ _ = await (summary, browse, log)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ Picker("Tab", selection: $selectedTab) {
+ ForEach(HgRepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
+ Text(tab.rawValue).tag(tab)
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+
+ Divider()
+
+ switch selectedTab {
+ case .summary:
+ summaryTab(viewModel)
+ case .browse:
+ browseTab(viewModel)
+ case .log:
+ logTab(viewModel)
+ case .tags:
+ revisionsList(viewModel.tags, emptyTitle: "No Tags", emptyDescription: "This repository does not have any tags.")
+ case .branches:
+ revisionsList(viewModel.branches, emptyTitle: "No Branches", emptyDescription: "This repository does not have any named branches.")
+ case .bookmarks:
+ revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
+ }
+ }
+ .alert("Error", isPresented: .constant(viewModel.error != nil)) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ if let error = viewModel.error {
+ Text(error)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ summaryCards(viewModel)
+
+ if let readmeView = readmeContentView(viewModel) {
+ readmeView
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
+ }
+ .padding()
+ }
+ .refreshable {
+ await viewModel.loadSummary()
+ }
+ }
+ }
+
+ private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ LabeledContent("Visibility", value: visibilityLabel(repository.visibility))
+ LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
+
+ if let description = repository.description, !description.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Description")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Text(description)
+ }
+ }
+
+ if let tip = viewModel.tip {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Tip")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Text(tip.title)
+ .font(.headline)
+ HStack {
+ Text(tip.displayShortId)
+ .font(.caption.monospaced())
+ Spacer()
+ Text(tip.author.time.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Text(tip.author.name)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding()
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+
+ @ViewBuilder
+ private func browseTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ browseBreadcrumbs(viewModel)
+ Divider()
+
+ if viewModel.isLoadingBrowse {
+ Spacer()
+ ProgressView()
+ Spacer()
+ } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
+ GeometryReader { geometry in
+ ScrollView([.vertical, .horizontal]) {
+ Text(fileContent)
+ .font(.system(.body, design: .monospaced))
+ .frame(
+ minWidth: geometry.size.width,
+ minHeight: geometry.size.height,
+ alignment: .topLeading
+ )
+ .padding()
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ Button("Back to directory") {
+ viewModel.dismissFileView()
+ }
+ .buttonStyle(.bordered)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity)
+ .background(.bar)
+ }
+ .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
+ } else if viewModel.files.isEmpty {
+ ContentUnavailableView(
+ "No Files",
+ systemImage: "folder",
+ description: Text("This revision does not contain any browsable files.")
+ )
+ } else {
+ List(viewModel.files) { file in
+ Label {
+ Text(displayFileName(file.name))
+ .font(.body.monospaced())
+ } icon: {
+ Image(systemName: file.isDirectory ? "folder.fill" : "doc")
+ .foregroundStyle(file.isDirectory ? .blue : .secondary)
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ Task { await viewModel.openFile(file) }
+ }
+ }
+ .listStyle(.plain)
+ }
+ }
+ .refreshable {
+ await viewModel.loadBrowseRoot()
+ }
+ }
+
+ private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 4) {
+ Button {
+ Task { await viewModel.navigateToPath(index: 0) }
+ } label: {
+ Text("root")
+ .font(.subheadline.monospaced())
+ }
+ .buttonStyle(.plain)
+
+ ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+
+ Button {
+ Task { await viewModel.navigateToPath(index: index + 1) }
+ } label: {
+ Text(component)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if let selectedFilePath = viewModel.selectedFilePath {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
+ .font(.subheadline.monospaced())
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .background(.bar)
+ }
+
+ @ViewBuilder
+ private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ List {
+ ForEach(viewModel.log) { revision in
+ revisionRow(revision)
+ .task {
+ await viewModel.loadMoreLogIfNeeded(currentItem: revision)
+ }
+ }
+
+ if viewModel.isLoadingMoreLog {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoadingLog {
+ ProgressView()
+ } else if viewModel.log.isEmpty {
+ ContentUnavailableView(
+ "No Revisions",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository has no revision history.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadLog()
+ }
+ }
+
+ @ViewBuilder
+ private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
+ if revisions.isEmpty {
+ ContentUnavailableView(
+ emptyTitle,
+ systemImage: "tray",
+ description: Text(emptyDescription)
+ )
+ } else {
+ List(revisions) { revision in
+ revisionRow(revision)
+ }
+ .listStyle(.plain)
+ }
+ }
+
+ private func revisionRow(_ revision: HgRevision) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(revision.primaryName)
+ .font(.headline)
+ Spacer()
+ Text(revision.displayShortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+
+ Text(revision.title)
+ .font(.subheadline)
+
+ if let body = revision.body {
+ Text(body)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack {
+ Text(revision.author.name)
+ Spacer()
+ Text(revision.author.time.relativeDescription)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+
+ @ViewBuilder
+ private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
+ let imageURLResolver = makeImageURLResolver(viewModel)
+
+ guard let content = viewModel.readmeContent else {
+ return nil
+ }
+
+ switch content {
+ case .html(let html):
+ return AnyView(
+ HTMLWebView(html: html, colorScheme: colorScheme)
+ .frame(minHeight: 400)
+ )
+ case .markdown(let text):
+ return AnyView(
+ HTMLWebView(
+ html: markdownToHTML(text, imageURLResolver: imageURLResolver),
+ colorScheme: colorScheme
+ )
+ .frame(minHeight: 400)
+ )
+ case .org(let text):
+ return AnyView(
+ HTMLWebView(
+ html: orgToHTML(text, imageURLResolver: imageURLResolver),
+ colorScheme: colorScheme
+ )
+ .frame(minHeight: 400)
+ )
+ case .plainText(let text):
+ return AnyView(
+ Text(text)
+ .font(.system(.body, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ )
+ }
+ }
+
+ private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? {
+ let owner = repository.owner.canonicalName
+ let repositoryName = repository.name
+ let readmePath = viewModel.readmePath
+
+ return { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: owner,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht")
+ }
+ }
+
+ private func displayFileName(_ name: String) -> String {
+ name.hasSuffix("/") ? String(name.dropLast()) : name
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
new file mode 100644
index 0000000..3db434c
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -0,0 +1,235 @@
+import SwiftUI
+
+struct RepositorySettingsView: View {
+ let repository: RepositorySummary
+ let branches: [Reference]
+ let client: SRHTClient
+ let onRenamed: (String) -> Void
+ let onDeleted: () -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositorySettingsViewModel?
+ @State private var showDeleteConfirmation = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ settingsForm(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = RepositorySettingsViewModel(
+ repository: repository,
+ branches: branches,
+ client: client
+ )
+ viewModel = vm
+ await vm.loadACLs()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingsForm(_ viewModel: RepositorySettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ infoSection(viewModel)
+ renameSection(viewModel)
+ accessSection(viewModel)
+ deleteSection(viewModel)
+ }
+ .alert("Error", isPresented: .constant(viewModel.error != nil)) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ if let error = viewModel.error {
+ Text(error)
+ }
+ }
+ .alert(
+ "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
+ isPresented: $showDeleteConfirmation
+ ) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ await viewModel.deleteRepository()
+ if viewModel.didDelete {
+ dismiss()
+ onDeleted()
+ }
+ }
+ }
+ } message: {
+ Text("This cannot be undone.")
+ }
+ }
+
+ // MARK: - Info Section
+
+ @ViewBuilder
+ private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section("Info") {
+ TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
+ .lineLimit(3...6)
+
+ Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+
+ if !viewModel.branches.isEmpty {
+ Picker("Default Branch", selection: Bindable(viewModel).editedHead) {
+ ForEach(viewModel.branches, id: \.name) { branch in
+ let name = branch.name.replacingOccurrences(of: "refs/heads/", with: "")
+ Text(name).tag(name)
+ }
+ }
+ }
+
+ Button {
+ Task { await viewModel.saveInfo() }
+ } label: {
+ if viewModel.isSavingInfo {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Save")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isSavingInfo)
+ }
+ }
+
+ // MARK: - Rename Section
+
+ @ViewBuilder
+ private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section {
+ TextField("Repository Name", text: Bindable(viewModel).editedName)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Text("This will change the repository URL. Existing clones will be redirected but links may break.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button {
+ Task {
+ await viewModel.rename()
+ if let newName = viewModel.updatedName {
+ onRenamed(newName)
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isRenaming {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Rename")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isRenaming || viewModel.editedName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ } header: {
+ Text("Rename")
+ }
+ }
+
+ // MARK: - Access Section
+
+ @ViewBuilder
+ private func accessSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingACLs {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if viewModel.acls.isEmpty {
+ Text("No access control entries.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.acls) { entry in
+ HStack {
+ Text(entry.entity.canonicalName)
+ Spacer()
+ Text(entry.mode)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ Button(role: .destructive) {
+ Task { await viewModel.deleteACL(entry) }
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ // Add ACL form
+ HStack {
+ TextField("Username", text: Bindable(viewModel).newACLEntity)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Picker("", selection: Bindable(viewModel).newACLMode) {
+ Text("RO").tag("RO")
+ Text("RW").tag("RW")
+ }
+ .pickerStyle(.segmented)
+ .frame(width: 100)
+
+ Button {
+ Task { await viewModel.addACL() }
+ } label: {
+ if viewModel.isAddingACL {
+ ProgressView()
+ } else {
+ Image(systemName: "plus.circle.fill")
+ }
+ }
+ .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ } header: {
+ Text("Access")
+ }
+ }
+
+ // MARK: - Delete Section
+
+ @ViewBuilder
+ private func deleteSection(_ viewModel: RepositorySettingsViewModel) -> some View {
+ Section {
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ if viewModel.isDeleting {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Delete Repository")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isDeleting)
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
new file mode 100644
index 0000000..bdae87f
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -0,0 +1,301 @@
+import Foundation
+
+// MARK: - Response types
+
+private struct UpdateRepoResponse: Decodable, Sendable {
+ let updateRepository: UpdatedRepo
+}
+
+private struct UpdatedRepo: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+}
+
+private struct ACLResponse: Decodable, Sendable {
+ let repository: ACLRepository?
+}
+
+private struct ACLRepository: Decodable, Sendable {
+ let acls: ACLPage
+}
+
+private struct ACLPage: Decodable, Sendable {
+ let results: [ACLEntry]
+ let cursor: String?
+}
+
+private struct UpdateACLResponse: Decodable, Sendable {
+ let updateACL: ACLEntry
+}
+
+private struct DeleteACLResponse: Decodable, Sendable {
+ let deleteACL: DeletedACL
+}
+
+private struct DeletedACL: Decodable, Sendable {
+ let id: Int
+}
+
+private struct DeleteRepoResponse: Decodable, Sendable {
+ let deleteRepository: DeletedRepo
+}
+
+private struct DeletedRepo: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - ACL Model
+
+struct ACLEntry: Decodable, Sendable, Identifiable {
+ let id: Int
+ let mode: String
+ let entity: Entity
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class RepositorySettingsViewModel {
+
+ let repositoryId: Int
+ let repositoryRid: String
+ private let client: SRHTClient
+
+ // MARK: - Info fields
+
+ var editedDescription: String
+ var editedVisibility: Visibility
+ var editedHead: String
+ var isSavingInfo = false
+
+ // MARK: - Rename fields
+
+ var editedName: String
+ var isRenaming = false
+
+ // MARK: - ACL state
+
+ private(set) var acls: [ACLEntry] = []
+ private(set) var isLoadingACLs = false
+ var newACLEntity = ""
+ var newACLMode = "RO"
+ var isAddingACL = false
+ var isDeletingACL = false
+
+ // MARK: - Delete state
+
+ var isDeleting = false
+
+ // MARK: - Branches (for HEAD picker)
+
+ var branches: [Reference]
+
+ // MARK: - Results
+
+ var error: String?
+ var updatedName: String?
+ var didDelete = false
+
+ init(
+ repository: RepositorySummary,
+ branches: [Reference],
+ client: SRHTClient
+ ) {
+ self.repositoryId = repository.id
+ self.repositoryRid = repository.rid
+ self.client = client
+ self.editedDescription = repository.description ?? ""
+ self.editedVisibility = repository.visibility
+ self.editedName = repository.name
+ self.branches = branches
+
+ // Extract branch name from HEAD reference
+ if let head = repository.head?.name {
+ self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
+ } else {
+ self.editedHead = "main"
+ }
+ }
+
+ // MARK: - Update Repository Info
+
+ private static let updateRepoMutation = """
+ mutation updateRepository($id: Int!, $input: RepoInput!) {
+ updateRepository(id: $id, input: $input) {
+ id rid name description visibility
+ }
+ }
+ """
+
+ func saveInfo() async {
+ isSavingInfo = true
+ defer { isSavingInfo = false }
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "description": editedDescription,
+ "visibility": editedVisibility.rawValue,
+ "HEAD": editedHead
+ ]
+ _ = try await client.execute(
+ service: .git,
+ query: Self.updateRepoMutation,
+ variables: ["id": repositoryId, "input": input],
+ responseType: UpdateRepoResponse.self
+ )
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Rename
+
+ func rename() async {
+ isRenaming = true
+ defer { isRenaming = false }
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "name": editedName
+ ]
+ let result = try await client.execute(
+ service: .git,
+ query: Self.updateRepoMutation,
+ variables: ["id": repositoryId, "input": input],
+ responseType: UpdateRepoResponse.self
+ )
+ updatedName = result.updateRepository.name
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - ACLs
+
+ private static let aclsQuery = """
+ query acls($rid: ID!) {
+ repository(rid: $rid) {
+ acls {
+ results {
+ id
+ mode
+ entity { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let updateACLMutation = """
+ mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
+ updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
+ id mode entity { canonicalName }
+ }
+ }
+ """
+
+ private static let deleteACLMutation = """
+ mutation deleteACL($id: Int!) {
+ deleteACL(id: $id) { id }
+ }
+ """
+
+ func loadACLs() async {
+ guard !isLoadingACLs else { return }
+ isLoadingACLs = true
+ defer { isLoadingACLs = false }
+
+ do {
+ let result = try await client.execute(
+ service: .git,
+ query: Self.aclsQuery,
+ variables: ["rid": repositoryRid],
+ responseType: ACLResponse.self
+ )
+ acls = result.repository?.acls.results ?? []
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func addACL() async {
+ let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !entity.isEmpty else { return }
+ isAddingACL = true
+ defer { isAddingACL = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .git,
+ query: Self.updateACLMutation,
+ variables: [
+ "repoId": repositoryId,
+ "mode": newACLMode,
+ "entity": entity
+ ],
+ responseType: UpdateACLResponse.self
+ )
+ // Replace existing entry or append
+ if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
+ acls[index] = result.updateACL
+ } else {
+ acls.append(result.updateACL)
+ }
+ newACLEntity = ""
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteACL(_ entry: ACLEntry) async {
+ isDeletingACL = true
+ defer { isDeletingACL = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.deleteACLMutation,
+ variables: ["id": entry.id],
+ responseType: DeleteACLResponse.self
+ )
+ acls.removeAll { $0.id == entry.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Delete Repository
+
+ private static let deleteRepoMutation = """
+ mutation deleteRepository($id: Int!) {
+ deleteRepository(id: $id) { id }
+ }
+ """
+
+ func deleteRepository() async {
+ isDeleting = true
+ defer { isDeleting = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.deleteRepoMutation,
+ variables: ["id": repositoryId],
+ responseType: DeleteRepoResponse.self
+ )
+ didDelete = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
new file mode 100644
index 0000000..8ad3659
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -0,0 +1,101 @@
+import SwiftUI
+
+struct RepositoryCloneURLs {
+ let readOnly: String
+ let readWrite: String
+}
+
+func repositoryCloneURLs(for repository: RepositorySummary) -> RepositoryCloneURLs {
+ let owner = repository.owner.canonicalName
+ let name = repository.name
+
+ switch repository.service {
+ case .git:
+ return RepositoryCloneURLs(
+ readOnly: "https://git.sr.ht/\(owner)/\(name)",
+ readWrite: "[email protected]:\(owner)/\(name)"
+ )
+ case .hg:
+ return RepositoryCloneURLs(
+ readOnly: "https://hg.sr.ht/\(owner)/\(name)",
+ readWrite: "ssh://[email protected]/\(owner)/\(name)"
+ )
+ default:
+ return RepositoryCloneURLs(
+ readOnly: "https://\(repository.service.rawValue).sr.ht/\(owner)/\(name)",
+ readWrite: ""
+ )
+ }
+}
+
+func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+}
+
+struct RepositorySummaryCard<Content: View>: View {
+ let title: String
+ @ViewBuilder let content: Content
+
+ init(_ title: String, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ content
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+}
+
+struct RepositorySummaryField: View {
+ let label: String
+ let value: String
+ var monospace: Bool = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(monospace ? .system(.body, design: .monospaced) : .body)
+ .textSelection(.enabled)
+ }
+ }
+}
+
+struct RepositorySummaryListRow: View {
+ let label: String
+ let values: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(label)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if values.isEmpty {
+ Text("None")
+ .foregroundStyle(.tertiary)
+ } else {
+ Text(values.joined(separator: ", "))
+ }
+ }
+ }
+}