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 | |
| parent | d27726d8755a5631fbd2105f735811a95379c4ab (diff) | |
| download | hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.gz hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.bz2 hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.zip | |
create privacy policy
| -rw-r--r-- | Hutch/Extensions/SRHTShareUI.swift | 57 | ||||
| -rw-r--r-- | Hutch/Extensions/SRHTWebURL.swift | 84 | ||||
| -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 | ||||
| -rw-r--r-- | ROADMAP.md | 155 | ||||
| -rw-r--r-- | privacypolicy.md | 57 |
8 files changed, 1409 insertions, 0 deletions
diff --git a/Hutch/Extensions/SRHTShareUI.swift b/Hutch/Extensions/SRHTShareUI.swift new file mode 100644 index 0000000..9d949c9 --- /dev/null +++ b/Hutch/Extensions/SRHTShareUI.swift @@ -0,0 +1,57 @@ +import SwiftUI +import UIKit + +enum SRHTShareTarget: String { + case repository = "repository" + case commit = "commit" + case file = "file" + case build = "build" + case tracker = "tracker" + case ticket = "ticket" + case profile = "profile" + + var fallbackMessage: String { + "This \(rawValue) does not have a valid web URL to share." + } +} + +struct SRHTShareButton<Label: View>: View { + let url: URL? + let target: SRHTShareTarget + @ViewBuilder let label: () -> Label + + @State private var isShowingShareSheet = false + @State private var isShowingFallbackAlert = false + + var body: some View { + Button { + if url != nil { + isShowingShareSheet = true + } else { + isShowingFallbackAlert = true + } + } label: { + label() + } + .sheet(isPresented: $isShowingShareSheet) { + if let url { + ShareSheet(activityItems: [url]) + } + } + .alert("Share Unavailable", isPresented: $isShowingFallbackAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(target.fallbackMessage) + } + } +} + +private struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift new file mode 100644 index 0000000..53556de --- /dev/null +++ b/Hutch/Extensions/SRHTWebURL.swift @@ -0,0 +1,84 @@ +import Foundation + +enum SRHTWebURL { + static func repository(_ repository: RepositorySummary) -> URL? { + userScopedURL( + host: "\(repository.service.rawValue).sr.ht", + ownerCanonicalName: repository.owner.canonicalName, + pathComponents: [repository.name] + ) + } + + static func build(jobId: Int, ownerCanonicalName: String) -> URL? { + userScopedURL( + host: "builds.sr.ht", + ownerCanonicalName: ownerCanonicalName, + pathComponents: ["job", String(jobId)] + ) + } + + static func tracker(ownerUsername: String, trackerName: String) -> URL? { + userScopedURL( + host: "todo.sr.ht", + ownerUsername: ownerUsername, + pathComponents: [trackerName] + ) + } + + static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? { + userScopedURL( + host: "todo.sr.ht", + ownerUsername: ownerUsername, + pathComponents: [trackerName, String(ticketId)] + ) + } + + static func profile(canonicalName: String) -> URL? { + userScopedURL( + host: "meta.sr.ht", + ownerCanonicalName: canonicalName, + pathComponents: [] + ) + } + + private static func userScopedURL( + host: String, + ownerCanonicalName: String, + pathComponents: [String] + ) -> URL? { + userScopedURL( + host: host, + ownerUsername: username(from: ownerCanonicalName), + pathComponents: pathComponents + ) + } + + private static func userScopedURL( + host: String, + ownerUsername: String, + pathComponents: [String] + ) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = host + + let encodedComponents = (["~\(ownerUsername)"] + pathComponents).map { pathComponent in + pathComponent.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? pathComponent + } + components.percentEncodedPath = "/" + encodedComponents.joined(separator: "/") + return components.url + } + + private static func username(from canonicalName: String) -> String { + if canonicalName.hasPrefix("~") { + return String(canonicalName.dropFirst()) + } + return canonicalName + } + + private static let pathComponentCharacterSet: CharacterSet = { + var characterSet = CharacterSet.urlPathAllowed + characterSet.remove(charactersIn: "/") + return characterSet + }() +} 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: ", ")) + } + } + } +} diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c63853a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,155 @@ +# Hutch Roadmap + +## Positioning + +Hutch is already beyond a minimal v1. The core SourceHut surfaces are present: + +- Authentication with PAT flow +- Git repositories +- Mercurial repositories +- Trackers and tickets +- Builds +- Sharing +- Core settings and app metadata + +The main risk now is not lack of scope. The main risk is shipping too late with +an increasingly broad surface area and not enough polish. + +This roadmap treats the current app as a real 1.0 candidate and shifts the +focus from feature expansion to launch readiness. + +## Version 1.0 Goal + +Ship a stable SourceHut mobile client with strong support for the most active +day-to-day workflows: + +- Browse and manage repositories +- Browse and manage tickets +- Browse and manage builds +- Support both git and hg repositories +- Provide dependable sharing and navigation + +Version 1.0 does not need to cover every sr.ht service. + +## Release Strategy + +### Phase 1: Ship Readiness + +Focus on quality, not new surface area. + +#### Core release checklist + +- Verify authentication flow on simulator and physical device +- Verify reset/sign-out/reset-app-data flow on simulator and physical device +- Verify all destructive actions have confirmation and correct follow-up state +- Verify all create flows succeed end-to-end: + - Git repository + - Mercurial repository + - Tracker + - Ticket + - Build submission +- Verify build retry and edit/resubmit flows +- Verify repository sharing links for: + - repository + - commit + - file +- Verify sharing links for: + - build + - tracker + - ticket + - profile +- Review empty/loading/error states across all main tabs +- Remove any leftover temporary debug logging +- Audit device-only issues: + - Xcode attach quirks + - on-device auth/cache behavior + - WebView rendering performance + +#### UI/UX polish checklist + +- Tighten wording and error messages across create/edit flows +- Confirm summary tabs feel consistent across git and hg +- Confirm toolbar actions are visible and non-duplicated +- Confirm README rendering is smooth on large repositories +- Confirm forms behave well on iPhone-sized screens +- Confirm keyboard behavior and dismissal in all creation sheets + +#### App Store readiness + +- Finalize app icon and screenshots +- Finalize App Store copy +- Finalize privacy details +- Finalize support URL / project URL +- Decide whether TestFlight comes before public launch + +### Phase 2: Version 1.1 + +Add one compact new service surface after launch. + +#### Recommended priority + +1. Exact repository lookup +2. paste.sr.ht +3. lists.sr.ht + +#### Why + +- Exact repository lookup solves a real gap created by the lack of public + discovery APIs +- paste.sr.ht is relatively self-contained +- lists.sr.ht is valuable, but broader in UI and data model scope + +### Phase 3: Version 1.2+ + +Expand only after the v1 core is stable in the wild. + +- lists.sr.ht tab +- paste.sr.ht creation/editing polish +- pages.sr.ht management +- broader deep-link/share coverage +- workflow refinements for builds and tickets + +## What Counts As “Done Enough” For 1.0 + +Hutch is ready for 1.0 when: + +- The main flows work reliably on real devices +- Errors are understandable +- The app does not feel inconsistent across git/hg/builds/tickets +- The missing services feel like roadmap items, not broken gaps + +That means `lists.sr.ht`, `paste.sr.ht`, and `pages.sr.ht` are not blockers for +the first release. + +## Non-Blockers For 1.0 + +These should not delay launch: + +- Donation page / IAP +- Broad service parity across all SourceHut products +- Public repo discovery equivalent to `sr.ht/projects` +- Advanced creation workflows beyond what the public APIs cleanly support + +## Open Product Questions + +These are worth deciding before or shortly after launch: + +- Should the app be positioned as “SourceHut client” or “SourceHut for git/hg, + tickets, and builds” in App Store messaging? +- Should exact repository lookup live in Repositories search, a dedicated sheet, + or both? +- Should `paste.sr.ht` or `lists.sr.ht` be the first new post-launch tab? +- Is TestFlight feedback needed before calling the first public build 1.0? + +## Recommended Next Step + +Do not add another major service right away. + +Instead: + +1. Run a release-focused polish pass +2. Build a strict 1.0 checklist from the current app +3. Ship to TestFlight or release publicly +4. Use `ROADMAP.md` and `TODO.md` separately: + - `ROADMAP.md` for release strategy + - `TODO.md` for concrete implementation backlog diff --git a/privacypolicy.md b/privacypolicy.md new file mode 100644 index 0000000..aaa5d72 --- /dev/null +++ b/privacypolicy.md @@ -0,0 +1,57 @@ +# Privacy Policy + +Hutch +Last updated: March 17, 2026 + +## Overview + +Hutch is a client for SourceHut. This app allows users to browse repositories, +tickets, and builds using their own SourceHut account. + +## Data Collection + +Hutch does not collect, store, or transmit personal data to the developer. + +All data displayed in the app is retrieved directly from SourceHut using +user-provided authentication credentials. + +## Authentication + +Hutch uses a SourceHut Personal Access Token (PAT) provided by the user. + +The token is stored securely on-device and is used only to authenticate requests +to SourceHut. The developer does not have access to this token. + +## Data Usage + +The app communicates with SourceHut servers to: +- fetch repositories, files, and commits +- retrieve and update tickets +- submit and monitor builds + +No data is transmitted to any servers operated by the developer. + +## Third-Party Services + +Hutch communicates with SourceHut services (sr.ht) to retrieve user data. + +No analytics, advertising, or tracking SDKs are used. + +## User-Generated Content + +The app displays content from SourceHut, including repositories, tickets, and +builds. This content is accessed based on the user’s authentication and +permissions. + +## Device Permissions + +Hutch does not request access to sensitive device data such as location, +contacts, camera, or microphone. + +## Changes to This Policy + +This policy may be updated in future versions. Updates will be reflected on this page. + +## Contact + +For questions, contact: [email protected] |
