diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:22 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:22 -0500 |
| commit | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (patch) | |
| tree | 77b5a93625fe9ea98baf4ab0d137055524705dad /Hutch/Views/Repositories | |
| parent | d27726d8755a5631fbd2105f735811a95379c4ab (diff) | |
| download | hutch-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.swift | 419 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsView.swift | 235 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 301 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySummarySupport.swift | 101 |
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: ", ")) + } + } + } +} |
