summaryrefslogtreecommitdiff
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
parentd27726d8755a5631fbd2105f735811a95379c4ab (diff)
downloadhutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.gz
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.bz2
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.zip
create privacy policy
-rw-r--r--Hutch/Extensions/SRHTShareUI.swift57
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift84
-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
-rw-r--r--ROADMAP.md155
-rw-r--r--privacypolicy.md57
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]