krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

8f2057c53e9009c2529c9c4849c914666c0e4b40

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-18T04:19:22Z

create privacy policy
 Hutch/Extensions/SRHTShareUI.swift                 |  57 +++
 Hutch/Extensions/SRHTWebURL.swift                  |  84 +++++
 .../Repositories/HgRepositoryDetailView.swift      | 419 +++++++++++++++++++++
 .../Repositories/RepositorySettingsView.swift      | 235 ++++++++++++
 .../Repositories/RepositorySettingsViewModel.swift | 301 +++++++++++++++
 .../Repositories/RepositorySummarySupport.swift    | 101 +++++
 ROADMAP.md                                         | 155 ++++++++
 privacypolicy.md                                   |  57 +++
 8 files changed, 1409 insertions(+)

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: "git@git.sr.ht:\(owner)/\(name)"
+        )
+    case .hg:
+        return RepositoryCloneURLs(
+            readOnly: "https://hg.sr.ht/\(owner)/\(name)",
+            readWrite: "ssh://hg@hg.sr.ht/\(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: privacy@cleberg.net