summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Repositories')
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift73
-rw-r--r--Hutch/Views/Repositories/CommitDetailView.swift332
-rw-r--r--Hutch/Views/Repositories/CommitDetailViewModel.swift102
-rw-r--r--Hutch/Views/Repositories/CommitLogView.swift59
-rw-r--r--Hutch/Views/Repositories/CommitRowView.swift30
-rw-r--r--Hutch/Views/Repositories/DiffView.swift79
-rw-r--r--Hutch/Views/Repositories/FileTreeView.swift446
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift524
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift506
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift545
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsView.swift239
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift287
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift1125
-rw-r--r--Hutch/Views/Repositories/ReferencesListView.swift90
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift107
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift397
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift224
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift548
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift85
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsView.swift58
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift24
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift51
22 files changed, 5729 insertions, 202 deletions
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
new file mode 100644
index 0000000..ef3b972
--- /dev/null
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -0,0 +1,73 @@
+import SwiftUI
+
+struct ArtifactsView: View {
+ let viewModel: RepositoryDetailViewModel
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ List {
+ ForEach(viewModel.referenceArtifacts) { refArtifacts in
+ Section(refArtifacts.name) {
+ ForEach(refArtifacts.artifacts) { artifact in
+ ArtifactRow(artifact: artifact) {
+ openURL(artifact.url)
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
+ SRHTLoadingStateView(message: "Loading artifacts…")
+ } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Artifacts",
+ message: error,
+ retryAction: { await viewModel.loadArtifacts() }
+ )
+ } else if viewModel.referenceArtifacts.isEmpty {
+ ContentUnavailableView(
+ "No Artifacts",
+ systemImage: "archivebox",
+ description: Text("This repository has no release artifacts.")
+ )
+ }
+ }
+ .task {
+ if viewModel.referenceArtifacts.isEmpty {
+ await viewModel.loadArtifacts()
+ }
+ }
+ .refreshable {
+ await viewModel.loadArtifacts()
+ }
+ }
+}
+
+private struct ArtifactRow: View {
+ let artifact: ArtifactInfo
+ let onDownload: () -> Void
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(artifact.filename)
+ .font(.subheadline)
+
+ Text(artifact.size.formattedByteCount)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button {
+ onDownload()
+ } label: {
+ Image(systemName: "arrow.down.circle")
+ .imageScale(.large)
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift
new file mode 100644
index 0000000..d303542
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitDetailView.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+
+struct CommitDetailView: View {
+ let commitSummary: CommitSummary
+ let repository: RepositorySummary
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: CommitDetailViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ commitContent(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(commitSummary.shortId)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = CommitDetailViewModel(
+ repositoryRid: repository.rid,
+ service: repository.service,
+ commitId: commitSummary.id,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadCommit()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func commitContent(_ viewModel: CommitDetailViewModel) -> some View {
+ if viewModel.isLoading {
+ ProgressView()
+ } else if let error = viewModel.error {
+ ContentUnavailableView {
+ Label("Error", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(error)
+ } actions: {
+ Button("Retry") {
+ Task { await viewModel.loadCommit() }
+ }
+ }
+ } else if let commit = viewModel.commit {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ // Header
+ commitHeader(commit)
+
+ sectionDivider
+
+ // Message
+ commitMessage(commit)
+
+ // Trailers
+ if !commit.trailers.isEmpty {
+ sectionDivider
+ trailersSection(commit.trailers)
+ }
+
+ // Parents
+ if !commit.parents.isEmpty {
+ sectionDivider
+ parentsSection(commit.parents)
+ }
+
+ // Diff
+ if let diff = commit.diff, !diff.isEmpty {
+ sectionDivider
+ diffSection(diff)
+ }
+
+ // Tree
+ if let tree = commit.tree, !tree.entries.results.isEmpty {
+ sectionDivider
+ treeSection(tree.entries.results)
+ }
+ }
+ }
+ .navigationDestination(for: ParentCommit.self) { parent in
+ CommitDetailView(
+ commitSummary: CommitSummary(
+ id: parent.id,
+ shortId: parent.shortId,
+ author: CommitAuthor(name: parent.author.name, email: nil, time: .now),
+ message: ""
+ ),
+ repository: repository
+ )
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func commitHeader(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Full hash — tappable to copy
+ Button {
+ UIPasteboard.general.string = commit.id
+ } label: {
+ HStack(spacing: 4) {
+ Text(commit.id)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Image(systemName: "doc.on.doc")
+ .font(.caption2)
+ }
+ .foregroundStyle(.secondary)
+ }
+
+ // Author
+ HStack {
+ Label(commit.author.name, systemImage: "person")
+ Spacer()
+ Text(commit.author.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+
+ // Committer (if different from author)
+ if commit.committer.name != commit.author.name
+ || commit.committer.email != commit.author.email {
+ HStack {
+ Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
+ Spacer()
+ Text(commit.committer.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Message
+
+ @ViewBuilder
+ private func commitMessage(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Message")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ Text(commit.title)
+ .font(.headline)
+
+ if let body = commit.body {
+ Text(body)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Trailers
+
+ @ViewBuilder
+ private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Trailers")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(trailers) { trailer in
+ HStack(alignment: .top, spacing: 4) {
+ Text("\(trailer.name):")
+ .font(.subheadline.monospaced().weight(.medium))
+ Text(trailer.value)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Parents
+
+ @ViewBuilder
+ private func parentsSection(_ parents: [ParentCommit]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Parents")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(parents) { parent in
+ NavigationLink(value: parent) {
+ HStack {
+ Text(parent.shortId)
+ .font(.subheadline.monospaced())
+ Text(parent.author.name)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Diff
+
+ @ViewBuilder
+ private func diffSection(_ diff: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Diff")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ .padding(.horizontal)
+ .padding(.top)
+
+ DiffView(diff: invertDiff(diff))
+ .padding(.bottom)
+ }
+ }
+
+ /// The sr.ht API returns diffs comparing current→parent (inverted).
+ /// This swaps +/- prefixes so the diff reads as parent→current.
+ private func invertDiff(_ diff: String) -> String {
+ diff.split(separator: "\n", omittingEmptySubsequences: false)
+ .map { line in
+ let s = String(line)
+ if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
+ return s
+ }
+ if s.hasPrefix("---") {
+ return "+++" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+++") {
+ return "---" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+") {
+ return "-" + s.dropFirst(1)
+ }
+ if s.hasPrefix("-") {
+ return "+" + s.dropFirst(1)
+ }
+ return s
+ }
+ .joined(separator: "\n")
+ }
+
+ // MARK: - Tree
+
+ @ViewBuilder
+ private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Tree")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(entries) { entry in
+ HStack(spacing: 8) {
+ Image(systemName: treeEntryIcon(for: entry))
+ .foregroundStyle(treeEntryColor(for: entry))
+ .frame(width: 20)
+ Text(entry.name)
+ .font(.subheadline.monospaced())
+ Spacer()
+ if let obj = entry.object, let shortId = obj.shortId {
+ Text(shortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Helpers
+
+ private var sectionDivider: some View {
+ Divider().padding(.horizontal)
+ }
+
+ private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
+ guard let type = entry.object?.type else {
+ return "doc"
+ }
+ switch type {
+ case "tree": return "folder"
+ case "blob": return "doc.text"
+ case "tag": return "tag"
+ case "commit": return "arrow.triangle.branch"
+ default: return "doc"
+ }
+ }
+
+ private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
+ guard let type = entry.object?.type else {
+ return .secondary
+ }
+ switch type {
+ case "tree": return .blue
+ case "blob": return .secondary
+ case "tag": return .orange
+ case "commit": return .purple
+ default: return .secondary
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitDetailViewModel.swift b/Hutch/Views/Repositories/CommitDetailViewModel.swift
new file mode 100644
index 0000000..5a8a23d
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitDetailViewModel.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct CommitResponse: Decodable, Sendable {
+ let repository: CommitRepository?
+}
+
+private struct CommitRepository: Decodable, Sendable {
+ // swiftlint:disable:next identifier_name
+ let revparse_single: CommitDetail
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class CommitDetailViewModel {
+
+ let repositoryRid: String
+ let service: SRHTService
+ private let client: SRHTClient
+
+ private(set) var commit: CommitDetail?
+ private(set) var isLoading = false
+ var error: String?
+
+ init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ self.commitId = commitId
+ self.client = client
+ }
+
+ private let commitId: String
+
+ // MARK: - Query
+
+ private static let query = """
+ query commit($rid: ID!, $id: String!) {
+ repository(rid: $rid) {
+ revparse_single(revspec: $id) {
+ id
+ shortId
+ author { name email time }
+ committer { name email time }
+ message
+ diff
+ trailers { name value }
+ parents { id shortId author { name } }
+ tree {
+ entries {
+ results { id name mode object { type id shortId } }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ func loadCommit() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await executeWithRetry()
+ commit = result.repository?.revparse_single
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ /// Execute the commit query, retrying once after a 1-second delay on 502/503.
+ private func executeWithRetry() async throws -> CommitResponse {
+ do {
+ return try await client.execute(
+ service: service,
+ query: Self.query,
+ variables: [
+ "rid": repositoryRid,
+ "id": commitId
+ ],
+ responseType: CommitResponse.self
+ )
+ } catch let SRHTError.httpError(code) where code == 502 || code == 503 {
+ try await Task.sleep(for: .seconds(1))
+ return try await client.execute(
+ service: service,
+ query: Self.query,
+ variables: [
+ "rid": repositoryRid,
+ "id": commitId
+ ],
+ responseType: CommitResponse.self
+ )
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitLogView.swift b/Hutch/Views/Repositories/CommitLogView.swift
new file mode 100644
index 0000000..d63c5eb
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitLogView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct CommitLogView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.commits) { commit in
+ NavigationLink(value: commit) {
+ CommitRowView(commit: commit)
+ }
+ .task {
+ await viewModel.loadMoreCommitsIfNeeded(currentItem: commit)
+ }
+ }
+
+ if viewModel.isLoadingMoreCommits {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoadingCommits, viewModel.commits.isEmpty {
+ SRHTLoadingStateView(message: "Loading commits…")
+ } else if let error = viewModel.error, viewModel.commits.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Commits",
+ message: error,
+ retryAction: { await viewModel.loadCommits() }
+ )
+ } else if viewModel.commits.isEmpty {
+ ContentUnavailableView(
+ "No Commits",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository has no commit history.")
+ )
+ }
+ }
+ .task {
+ if viewModel.commits.isEmpty {
+ await viewModel.loadCommits()
+ }
+ }
+ .refreshable {
+ await viewModel.loadCommits()
+ }
+ .navigationDestination(for: CommitSummary.self) { commit in
+ CommitDetailView(
+ commitSummary: commit,
+ repository: viewModel.repository
+ )
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitRowView.swift b/Hutch/Views/Repositories/CommitRowView.swift
new file mode 100644
index 0000000..b5eb31d
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitRowView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct CommitRowView: View {
+ let commit: CommitSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(commit.title)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text(commit.shortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+
+ Text(commit.author.name)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(commit.author.time.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
new file mode 100644
index 0000000..b1db464
--- /dev/null
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+
+/// Renders a unified diff string with syntax highlighting:
+/// - Green background for added lines (+)
+/// - Red background for removed lines (-)
+/// - Gray for hunk headers (@@)
+/// - File headers (--- / +++ / diff) in bold
+struct DiffView: View {
+ let diff: String
+
+ var body: some View {
+ let lines = diff.components(separatedBy: "\n")
+
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
+ DiffLineView(line: line)
+ }
+ }
+ .font(.caption.monospaced())
+ }
+}
+
+private struct DiffLineView: View {
+ let line: String
+
+ var body: some View {
+ Text(line.isEmpty ? " " : line)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 1)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .fontWeight(isHeader ? .semibold : .regular)
+ }
+
+ private var kind: DiffLineKind {
+ if line.hasPrefix("@@") { return .hunk }
+ if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader }
+ if line.hasPrefix("diff ") { return .fileHeader }
+ if line.hasPrefix("index ") { return .meta }
+ if line.hasPrefix("+") { return .added }
+ if line.hasPrefix("-") { return .removed }
+ return .context
+ }
+
+ private var backgroundColor: Color {
+ switch kind {
+ case .added: .green.opacity(0.15)
+ case .removed: .red.opacity(0.15)
+ case .hunk: .gray.opacity(0.12)
+ case .fileHeader: .gray.opacity(0.08)
+ case .meta: .gray.opacity(0.05)
+ case .context: .clear
+ }
+ }
+
+ private var foregroundColor: Color {
+ switch kind {
+ case .added: .green
+ case .removed: .red
+ case .hunk: .secondary
+ case .meta: .secondary
+ default: .primary
+ }
+ }
+
+ private var isHeader: Bool {
+ kind == .fileHeader
+ }
+}
+
+private enum DiffLineKind {
+ case added
+ case removed
+ case hunk
+ case fileHeader
+ case meta
+ case context
+}
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
new file mode 100644
index 0000000..3750adb
--- /dev/null
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -0,0 +1,446 @@
+import SwiftUI
+
+struct FileTreeView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+
+ @State private var viewModel: FileTreeViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ FileTreeContentView(repository: repository, viewModel: viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading files…")
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = FileTreeViewModel(
+ repositoryRid: repository.rid,
+ service: repository.service,
+ client: client
+ )
+ viewModel = vm
+ async let loadTree: () = vm.loadRootTree()
+ async let loadRefs: () = vm.loadReferences()
+ _ = await (loadTree, loadRefs)
+ }
+ }
+ }
+}
+
+// MARK: - Content View
+
+private struct FileTreeContentView: View {
+ let repository: RepositorySummary
+ let viewModel: FileTreeViewModel
+
+ @State private var showRefPicker = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ breadcrumbBar
+ Divider()
+ contentArea
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showRefPicker = true
+ } label: {
+ Label(
+ revspecLabel,
+ systemImage: "arrow.triangle.branch"
+ )
+ .font(.subheadline)
+ }
+ }
+ }
+ .sheet(isPresented: $showRefPicker) {
+ RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker)
+ }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ .refreshable {
+ await viewModel.loadRootTree()
+ }
+ }
+
+ private var shareURL: URL? {
+ guard let viewingEntry = viewModel.viewingEntry else { return nil }
+ return SRHTWebURL.file(
+ repository: repository,
+ revspec: viewModel.revspec,
+ path: currentFilePath(for: viewingEntry)
+ )
+ }
+
+ private var revspecLabel: String {
+ let revspec = viewModel.revspec
+ if revspec == "HEAD" {
+ return "HEAD"
+ }
+ if revspec.hasPrefix("refs/heads/") {
+ return String(revspec.dropFirst("refs/heads/".count))
+ } else if revspec.hasPrefix("refs/tags/") {
+ return String(revspec.dropFirst("refs/tags/".count))
+ }
+ return revspec
+ }
+
+ // MARK: - Breadcrumb Bar
+
+ private var breadcrumbBar: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ HStack(spacing: 4) {
+ ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in
+ if index > 0 {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ Button {
+ Task {
+ await viewModel.navigateToBreadcrumb(at: index)
+ }
+ } label: {
+ Text(navEntry.name)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(
+ index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil
+ ? .primary : .secondary
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if let viewing = viewModel.viewingEntry {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+
+ Text(viewing.name)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.primary)
+ }
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .background(.bar)
+ }
+
+ private func currentFilePath(for entry: TreeEntry) -> String {
+ let directoryComponents = viewModel.navStack
+ .dropFirst()
+ .map(\.name)
+ return (directoryComponents + [entry.name]).joined(separator: "/")
+ }
+
+ // MARK: - Content Area
+
+ @ViewBuilder
+ private var contentArea: some View {
+ if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil {
+ SRHTLoadingStateView(message: "Loading files…")
+ } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject {
+ // Viewing a file
+ fileContentView(entry: entry, object: object)
+ } else if let error = viewModel.error, viewModel.entries.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Files",
+ message: error,
+ retryAction: { await viewModel.loadRootTree() }
+ )
+ } else if !viewModel.entries.isEmpty {
+ // Viewing a directory listing
+ treeListView
+ } else if viewModel.navStack.isEmpty {
+ ContentUnavailableView(
+ "No Files",
+ systemImage: "folder",
+ description: Text("This repository could not be loaded.")
+ )
+ } else {
+ ContentUnavailableView(
+ "Empty Directory",
+ systemImage: "folder",
+ description: Text("This directory has no files.")
+ )
+ }
+ }
+
+ // MARK: - File Content View
+
+ @ViewBuilder
+ private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
+ switch object {
+ case .textBlob(let blob):
+ textBlobView(entry: entry, blob: blob)
+ case .binaryBlob(let blob):
+ binaryBlobView(entry: entry, blob: blob)
+ default:
+ ContentUnavailableView(
+ "Unknown Object",
+ systemImage: "questionmark.folder",
+ description: Text("Cannot display this object type.")
+ )
+ }
+ }
+
+ // MARK: - Tree List
+
+ private var treeListView: some View {
+ let sorted = viewModel.entries.sorted { a, b in
+ let aIsTree = a.object?.isTree == true
+ let bIsTree = b.object?.isTree == true
+ if aIsTree != bIsTree { return aIsTree }
+ return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
+ }
+
+ return List(sorted) { entry in
+ TreeEntryRow(entry: entry)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ Task {
+ await viewModel.navigateInto(entry: entry)
+ }
+ }
+ }
+ .listStyle(.plain)
+ }
+
+ // MARK: - Text Blob
+
+ @ViewBuilder
+ private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View {
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.horizontal)
+ .padding(.top, 12)
+
+ GeometryReader { geometry in
+ ScrollView([.vertical, .horizontal]) {
+ Text(blob.text)
+ .font(.system(.body, design: .monospaced))
+ .multilineTextAlignment(.leading)
+ .fixedSize(horizontal: true, vertical: false)
+ .frame(minWidth: geometry.size.width,
+ minHeight: geometry.size.height,
+ alignment: .topLeading)
+ .padding()
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ }
+ }
+ }
+
+ // MARK: - Binary Blob
+
+ @ViewBuilder
+ private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
+ VStack(spacing: 16) {
+ Spacer()
+
+ Image(systemName: "doc.zipper")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+
+ Text(entry.name)
+ .font(.headline)
+
+ if let size = blob.size {
+ Text(formatBytes(size))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("Binary file — cannot be displayed inline.")
+ .font(.subheadline)
+ .foregroundStyle(.tertiary)
+
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+
+ if let content = blob.content, let url = URL(string: content) {
+ Link(destination: url) {
+ Label("Open in Safari", systemImage: "safari")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ Button {
+ viewModel.dismissFileView()
+ } label: {
+ Text("Back to directory")
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Helpers
+
+ private func formatBytes(_ bytes: Int) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: Int64(bytes))
+ }
+}
+
+// MARK: - Tree Entry Row
+
+private struct TreeEntryRow: View {
+ let entry: TreeEntry
+
+ var body: some View {
+ Label {
+ Text(entry.name)
+ .font(.body.monospaced())
+ .lineLimit(1)
+ } icon: {
+ Image(systemName: iconName)
+ .foregroundStyle(iconColor)
+ }
+ }
+
+ private var iconName: String {
+ switch entry.object {
+ case .tree: "folder.fill"
+ case .unknown: "questionmark.circle"
+ default: "doc"
+ }
+ }
+
+ private var iconColor: Color {
+ switch entry.object {
+ case .tree: .blue
+ case .unknown: .orange
+ default: .secondary
+ }
+ }
+}
+
+// MARK: - Ref Picker Sheet
+
+private struct RefPickerSheet: View {
+ let viewModel: FileTreeViewModel
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Button {
+ Task {
+ await viewModel.changeRevspec("HEAD")
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: "HEAD",
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.revspec == "HEAD"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches, id: \.name) { ref in
+ Button {
+ Task {
+ await viewModel.changeRevspec(ref.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.revspec == ref.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags, id: \.name) { ref in
+ Button {
+ Task {
+ await viewModel.changeRevspec(ref.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
+ systemImage: "tag",
+ color: .orange,
+ isSelected: viewModel.revspec == ref.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Select Ref")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isPresented = false
+ }
+ }
+ }
+ .overlay {
+ if viewModel.isLoadingRefs {
+ SRHTLoadingStateView(message: "Loading references…")
+ }
+ }
+ }
+ }
+
+ private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(color)
+
+ Text(title)
+ .font(.body.monospaced())
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+}
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
new file mode 100644
index 0000000..f270779
--- /dev/null
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -0,0 +1,524 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct RevparseResponse: Decodable, Sendable {
+ let repository: RevparseRepository?
+}
+
+private struct RevparseRepository: Decodable, Sendable {
+ let revparse_single: RevparseCommit?
+}
+
+private struct RevparseCommit: Decodable, Sendable {
+ let tree: GitTree?
+}
+
+private struct SubtreeResponse: Decodable, Sendable {
+ let repository: SubtreeRepository?
+}
+
+private struct SubtreeRepository: Decodable, Sendable {
+ let object: SubtreeObject?
+}
+
+/// Dedicated decoding struct for the subtree query response.
+/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment.
+private struct SubtreeObject: Decodable, Sendable {
+ let entries: GitTreeEntryPage?
+}
+
+private struct BlobResponse: Decodable, Sendable {
+ let repository: BlobRepository?
+}
+
+private struct BlobRepository: Decodable, Sendable {
+ let object: GitObject?
+}
+
+// MARK: - Navigation Stack Entry
+
+struct FileNavEntry: Hashable {
+ let name: String
+ let treeId: String
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class FileTreeViewModel {
+
+ let repositoryRid: String
+ let service: SRHTService
+ private let client: SRHTClient
+
+ /// The current revspec. "HEAD" by default, or a full ref name.
+ var revspec: String = "HEAD"
+
+ /// Navigation stack: each entry is a (name, treeId) pair.
+ /// The first entry is always the root.
+ private(set) var navStack: [FileNavEntry] = []
+
+ /// The tree entries at the current directory level.
+ private(set) var entries: [TreeEntry] = []
+
+ /// When viewing a file (text blob or binary blob), this holds the object.
+ private(set) var viewingEntry: TreeEntry?
+ private(set) var viewingObject: GitObject?
+
+ private(set) var isLoading = false
+ var error: String?
+
+ // Available references for the branch/tag picker
+ private(set) var branches: [Reference] = []
+ private(set) var tags: [Reference] = []
+ private(set) var isLoadingRefs = false
+
+ init(repositoryRid: String, service: SRHTService, client: SRHTClient) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let rootTreeQuery = """
+ query files($rid: ID!, $revspec: String!) {
+ repository(rid: $rid) {
+ revparse_single(revspec: $revspec) {
+ tree {
+ id
+ entries {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ /// Query to fetch additional pages of tree entries by tree ID + cursor.
+ private static let treeEntriesPageQuery = """
+ query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ object(id: $treeId) {
+ type
+ id
+ ... on Tree {
+ entries(cursor: $cursor) {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let subtreeQuery = """
+ query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ object(id: $treeId) {
+ type
+ id
+ ... on Tree {
+ entries(cursor: $cursor) {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let blobQuery = """
+ query blob($rid: ID!, $blobId: String!) {
+ repository(rid: $rid) {
+ object(id: $blobId) {
+ type
+ id
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ }
+ """
+
+ private static let refsQuery = """
+ query refs($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results { name target }
+ cursor
+ }
+ }
+ }
+ """
+
+ // MARK: - Load Root Tree
+
+ func loadRootTree() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ viewingEntry = nil
+ viewingObject = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "revspec": revspec
+ ]
+
+ do {
+ let result: RevparseResponse
+ do {
+ result = try await client.execute(
+ service: service,
+ query: Self.rootTreeQuery,
+ variables: variables,
+ responseType: RevparseResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ navStack = [FileNavEntry(name: "root", treeId: "")]
+ entries = []
+ return
+ }
+ throw error
+ }
+ if let tree = result.repository?.revparse_single?.tree,
+ let rootId = tree.id {
+ navStack = [FileNavEntry(name: "root", treeId: rootId)]
+ var allEntries = tree.entries?.results ?? []
+ var cursor = tree.entries?.cursor
+ // Follow cursor pagination for remaining pages
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } else {
+ navStack = []
+ entries = []
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Navigate Into Folder
+
+ func navigateInto(entry: TreeEntry) async {
+ guard let object = entry.object else { return }
+
+ switch object {
+ case .tree(let tree):
+ // Use the git object SHA from entry.object, NOT entry.id
+ guard let objectSHA = tree.id else { return }
+ // If we already have the entries inline and no further pages, use them directly
+ if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil {
+ navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA))
+ entries = inlineEntries
+ viewingEntry = nil
+ viewingObject = nil
+ return
+ }
+ // Otherwise fetch the subtree (handles pagination)
+ await loadSubtree(name: entry.name, treeId: objectSHA)
+
+ case .textBlob:
+ viewingEntry = entry
+ viewingObject = object
+
+ case .binaryBlob(let blob):
+ if blob.content != nil || blob.size != nil {
+ viewingEntry = entry
+ viewingObject = object
+ } else if let blobId = blob.id {
+ await loadBlob(entry: entry, blobId: blobId)
+ }
+
+ case .unknown:
+ break
+ }
+ }
+
+ private func loadSubtree(name: String, treeId: String) async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ viewingEntry = nil
+ viewingObject = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": treeId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.subtreeQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ navStack.append(FileNavEntry(name: name, treeId: treeId))
+ var allEntries = result.repository?.object?.entries?.results ?? []
+ var cursor = result.repository?.object?.entries?.cursor
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func loadBlob(entry: TreeEntry, blobId: String) async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "blobId": blobId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.blobQuery,
+ variables: variables,
+ responseType: BlobResponse.self
+ )
+ viewingEntry = entry
+ viewingObject = result.repository?.object ?? .unknown
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Navigate to Breadcrumb
+
+ func navigateToBreadcrumb(at index: Int) async {
+ guard index >= 0, index < navStack.count else { return }
+
+ // If tapping current level, do nothing
+ if index == navStack.count - 1, viewingEntry == nil {
+ return
+ }
+
+ // Clear file view
+ viewingEntry = nil
+ viewingObject = nil
+
+ // Trim the stack
+ let targetEntry = navStack[index]
+ navStack = Array(navStack.prefix(index + 1))
+
+ if index == 0 {
+ // Go back to root — reload from revparse_single
+ await loadRootTree()
+ } else {
+ // Load the subtree at this level
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": targetEntry.treeId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.subtreeQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ var allEntries = result.repository?.object?.entries?.results ?? []
+ var cursor = result.repository?.object?.entries?.cursor
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ /// Fetch a single page of tree entries by tree ID and cursor.
+ private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": treeId,
+ "cursor": cursor
+ ]
+ let result = try await client.execute(
+ service: service,
+ query: Self.treeEntriesPageQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
+ }
+
+ /// Dismiss the file view and go back to the directory listing.
+ func dismissFileView() {
+ viewingEntry = nil
+ viewingObject = nil
+ }
+
+ // MARK: - Change Revspec
+
+ func changeRevspec(_ newRevspec: String) async {
+ revspec = newRevspec
+ await loadRootTree()
+ }
+
+ // MARK: - Load References
+
+ func loadReferences() async {
+ guard !isLoadingRefs else { return }
+ isLoadingRefs = true
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.refsQuery,
+ variables: ["rid": repositoryRid],
+ responseType: RefsResponseLocal.self
+ )
+ let allRefs = result.repository?.references.results ?? []
+ branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
+ tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
+ } catch {
+ // Silently fail for refs — non-critical
+ }
+
+ isLoadingRefs = false
+ }
+
+ private func isMissingGitReferenceError(_ error: Error) -> Bool {
+ guard let srhtError = error as? SRHTError else { return false }
+ guard case .graphQLErrors(let errors) = srhtError else { return false }
+ return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ }
+}
+
+// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
+private struct RefsResponseLocal: Decodable, Sendable {
+ let repository: RefsRepoLocal?
+}
+
+private struct RefsRepoLocal: Decodable, Sendable {
+ let references: RefsPageLocal
+}
+
+private struct RefsPageLocal: Decodable, Sendable {
+ let results: [Reference]
+ let cursor: String?
+}
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
index 8e47d33..6779165 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View {
@State private var viewModel: HgRepositoryDetailViewModel?
@State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
+ @State private var isShowingRepositoryDetails = false
+ @State private var showBrowseRefPicker = false
+
+ private var shareURL: URL? {
+ guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
+ return SRHTWebURL.file(
+ repository: repository,
+ revspec: viewModel.browseRevspec,
+ path: selectedFilePath
+ )
+ }
var body: some View {
Group {
if let viewModel {
content(viewModel)
} else {
- ProgressView()
+ SRHTLoadingStateView(message: "Loading repository…")
}
}
.navigationTitle(repository.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarTrailing) {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ if selectedTab == .browse, let viewModel {
+ Button {
+ showBrowseRefPicker = true
+ } label: {
+ Label(
+ browseRevspecLabel(viewModel.browseRevspec),
+ systemImage: "arrow.triangle.branch"
+ )
+ .font(.subheadline)
+ }
+ }
+
+ SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
Button {
showSettings = true
} label: {
@@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View {
}
)
}
+ .sheet(isPresented: $showBrowseRefPicker) {
+ if let viewModel {
+ HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker)
+ }
+ }
.task {
if viewModel == nil {
let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
@@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View {
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)
- }
- }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
}
@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()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ headerSection
+ metadataSection(viewModel)
+ repositoryDetailsSection(viewModel)
+ latestChangeSection(viewModel)
+ readmeSection(viewModel)
}
- .refreshable {
- await viewModel.loadSummary()
+ .padding()
+ }
+ .overlay {
+ if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
+ SRHTLoadingStateView(message: "Loading repository…")
+ } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Repository",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
}
}
+ .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")
-
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Text(repository.name)
+ .font(.largeTitle.weight(.semibold))
if let description = repository.description, !description.isEmpty {
- VStack(alignment: .leading, spacing: 4) {
- Text("Description")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- .textCase(.uppercase)
- Text(description)
- }
+ Text(description)
+ .font(.body)
}
+ }
+ }
- 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)
- }
+ @ViewBuilder
+ private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
+ )
+
+ if let readmePath = viewModel.readmePath {
+ SummaryMetadataRow(
+ icon: "doc.text",
+ title: readmePath
+ )
}
}
- .padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+
+ private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
+ VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
+ SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
+ SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
+ SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true)
+ SummaryDetailRow(label: "RID", value: repository.rid, monospace: true)
+ }
+ .padding(.top, 8)
+ } label: {
+ Text("Repository Details")
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ @ViewBuilder
+ private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if viewModel.isLoadingSummary && viewModel.tip == nil {
+ SRHTLoadingStateView(message: "Loading latest change…")
+ .frame(maxWidth: .infinity)
+ } else if let tip = viewModel.tip {
+ SummaryMetadataRow(
+ icon: "arrow.trianglehead.clockwise",
+ title: tip.title,
+ subtitle: "\(tip.displayShortId) — \(tip.author)"
+ )
+ } else if let error = viewModel.error, !viewModel.summaryLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load Latest Change",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No Recent Revisions",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository does not have any revision history yet.")
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
+ SRHTLoadingStateView(message: "Loading README…")
+ } else if let readmeView = readmeContentView(viewModel) {
+ readmeView
+ } else if let error = viewModel.error, !viewModel.summaryLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load README",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
}
@ViewBuilder
@@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View {
browseBreadcrumbs(viewModel)
Divider()
- if viewModel.isLoadingBrowse {
- Spacer()
- ProgressView()
- Spacer()
+ if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil {
+ SRHTLoadingStateView(message: "Loading files…")
} 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()
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.horizontal)
+ .padding(.top, 12)
+
+ 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) {
@@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View {
.background(.bar)
}
.navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
+ } else if let error = viewModel.error, viewModel.files.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Files",
+ message: error,
+ retryAction: { await viewModel.loadBrowseRoot() }
+ )
} else if viewModel.files.isEmpty {
ContentUnavailableView(
"No Files",
@@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View {
)
} 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)
- }
+ HgFileRow(file: file)
.contentShape(Rectangle())
.onTapGesture {
Task { await viewModel.openFile(file) }
@@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View {
}
.listStyle(.plain)
.overlay {
- if viewModel.isLoadingLog {
- ProgressView()
+ if viewModel.isLoadingLog, viewModel.log.isEmpty {
+ SRHTLoadingStateView(message: "Loading revisions…")
+ } else if let error = viewModel.error, viewModel.log.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Revisions",
+ message: error,
+ retryAction: { await viewModel.loadLog() }
+ )
} else if viewModel.log.isEmpty {
ContentUnavailableView(
"No Revisions",
@@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View {
}
@ViewBuilder
- private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
+ private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
if revisions.isEmpty {
ContentUnavailableView(
emptyTitle,
@@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View {
)
} else {
List(revisions) { revision in
- revisionRow(revision)
+ namedRevisionRow(revision)
}
.listStyle(.plain)
}
@@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View {
}
HStack {
- Text(revision.author.name)
- Spacer()
- Text(revision.author.time.relativeDescription)
+ Text(revision.author)
}
.font(.caption)
.foregroundStyle(.secondary)
@@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View {
.padding(.vertical, 4)
}
- @ViewBuilder
- private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
- let imageURLResolver = makeImageURLResolver(viewModel)
+ private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(revision.name)
+ .font(.headline)
+ Spacer()
+ Text(revision.displayShortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 6)
+ }
+ private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
guard let content = viewModel.readmeContent else {
return nil
}
+ return AnyView(
+ RenderedMarkupContentView(
+ content: sharedReadmeContent(from: content),
+ readmePath: viewModel.readmePath,
+ colorScheme: colorScheme,
+ ownerCanonicalName: repository.owner.canonicalName,
+ repositoryName: repository.name,
+ repositoryHost: "hg.sr.ht"
+ )
+ )
+ }
+
+ private func browseRevspecLabel(_ revspec: String) -> String {
+ if revspec == "tip" {
+ return "tip"
+ }
+ return revspec
+ }
+
+ private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
switch content {
case .html(let html):
- return AnyView(
- HTMLWebView(html: html, colorScheme: colorScheme)
- .frame(minHeight: 400)
- )
+ .html(html)
case .markdown(let text):
- return AnyView(
- HTMLWebView(
- html: markdownToHTML(text, imageURLResolver: imageURLResolver),
- colorScheme: colorScheme
- )
- .frame(minHeight: 400)
- )
+ .markdown(text)
case .org(let text):
- return AnyView(
- HTMLWebView(
- html: orgToHTML(text, imageURLResolver: imageURLResolver),
- colorScheme: colorScheme
- )
- .frame(minHeight: 400)
- )
+ .org(text)
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))
- )
+ .plainText(text)
}
}
- 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 struct HgBrowseRefPickerSheet: View {
+ let viewModel: HgRepositoryDetailViewModel
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec("tip")
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: "tip",
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.browseRevspec == "tip"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "tag",
+ color: .orange,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.bookmarks.isEmpty {
+ Section("Bookmarks") {
+ ForEach(viewModel.bookmarks) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "bookmark",
+ color: .purple,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Select Ref")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isPresented = false
+ }
+ }
+ }
}
}
- private func displayFileName(_ name: String) -> String {
- name.hasSuffix("/") ? String(name.dropLast()) : name
+ private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(color)
+
+ Text(title)
+ .font(.body.monospaced())
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
}
+}
- private func visibilityLabel(_ visibility: Visibility) -> String {
- switch visibility {
- case .public:
- return "Public"
- case .unlisted:
- return "Unlisted"
- case .private:
- return "Private"
+private struct HgFileRow: View {
+ let file: HgFile
+
+ var body: some View {
+ Label {
+ Text(displayName)
+ .font(.body.monospaced())
+ .lineLimit(1)
+ } icon: {
+ Image(systemName: file.isDirectory ? "folder.fill" : "doc")
+ .foregroundStyle(file.isDirectory ? .blue : .secondary)
}
}
+
+ private var displayName: String {
+ file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
+ }
}
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
new file mode 100644
index 0000000..30ee3bb
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
@@ -0,0 +1,545 @@
+import Foundation
+
+private struct HgRepositorySummaryResponse: Decodable, Sendable {
+ let repository: HgRepositorySummaryPayload?
+}
+
+private struct HgRepositorySummaryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let readme: String?
+ let nonPublishing: Bool?
+ let tip: HgSummaryTip?
+ let branches: HgNamedRevisionPage?
+ let tags: HgNamedRevisionPage?
+ let bookmarks: HgNamedRevisionPage?
+}
+
+private struct HgSummaryTip: Decodable, Sendable {
+ let id: String?
+ let author: String?
+ let description: String?
+ let branch: String?
+ let tags: [String]?
+
+ var resolvedRevision: HgRevision? {
+ guard
+ let id,
+ let author,
+ let description
+ else {
+ return nil
+ }
+
+ return HgRevision(
+ id: id,
+ author: author,
+ description: description,
+ branch: branch,
+ tags: tags
+ )
+ }
+}
+
+private struct HgRevisionLogResponse: Decodable, Sendable {
+ let repository: HgRevisionLogRepository?
+}
+
+private struct HgRevisionLogRepository: Decodable, Sendable {
+ let log: HgRevisionPage?
+}
+
+private struct HgReadmeFileResponse: Decodable, Sendable {
+ let repository: HgReadmeFileRepository?
+}
+
+private struct HgReadmeFileRepository: Decodable, Sendable {
+ let readme: String?
+}
+
+private struct HgFilesResponse: Decodable, Sendable {
+ let repository: HgFilesRepository?
+}
+
+private struct HgFilesRepository: Decodable, Sendable {
+ let files: HgFilePage?
+}
+
+private struct HgFilePage: Decodable, Sendable {
+ let results: [HgFile]
+ let cursor: String?
+}
+
+private struct HgNamedRevisionPage: Decodable, Sendable {
+ let results: [HgNamedRevision]
+ let cursor: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case results
+ case cursor
+ }
+
+ init(results: [HgNamedRevision], cursor: String?) {
+ self.results = results
+ self.cursor = cursor
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? []
+ self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
+}
+
+private struct HgCatResponse: Decodable, Sendable {
+ let repository: HgCatRepository?
+}
+
+private struct HgCatRepository: Decodable, Sendable {
+ let cat: String?
+}
+
+struct HgRevisionPage: Decodable, Sendable {
+ let results: [HgRevision]
+ let cursor: String?
+}
+
+struct HgRevision: Decodable, Sendable, Identifiable, Hashable {
+ let id: String
+ let author: String
+ let description: String
+ let branch: String?
+ let tags: [String]?
+
+ var displayShortId: String {
+ String(id.prefix(12))
+ }
+
+ var title: String {
+ description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var body: String? {
+ let body = description
+ .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
+ .dropFirst()
+ .first
+ .map(String.init)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return body?.isEmpty == false ? body : nil
+ }
+
+ var primaryName: String {
+ if let tag = tags?.first, !tag.isEmpty {
+ return tag
+ }
+ if let branch, !branch.isEmpty {
+ return branch
+ }
+ return displayShortId
+ }
+}
+
+struct HgFile: Decodable, Sendable, Hashable, Identifiable {
+ let name: String
+
+ var id: String { name }
+
+ var isDirectory: Bool {
+ name.hasSuffix("/")
+ }
+}
+
+struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable {
+ let name: String
+ let id: String
+
+ var displayShortId: String {
+ String(id.prefix(12))
+ }
+}
+
+@Observable
+@MainActor
+final class HgRepositoryDetailViewModel {
+ enum Tab: String, CaseIterable {
+ case summary = "Summary"
+ case browse = "Browse"
+ case log = "Log"
+ case tags = "Tags"
+ case branches = "Branches"
+ case bookmarks = "Bookmarks"
+ }
+
+ enum ReadmeContent {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+ }
+
+ let repository: RepositorySummary
+ private let client: SRHTClient
+
+ private(set) var summaryLoaded = false
+ private(set) var isLoadingSummary = false
+ private(set) var readmeContent: ReadmeContent?
+ private(set) var readmePath: String?
+ private(set) var nonPublishing = false
+ private(set) var tip: HgRevision?
+ private(set) var branches: [HgNamedRevision] = []
+ private(set) var tags: [HgNamedRevision] = []
+ private(set) var bookmarks: [HgNamedRevision] = []
+
+ private(set) var log: [HgRevision] = []
+ private(set) var isLoadingLog = false
+ private(set) var isLoadingMoreLog = false
+ private var logCursor: String?
+ private var hasMoreLog = true
+
+ private(set) var currentBrowsePath = ""
+ private(set) var pathStack: [String] = []
+ private(set) var files: [HgFile] = []
+ private(set) var fileContent: String?
+ private(set) var selectedFilePath: String?
+ private(set) var isLoadingBrowse = false
+ private(set) var browseRevspec = "tip"
+
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repository = repository
+ self.client = client
+ }
+
+ private static let summaryQuery = """
+ query hgRepositorySummary($rid: ID!) {
+ repository(rid: $rid) {
+ id
+ rid
+ name
+ description
+ visibility
+ readme
+ nonPublishing
+ tip {
+ id
+ author
+ description
+ branch
+ tags
+ }
+ branches {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ tags {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ bookmarks {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let logQuery = """
+ query hgRepositoryLog($rid: ID!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ log(cursor: $cursor) {
+ results {
+ id
+ author
+ description
+ branch
+ tags
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static func readmeFileQuery(filename: String) -> String {
+ """
+ query hgReadmeFile($rid: ID!) {
+ repository(rid: $rid) {
+ readme: cat(path: "\(filename)", revspec: "tip")
+ }
+ }
+ """
+ }
+
+ private static let readmeFilenames = [
+ "README.md", "README.org", "README.txt", "README",
+ "readme.md", "readme.org"
+ ]
+
+ private static let filesQuery = """
+ query hgFiles($rid: ID!, $path: String!, $revspec: String!) {
+ repository(rid: $rid) {
+ files(path: $path, revspec: $revspec) {
+ results {
+ name
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let catQuery = """
+ query hgCat($rid: ID!, $path: String!, $revspec: String!) {
+ repository(rid: $rid) {
+ cat(path: $path, revspec: $revspec)
+ }
+ }
+ """
+
+ func loadSummary() async {
+ guard !isLoadingSummary, !summaryLoaded else { return }
+ isLoadingSummary = true
+ defer { isLoadingSummary = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.summaryQuery,
+ variables: ["rid": repository.rid],
+ responseType: HgRepositorySummaryResponse.self
+ )
+
+ guard let repository = result.repository else {
+ summaryLoaded = true
+ return
+ }
+
+ tip = repository.tip?.resolvedRevision
+ branches = repository.branches?.results ?? []
+ tags = repository.tags?.results ?? []
+ bookmarks = repository.bookmarks?.results ?? []
+ nonPublishing = repository.nonPublishing ?? false
+
+ if let html = repository.readme, !html.isEmpty {
+ readmePath = nil
+ readmeContent = .html(html)
+ } else {
+ await loadReadmeFile()
+ }
+
+ summaryLoaded = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func loadLog() async {
+ guard !isLoadingLog else { return }
+ isLoadingLog = true
+ defer { isLoadingLog = false }
+ error = nil
+ logCursor = nil
+ hasMoreLog = true
+
+ do {
+ let page = try await fetchLogPage(cursor: nil)
+ log = page.results
+ logCursor = page.cursor
+ hasMoreLog = page.cursor != nil
+ } catch {
+ if isEmptyRepositoryError(error) {
+ log = []
+ logCursor = nil
+ hasMoreLog = false
+ } else {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ func loadMoreLogIfNeeded(currentItem: HgRevision) async {
+ guard let last = log.last,
+ last.id == currentItem.id,
+ hasMoreLog,
+ !isLoadingMoreLog else {
+ return
+ }
+
+ isLoadingMoreLog = true
+ defer { isLoadingMoreLog = false }
+
+ do {
+ let page = try await fetchLogPage(cursor: logCursor)
+ log.append(contentsOf: page.results)
+ logCursor = page.cursor
+ hasMoreLog = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage {
+ var variables: [String: any Sendable] = ["rid": repository.rid]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.logQuery,
+ variables: variables,
+ responseType: HgRevisionLogResponse.self
+ )
+ return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil)
+ }
+
+ private func loadReadmeFile() async {
+ for filename in Self.readmeFilenames {
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.readmeFileQuery(filename: filename),
+ variables: ["rid": repository.rid],
+ responseType: HgReadmeFileResponse.self
+ )
+
+ if let text = result.repository?.readme, !text.isEmpty {
+ readmePath = filename
+ if filename.hasSuffix(".md") {
+ readmeContent = .markdown(text)
+ } else if filename.hasSuffix(".org") {
+ readmeContent = .org(text)
+ } else {
+ readmeContent = .plainText(text)
+ }
+ return
+ }
+ } catch {
+ if isEmptyRepositoryError(error) {
+ readmeContent = nil
+ readmePath = nil
+ return
+ }
+ continue
+ }
+ }
+ }
+
+ func loadBrowseRoot() async {
+ await loadFiles(at: "")
+ }
+
+ func openFile(_ file: HgFile) async {
+ let path = joinedPath(for: file.name)
+ if file.isDirectory {
+ await loadFiles(at: path)
+ return
+ }
+
+ isLoadingBrowse = true
+ defer { isLoadingBrowse = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.catQuery,
+ variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
+ responseType: HgCatResponse.self
+ )
+
+ if let text = result.repository?.cat {
+ selectedFilePath = path
+ fileContent = text
+ } else {
+ await loadFiles(at: path)
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func navigateToPath(index: Int) async {
+ guard index >= 0, index <= pathStack.count else { return }
+ let targetPath = Array(pathStack.prefix(index)).joined(separator: "/")
+ await loadFiles(at: targetPath)
+ }
+
+ func dismissFileView() {
+ selectedFilePath = nil
+ fileContent = nil
+ }
+
+ func changeBrowseRevspec(_ newRevspec: String) async {
+ guard browseRevspec != newRevspec else { return }
+ browseRevspec = newRevspec
+ await loadBrowseRoot()
+ }
+
+ private func loadFiles(at path: String) async {
+ isLoadingBrowse = true
+ defer { isLoadingBrowse = false }
+ error = nil
+ selectedFilePath = nil
+ fileContent = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.filesQuery,
+ variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
+ responseType: HgFilesResponse.self
+ )
+
+ currentBrowsePath = path
+ pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
+ files = result.repository?.files?.results ?? []
+ } catch {
+ if isEmptyRepositoryError(error) {
+ currentBrowsePath = path
+ pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
+ files = []
+ } else {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ private func joinedPath(for name: String) -> String {
+ let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name
+ return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)"
+ }
+
+ private func isEmptyRepositoryError(_ error: Error) -> Bool {
+ if let srhtError = error as? SRHTError,
+ case .graphQLErrors(let errors) = srhtError {
+ return errors.contains {
+ let message = $0.message.localizedLowercase
+ return message.contains("missing")
+ || message.contains("not found")
+ || message.contains("unknown revision")
+ || message.contains("unknown revision or path not in the working tree")
+ }
+ }
+
+ let message = error.localizedDescription.localizedLowercase
+ return message.contains("missing")
+ || message.contains("not found")
+ || message.contains("unknown revision")
+ }
+}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
new file mode 100644
index 0000000..3894b9d
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
@@ -0,0 +1,239 @@
+import SwiftUI
+
+struct HgRepositorySettingsView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ let onDeleted: () -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: HgRepositorySettingsViewModel?
+ @State private var showDeleteConfirmation = false
+ @State private var pendingACLDeletion: HgACLEntry?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ settingsForm(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading settings…")
+ }
+ }
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
+ viewModel = vm
+ async let info: () = vm.loadRepositoryInfo()
+ async let acls: () = vm.loadACLs()
+ _ = await (info, acls)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ infoSection(viewModel)
+ accessSection(viewModel)
+ featuresSection(viewModel)
+ histeditSection(viewModel)
+ deleteSection(viewModel)
+ }
+ .srhtErrorBanner(error: $vm.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.")
+ }
+ .alert("Remove Access?", isPresented: Binding(
+ get: { pendingACLDeletion != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingACLDeletion = nil
+ }
+ }
+ )) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Access", role: .destructive) {
+ guard let entry = pendingACLDeletion else { return }
+ Task {
+ await viewModel.deleteACL(entry)
+ pendingACLDeletion = nil
+ }
+ }
+ } message: {
+ if let entry = pendingACLDeletion {
+ Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Info") {
+ LabeledContent("Name") {
+ Text(repository.name)
+ .font(.body.monospaced())
+ }
+
+ 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)
+ }
+
+ Button {
+ Task { await viewModel.saveInfo() }
+ } label: {
+ if viewModel.isSavingInfo {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Save Changes")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isSavingInfo)
+ }
+ }
+
+ @ViewBuilder
+ private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Access") {
+ if viewModel.isLoadingACLs {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if viewModel.acls.isEmpty {
+ Text("No access entries yet.")
+ .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: false) {
+ Button(role: .destructive) {
+ pendingACLDeletion = entry
+ } label: {
+ Label("Remove Access", systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ HStack {
+ TextField("Username or ~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 {
+ Text("Add")
+ }
+ }
+ .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ Text("Add a SourceHut user and choose read-only or read/write access.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Features") {
+ Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
+
+ Button {
+ Task { await viewModel.saveInfo() }
+ } label: {
+ if viewModel.isSavingInfo {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Save Changes")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isSavingInfo)
+ }
+ }
+
+ @ViewBuilder
+ private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Histedit") {
+ TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .disabled(true)
+
+ Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button("Remove Revision", role: .destructive) {}
+ .disabled(true)
+ }
+ }
+
+ @ViewBuilder
+ private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> 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/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
new file mode 100644
index 0000000..3e431f8
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -0,0 +1,287 @@
+import Foundation
+
+private struct HgUpdateRepositoryResponse: Decodable, Sendable {
+ let updateRepository: HgUpdatedRepository
+}
+
+private struct HgUpdatedRepository: Decodable, Sendable {
+ let id: Int
+}
+
+private struct HgRepositoryInfoResponse: Decodable, Sendable {
+ let repository: HgRepositoryInfo?
+}
+
+private struct HgRepositoryInfo: Decodable, Sendable {
+ let description: String?
+ let visibility: Visibility
+ let nonPublishing: Bool?
+}
+
+private struct HgACLResponse: Decodable, Sendable {
+ let repository: HgACLRepository?
+}
+
+private struct HgACLRepository: Decodable, Sendable {
+ let accessControlList: HgACLPage
+}
+
+private struct HgACLPage: Decodable, Sendable {
+ let results: [HgACLEntry]
+ let cursor: String?
+}
+
+private struct HgUpdateACLResponse: Decodable, Sendable {
+ let updateACL: HgACLEntry
+}
+
+private struct HgDeleteACLResponse: Decodable, Sendable {
+ let deleteACL: HgDeletedACL
+}
+
+private struct HgDeletedACL: Decodable, Sendable {
+ let id: Int
+}
+
+private struct HgDeleteRepositoryResponse: Decodable, Sendable {
+ let deleteRepository: HgDeletedRepository
+}
+
+private struct HgDeletedRepository: Decodable, Sendable {
+ let id: Int
+}
+
+struct HgACLEntry: Decodable, Sendable, Identifiable {
+ let id: Int
+ let mode: String
+ let entity: Entity
+}
+
+@Observable
+@MainActor
+final class HgRepositorySettingsViewModel {
+ let repositoryId: Int
+ let repositoryRid: String
+ let repositoryName: String
+ private let client: SRHTClient
+
+ var editedDescription: String
+ var editedVisibility: Visibility
+ var editedNonPublishing: Bool
+ var isSavingInfo = false
+
+ private(set) var acls: [HgACLEntry] = []
+ private(set) var isLoadingACLs = false
+ var newACLEntity = ""
+ var newACLMode = "RO"
+ var isAddingACL = false
+ var isDeletingACL = false
+
+ var histeditRevision = ""
+ var isDeleting = false
+ var didDelete = false
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repositoryId = repository.id
+ self.repositoryRid = repository.rid
+ self.repositoryName = repository.name
+ self.client = client
+ self.editedDescription = repository.description ?? ""
+ self.editedVisibility = repository.visibility
+ self.editedNonPublishing = false
+ }
+
+ private static let updateRepositoryMutation = """
+ mutation updateRepository($id: Int!, $input: RepoInput!) {
+ updateRepository(id: $id, input: $input) {
+ id
+ }
+ }
+ """
+
+ private static let accessControlListQuery = """
+ query hgAccessControlList($rid: ID!) {
+ repository(rid: $rid) {
+ accessControlList {
+ 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 }
+ }
+ """
+
+ private static let deleteRepositoryMutation = """
+ mutation deleteRepository($id: Int!) {
+ deleteRepository(id: $id) { id }
+ }
+ """
+
+ private static let repositoryInfoQuery = """
+ query hgRepositoryInfo($rid: ID!) {
+ repository(rid: $rid) {
+ description
+ visibility
+ nonPublishing
+ }
+ }
+ """
+
+ func loadRepositoryInfo() async {
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.repositoryInfoQuery,
+ variables: ["rid": repositoryRid],
+ responseType: HgRepositoryInfoResponse.self
+ )
+
+ if let repository = result.repository {
+ editedDescription = repository.description ?? ""
+ editedVisibility = repository.visibility
+ editedNonPublishing = repository.nonPublishing ?? false
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func saveInfo() async {
+ isSavingInfo = true
+ defer { isSavingInfo = false }
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "description": editedDescription,
+ "visibility": editedVisibility.rawValue,
+ "nonPublishing": editedNonPublishing
+ ]
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.updateRepositoryMutation,
+ variables: ["id": repositoryId, "input": input],
+ responseType: HgUpdateRepositoryResponse.self
+ )
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func loadACLs() async {
+ guard !isLoadingACLs else { return }
+ isLoadingACLs = true
+ defer { isLoadingACLs = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.accessControlListQuery,
+ variables: ["rid": repositoryRid],
+ responseType: HgACLResponse.self
+ )
+ acls = result.repository?.accessControlList.results ?? []
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func addACL() async {
+ let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !rawEntity.isEmpty else { return }
+ let entity = hgCanonicalEntity(from: rawEntity)
+ isAddingACL = true
+ defer { isAddingACL = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.updateACLMutation,
+ variables: [
+ "repoId": repositoryId,
+ "mode": newACLMode,
+ "entity": entity
+ ],
+ responseType: HgUpdateACLResponse.self
+ )
+ if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
+ acls[index] = result.updateACL
+ } else {
+ acls.append(result.updateACL)
+ }
+ newACLEntity = ""
+ } catch {
+ let message = error.localizedDescription
+ if message.localizedCaseInsensitiveContains("No such repository or user found") {
+ self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
+ } else {
+ self.error = message
+ }
+ }
+ }
+
+ private func hgCanonicalEntity(from input: String) -> String {
+ let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
+ return "~\(username)"
+ }
+
+ func deleteACL(_ entry: HgACLEntry) async {
+ isDeletingACL = true
+ defer { isDeletingACL = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.deleteACLMutation,
+ variables: ["id": entry.id],
+ responseType: HgDeleteACLResponse.self
+ )
+ acls.removeAll { $0.id == entry.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteRepository() async {
+ isDeleting = true
+ defer { isDeleting = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.deleteRepositoryMutation,
+ variables: ["id": repositoryId],
+ responseType: HgDeleteRepositoryResponse.self
+ )
+ didDelete = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
new file mode 100644
index 0000000..b53885e
--- /dev/null
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -0,0 +1,1125 @@
+import SwiftUI
+import WebKit
+
+struct ReadmeView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var isShowingRepositoryDetails = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ headerSection
+ metadataSection
+ repositoryDetailsSection
+ latestChangeSection
+ readmeSection
+ }
+ .padding()
+ }
+ .task {
+ async let readme: () = viewModel.loadReadme()
+ async let commits: () = viewModel.loadCommits()
+ async let refs: () = viewModel.loadReferences()
+ _ = await (readme, commits, refs)
+ }
+ .navigationDestination(for: CommitSummary.self) { commit in
+ CommitDetailView(
+ commitSummary: commit,
+ repository: viewModel.repository
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(viewModel.repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Text(viewModel.repository.name)
+ .font(.largeTitle.weight(.semibold))
+ if let description = viewModel.repository.description, !description.isEmpty {
+ Text(description)
+ .font(.body)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var metadataSection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
+ )
+
+ if let readmePath = viewModel.readmePath {
+ SummaryMetadataRow(
+ icon: "doc.text",
+ title: readmePath
+ )
+ }
+ }
+ }
+
+ private var repositoryDetailsSection: some View {
+ DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
+ VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
+ SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
+ SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
+ SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
+ }
+ .padding(.top, 8)
+ } label: {
+ Text("Repository Details")
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ @ViewBuilder
+ private var latestChangeSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
+ SRHTLoadingStateView(message: "Loading latest change…")
+ .frame(maxWidth: .infinity)
+ } else if let commit = viewModel.commits.first {
+ NavigationLink(value: commit) {
+ SummaryMetadataRow(
+ icon: "arrow.trianglehead.clockwise",
+ title: commit.title,
+ subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ } else if let error = viewModel.error, viewModel.commits.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Latest Change",
+ message: error,
+ retryAction: { await viewModel.loadCommits() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No Recent Commits",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository does not have any commit history yet.")
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var readmeSection: some View {
+ if viewModel.isLoadingReadme {
+ SRHTLoadingStateView(message: "Loading README…")
+ } else if let content = viewModel.readmeContent {
+ RenderedMarkupContentView(
+ content: sharedReadmeContent(from: content),
+ readmePath: viewModel.readmePath,
+ colorScheme: colorScheme,
+ ownerCanonicalName: viewModel.repository.owner.canonicalName,
+ repositoryName: viewModel.repository.name
+ )
+ } else if let error = viewModel.error, !viewModel.readmeLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load README",
+ message: error,
+ retryAction: { await viewModel.loadReadme() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
+ }
+
+ private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
+ switch content {
+ case .html(let html):
+ .html(html)
+ case .markdown(let text):
+ .markdown(text)
+ case .org(let text):
+ .org(text)
+ case .plainText(let text):
+ .plainText(text)
+ }
+ }
+}
+
+enum RenderedMarkupContent: Sendable {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+}
+
+struct RenderedMarkupContentView: View {
+ let content: RenderedMarkupContent
+ let readmePath: String?
+ let colorScheme: ColorScheme
+ let ownerCanonicalName: String
+ let repositoryName: String
+ var repositoryHost = "git.sr.ht"
+
+ @State private var renderedHTML: String?
+
+ private var cacheKey: String {
+ switch content {
+ case .html(let html):
+ "html:\(readmePath ?? "custom"):\(html)"
+ case .markdown(let text):
+ "markdown:\(readmePath ?? ""):\(text)"
+ case .org(let text):
+ "org:\(readmePath ?? ""):\(text)"
+ case .plainText(let text):
+ "plain:\(readmePath ?? ""):\(text)"
+ }
+ }
+
+ var body: some View {
+ Group {
+ switch content {
+ case .html(let html):
+ HTMLWebView(html: html, colorScheme: colorScheme)
+ case .markdown, .org:
+ if let renderedHTML {
+ HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
+ } else {
+ SRHTLoadingStateView(message: "Preparing README…")
+ }
+ case .plainText(let text):
+ Text(text)
+ .font(.system(.body, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .task(id: cacheKey) {
+ await prepareHTMLIfNeeded()
+ }
+ }
+
+ private func prepareHTMLIfNeeded() async {
+ switch content {
+ case .html, .plainText:
+ renderedHTML = nil
+ case .markdown(let text):
+ if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
+ renderedHTML = cached
+ return
+ }
+ let html = await Task.detached(priority: .userInitiated) {
+ markdownToHTML(text) { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: ownerCanonicalName,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+ }
+ }.value
+ RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ case .org(let text):
+ if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
+ renderedHTML = cached
+ return
+ }
+ let html = await Task.detached(priority: .userInitiated) {
+ orgToHTML(text) { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: ownerCanonicalName,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+ }
+ }.value
+ RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ }
+ }
+}
+
+private final class RenderedReadmeHTMLCache: @unchecked Sendable {
+ static let shared = RenderedReadmeHTMLCache()
+
+ private let storage = NSCache<NSString, NSString>()
+
+ func html(forKey key: String) -> String? {
+ storage.object(forKey: key as NSString) as String?
+ }
+
+ func setHTML(_ html: String, forKey key: String) {
+ storage.setObject(html as NSString, forKey: key as NSString)
+ }
+
+ func removeAll() {
+ storage.removeAllObjects()
+ }
+}
+
+@MainActor
+func clearWebContentRenderCaches() {
+ RenderedReadmeHTMLCache.shared.removeAll()
+ HTMLWebViewCoordinator.heightCache.removeAllObjects()
+}
+
+// MARK: - Markdown to HTML
+
+nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ let normalizedText = text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var html = ""
+ var inCodeBlock = false
+ var inList = false
+ var paragraph: [String] = []
+
+ func flushParagraph() {
+ if !paragraph.isEmpty {
+ let normalizedParagraph = paragraph
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ html += "<p>" + normalizedParagraph + "</p>\n"
+ paragraph = []
+ }
+ }
+
+ func closeList() {
+ if inList {
+ html += "</ul>\n"
+ inList = false
+ }
+ }
+
+ for line in lines {
+ // Fenced code blocks
+ if line.hasPrefix("```") {
+ if inCodeBlock {
+ html += "</code></pre>\n"
+ inCodeBlock = false
+ } else {
+ flushParagraph()
+ closeList()
+ html += "<pre><code>"
+ inCodeBlock = true
+ }
+ continue
+ }
+
+ if inCodeBlock {
+ html += escapeHTML(line) + "\n"
+ continue
+ }
+
+ // Headings
+ if line.hasPrefix("### ") {
+ flushParagraph()
+ closeList()
+ html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
+ continue
+ }
+ if line.hasPrefix("## ") {
+ flushParagraph()
+ closeList()
+ html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
+ continue
+ }
+ if line.hasPrefix("# ") {
+ flushParagraph()
+ closeList()
+ html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
+ continue
+ }
+
+ // List items
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
+ flushParagraph()
+ if !inList {
+ html += "<ul>\n"
+ inList = true
+ }
+ html += "<li>" + renderTaskListItem(
+ String(trimmed.dropFirst(2)),
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ // Blank line
+ if trimmed.isEmpty {
+ flushParagraph()
+ closeList()
+ continue
+ }
+
+ // Regular text — accumulate into paragraph
+ paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
+ }
+
+ // Flush remaining state
+ if inCodeBlock {
+ html += "</code></pre>\n"
+ }
+ flushParagraph()
+ closeList()
+
+ return html
+}
+
+nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ var result = escapeHTML(text)
+
+ // Images: ![alt](url)
+ result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
+ let alt = nsText.substring(with: match.range(at: 1))
+ let source = nsText.substring(with: match.range(at: 2))
+ let resolvedSource = imageURLResolver?(source) ?? source
+ return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
+ }
+ // Links: [text](url)
+ result = result.replacingOccurrences(
+ of: #"\[([^\]]+)\]\(([^)]+)\)"#,
+ with: #"<a href="$2">$1</a>"#,
+ options: .regularExpression
+ )
+ // Bold: **text**
+ result = result.replacingOccurrences(
+ of: #"\*\*(.+?)\*\*"#,
+ with: "<strong>$1</strong>",
+ options: .regularExpression
+ )
+ // Italic: *text*
+ result = result.replacingOccurrences(
+ of: #"\*(.+?)\*"#,
+ with: "<em>$1</em>",
+ options: .regularExpression
+ )
+ // Inline code: `text`
+ result = result.replacingOccurrences(
+ of: #"`([^`]+)`"#,
+ with: "<code>$1</code>",
+ options: .regularExpression
+ )
+
+ return result
+}
+
+// MARK: - Org-mode to HTML
+
+nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ let normalizedText = text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var html = ""
+ var listType: OrgListType?
+ var inQuoteBlock = false
+ var inPropertyDrawer = false
+ var srcLanguage: String?
+ var paragraph: [String] = []
+ var tableRows: [[String]] = []
+ var propertyRows: [(String, String)] = []
+
+ func flushParagraph() {
+ if !paragraph.isEmpty {
+ let normalizedParagraph = paragraph
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
+ paragraph = []
+ }
+ }
+
+ func closeList() {
+ switch listType {
+ case .unordered:
+ html += "</ul>\n"
+ case .ordered:
+ html += "</ol>\n"
+ case nil:
+ break
+ }
+ listType = nil
+ }
+
+ func flushTable() {
+ guard !tableRows.isEmpty else { return }
+ let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
+ let headerRow = tableRows.first ?? []
+ let bodyRows: [[String]]
+
+ html += "<table>\n"
+ if hasHeaderSeparator {
+ html += "<thead><tr>"
+ for cell in headerRow {
+ html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
+ }
+ html += "</tr></thead>\n<tbody>\n"
+ bodyRows = Array(tableRows.dropFirst(2))
+ } else {
+ bodyRows = tableRows
+ }
+
+ for row in bodyRows {
+ html += "<tr>"
+ for cell in row {
+ html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
+ }
+ html += "</tr>\n"
+ }
+
+ if hasHeaderSeparator {
+ html += "</tbody>\n"
+ }
+ html += "</table>\n"
+ tableRows = []
+ }
+
+ func flushPropertyDrawer() {
+ guard !propertyRows.isEmpty else { return }
+ html += "<dl class=\"org-properties\">\n"
+ for (key, value) in propertyRows {
+ html += "<dt>" + escapeHTML(key) + "</dt>"
+ html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
+ }
+ html += "</dl>\n"
+ propertyRows = []
+ }
+
+ func closeQuoteBlock() {
+ if inQuoteBlock {
+ flushParagraph()
+ html += "</blockquote>\n"
+ inQuoteBlock = false
+ }
+ }
+
+ func closeSourceBlock() {
+ if srcLanguage != nil {
+ html += "</code></pre>\n"
+ srcLanguage = nil
+ }
+ }
+
+ func flushBlockState() {
+ flushParagraph()
+ closeList()
+ flushTable()
+ flushPropertyDrawer()
+ }
+
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+
+ if srcLanguage != nil {
+ if trimmed.lowercased() == "#+end_src" {
+ closeSourceBlock()
+ } else {
+ html += escapeHTML(line) + "\n"
+ }
+ continue
+ }
+
+ if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
+ closeQuoteBlock()
+ continue
+ }
+
+ if trimmed.lowercased().hasPrefix("#+begin_src") {
+ closeQuoteBlock()
+ flushBlockState()
+ let language = trimmed
+ .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
+ .dropFirst()
+ .first
+ .map(String.init)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
+ html += "<pre><code\(classAttribute)>"
+ srcLanguage = language ?? ""
+ continue
+ }
+
+ if trimmed.lowercased() == "#+begin_quote" {
+ flushBlockState()
+ html += "<blockquote>\n"
+ inQuoteBlock = true
+ continue
+ }
+
+ if trimmed == ":PROPERTIES:" {
+ closeQuoteBlock()
+ flushBlockState()
+ inPropertyDrawer = true
+ continue
+ }
+
+ if trimmed == ":END:", inPropertyDrawer {
+ flushPropertyDrawer()
+ inPropertyDrawer = false
+ continue
+ }
+
+ if inPropertyDrawer,
+ trimmed.hasPrefix(":"),
+ let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
+ let keyStart = trimmed.index(after: trimmed.startIndex)
+ let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
+ let valueStart = trimmed.index(after: secondColonIndex)
+ let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
+ if !key.isEmpty {
+ propertyRows.append((key, value))
+ continue
+ }
+ }
+
+ if isOrgTableLine(trimmed) {
+ closeQuoteBlock()
+ flushParagraph()
+ closeList()
+ tableRows.append(parseOrgTableRow(trimmed))
+ continue
+ } else {
+ flushTable()
+ }
+
+ // Org headings: * heading, ** heading, *** heading
+ if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
+ closeQuoteBlock()
+ flushBlockState()
+ let level = match.1.count
+ let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
+ html += "<h\(level)>" + content + "</h\(level)>\n"
+ continue
+ }
+
+ // List items: - item
+ if trimmed.hasPrefix("- ") {
+ flushParagraph()
+ flushPropertyDrawer()
+ if listType != .unordered {
+ closeList()
+ html += "<ul>\n"
+ listType = .unordered
+ }
+ html += "<li>" + renderTaskListItem(
+ String(trimmed.dropFirst(2)),
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ if let orderedItem = orderedListItem(in: trimmed) {
+ flushParagraph()
+ flushPropertyDrawer()
+ if listType != .ordered {
+ closeList()
+ html += "<ol>\n"
+ listType = .ordered
+ }
+ html += "<li>" + renderTaskListItem(
+ orderedItem,
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ // Blank line
+ if trimmed.isEmpty {
+ if inQuoteBlock {
+ flushParagraph()
+ } else {
+ flushBlockState()
+ }
+ continue
+ }
+
+ // Regular text
+ paragraph.append(line)
+ }
+
+ closeSourceBlock()
+ closeQuoteBlock()
+ flushBlockState()
+
+ return html
+}
+
+nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ var result = escapeHTML(text)
+ var protectedFragments: [String: String] = [:]
+
+ result = protectMatches(
+ in: result,
+ pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let url = nsText.substring(with: match.range(at: 1))
+ let label = nsText.substring(with: match.range(at: 2))
+ if let imageHTML = makeOrgImageHTML(
+ source: url,
+ alt: label,
+ imageURLResolver: imageURLResolver
+ ) {
+ return imageHTML
+ }
+ return #"<a href="\#(url)">\#(label)</a>"#
+ }
+ result = protectMatches(
+ in: result,
+ pattern: #"\[\[([^\]]+)\]\]"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let url = nsText.substring(with: match.range(at: 1))
+ if let imageHTML = makeOrgImageHTML(
+ source: url,
+ alt: nil,
+ imageURLResolver: imageURLResolver
+ ) {
+ return imageHTML
+ }
+ return #"<a href="\#(url)">\#(url)</a>"#
+ }
+ result = protectMatches(
+ in: result,
+ pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let tildeRange = match.range(at: 1)
+ let equalsRange = match.range(at: 2)
+ let codeText: String
+ if tildeRange.location != NSNotFound {
+ codeText = nsText.substring(with: tildeRange)
+ } else {
+ codeText = nsText.substring(with: equalsRange)
+ }
+ return "<code>\(codeText)</code>"
+ }
+
+ // Bold: *text*
+ result = result.replacingOccurrences(
+ of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
+ with: "<strong>$1</strong>",
+ options: .regularExpression
+ )
+ // Italic: /text/
+ result = result.replacingOccurrences(
+ of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
+ with: "<em>$1</em>",
+ options: .regularExpression
+ )
+
+ for (token, fragment) in protectedFragments {
+ result = result.replacingOccurrences(of: token, with: fragment)
+ }
+
+ return result
+}
+
+// MARK: - HTML Escaping
+
+nonisolated func escapeHTML(_ text: String) -> String {
+ text.replacingOccurrences(of: "&", with: "&amp;")
+ .replacingOccurrences(of: "<", with: "&lt;")
+ .replacingOccurrences(of: ">", with: "&gt;")
+ .replacingOccurrences(of: "\"", with: "&quot;")
+}
+
+nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
+ escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
+}
+
+nonisolated private func isOrgTableLine(_ line: String) -> Bool {
+ line.hasPrefix("|") && line.hasSuffix("|")
+}
+
+nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
+ line
+ .split(separator: "|", omittingEmptySubsequences: false)
+ .dropFirst()
+ .dropLast()
+ .map { String($0).trimmingCharacters(in: .whitespaces) }
+}
+
+nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
+ let trimmed = cell.trimmingCharacters(in: .whitespaces)
+ return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
+}
+
+private enum OrgListType {
+ case unordered
+ case ordered
+}
+
+nonisolated private func orderedListItem(in line: String) -> String? {
+ guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
+ return String(match.2)
+}
+
+nonisolated private func protectMatches(
+ in text: String,
+ pattern: String,
+ protectedFragments: inout [String: String],
+ transform: (NSTextCheckingResult, NSString) -> String
+) -> String {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
+ var result = text
+ let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
+
+ for match in matches.reversed() {
+ let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
+ let nsText = result as NSString
+ protectedFragments[token] = transform(match, nsText)
+ result = nsText.replacingCharacters(in: match.range, with: token)
+ }
+
+ return result
+}
+
+nonisolated private func replaceMatches(
+ in text: String,
+ pattern: String,
+ transform: (NSTextCheckingResult, NSString) -> String
+) -> String {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
+ var result = text
+ let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
+
+ for match in matches.reversed() {
+ let nsText = result as NSString
+ let replacement = transform(match, nsText)
+ result = nsText.replacingCharacters(in: match.range, with: replacement)
+ }
+
+ return result
+}
+
+nonisolated private func makeOrgImageHTML(
+ source: String,
+ alt: String?,
+ imageURLResolver: ((String) -> String?)?
+) -> String? {
+ guard isRenderableImageSource(source) else { return nil }
+ let resolvedSource = imageURLResolver?(source) ?? source
+ let altText = escapeHTMLAttribute(alt ?? "")
+ return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
+}
+
+nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
+ let lowercased = source.lowercased()
+ return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
+ .contains(where: { lowercased.hasSuffix($0) })
+}
+
+nonisolated func resolveRepositoryAssetURL(
+ _ source: String,
+ owner: String,
+ repositoryName: String,
+ readmePath: String?
+) -> String? {
+ let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSource.isEmpty else { return nil }
+
+ if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
+ return trimmedSource
+ }
+
+ let relativePath: String
+ if trimmedSource.hasPrefix("/") {
+ relativePath = String(trimmedSource.dropFirst())
+ } else {
+ let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
+ relativePath = normalizeRepositoryPath(
+ (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
+ )
+ }
+
+ guard !relativePath.isEmpty else { return nil }
+ return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
+}
+
+nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
+ var components: [String] = []
+
+ for part in path.split(separator: "/") {
+ switch part {
+ case ".":
+ continue
+ case "..":
+ if !components.isEmpty {
+ components.removeLast()
+ }
+ default:
+ components.append(String(part))
+ }
+ }
+
+ return components.joined(separator: "/")
+}
+
+nonisolated private func renderTaskListItem(
+ _ text: String,
+ inlineRenderer: (String) -> String
+) -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ guard trimmed.count >= 4 else {
+ return inlineRenderer(text)
+ }
+
+ let prefix = String(trimmed.prefix(4))
+ let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
+
+ switch prefix {
+ case "[ ] ":
+ return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
+ case "[x] ", "[X] ":
+ return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
+ default:
+ return inlineRenderer(text)
+ }
+}
+
+// MARK: - WKWebView Wrapper
+
+/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
+struct HTMLWebView: View {
+ let html: String
+ let colorScheme: ColorScheme
+ var style: HTMLWebViewStyle = .readme
+ @State private var contentHeight: CGFloat = 1
+ @State private var loadError: String?
+ @State private var reloadToken = 0
+
+ var body: some View {
+ Group {
+ if let loadError {
+ SRHTErrorStateView(
+ title: "Couldn't Render Content",
+ message: loadError,
+ retryAction: {
+ await MainActor.run {
+ self.loadError = nil
+ reloadToken += 1
+ }
+ }
+ )
+ } else {
+ HTMLWebViewRepresentable(
+ html: html,
+ colorScheme: colorScheme,
+ style: style,
+ dynamicHeight: $contentHeight,
+ loadError: $loadError,
+ reloadToken: reloadToken
+ )
+ .frame(height: max(contentHeight, 1))
+ }
+ }
+ }
+}
+
+struct HTMLWebViewStyle: Sendable {
+ let bodyFontSize: Int
+ let lineHeight: Double
+ let codeFontSize: Int
+ let viewport: String
+
+ static let readme = HTMLWebViewStyle(
+ bodyFontSize: 16,
+ lineHeight: 1.6,
+ codeFontSize: 13,
+ viewport: "width=device-width, initial-scale=1, maximum-scale=1"
+ )
+
+ static let commentPreview = HTMLWebViewStyle(
+ bodyFontSize: 15,
+ lineHeight: 1.5,
+ codeFontSize: 12,
+ viewport: "width=device-width, initial-scale=1, user-scalable=no"
+ )
+}
+
+private struct HTMLWebViewRepresentable: UIViewRepresentable {
+ let html: String
+ let colorScheme: ColorScheme
+ let style: HTMLWebViewStyle
+ @Binding var dynamicHeight: CGFloat
+ @Binding var loadError: String?
+ let reloadToken: Int
+
+ func makeCoordinator() -> HTMLWebViewCoordinator {
+ HTMLWebViewCoordinator(parent: self)
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+ config.defaultWebpagePreferences.allowsContentJavaScript = true
+ config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
+ let webView = WKWebView(frame: .zero, configuration: config)
+ webView.isOpaque = false
+ webView.backgroundColor = .clear
+ webView.clipsToBounds = false
+ webView.scrollView.isScrollEnabled = false
+ webView.scrollView.contentInsetAdjustmentBehavior = .never
+ webView.scrollView.clipsToBounds = false
+ webView.navigationDelegate = context.coordinator
+ return webView
+ }
+
+ func updateUIView(_ webView: WKWebView, context: Context) {
+ let textColor = colorScheme == .dark ? "#fff" : "#000"
+ let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
+
+ let wrapped = """
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta name="viewport" content="\(style.viewport)">
+ <style>
+ body {
+ font-family: -apple-system, system-ui, sans-serif;
+ font-size: \(style.bodyFontSize)px;
+ line-height: \(style.lineHeight);
+ padding: 0;
+ margin: 0;
+ color: \(textColor);
+ background: transparent;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ max-width: 100%;
+ }
+ * { box-sizing: border-box; }
+ h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
+ p:first-child { margin-top: 0; }
+ p:last-child { margin-bottom: 0; }
+ pre, code {
+ font-family: ui-monospace, Menlo, monospace;
+ font-size: \(style.codeFontSize)px;
+ background: rgba(128, 128, 128, 0.15);
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+ pre code { padding: 0; background: none; }
+ pre {
+ padding: 8px;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ img { max-width: 100%; height: auto; }
+ input[type="checkbox"] {
+ margin-right: 0.45rem;
+ vertical-align: middle;
+ }
+ .task-list-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.1rem;
+ }
+ a { color: \(linkColor); }
+ table { border-collapse: collapse; width: 100%; }
+ td, th { border: 1px solid #ccc; padding: 4px 8px; }
+ </style>
+ </head>
+ <body>\(html)</body>
+ </html>
+ """
+
+ if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
+ let height = CGFloat(cachedHeight)
+ if abs(dynamicHeight - height) > 0.5 {
+ dynamicHeight = height
+ }
+ }
+
+ guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
+ context.coordinator.lastHTML = wrapped
+ context.coordinator.lastReloadToken = reloadToken
+ if loadError != nil {
+ DispatchQueue.main.async {
+ self.loadError = nil
+ }
+ }
+ webView.loadHTMLString(wrapped, baseURL: nil)
+ }
+}
+
+private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
+ static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
+ static let heightCache = NSCache<NSString, NSNumber>()
+
+ let parent: HTMLWebViewRepresentable
+ var lastHTML: String?
+ var lastReloadToken = 0
+
+ init(parent: HTMLWebViewRepresentable) {
+ self.parent = parent
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ DispatchQueue.main.async {
+ self.parent.loadError = nil
+ }
+ updateHeight(for: webView)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
+ guard let self, let webView else { return }
+ self.updateHeight(for: webView)
+ }
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ handleLoadFailure(error)
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ handleLoadFailure(error)
+ }
+
+ private func handleLoadFailure(_ error: Error) {
+ let nsError = error as NSError
+ guard nsError.code != NSURLErrorCancelled else { return }
+ DispatchQueue.main.async {
+ self.parent.loadError = "The content could not be displayed right now."
+ }
+ }
+
+ private func updateHeight(for webView: WKWebView) {
+ let script = """
+ Math.max(
+ document.body.scrollHeight,
+ document.body.offsetHeight,
+ document.documentElement.scrollHeight,
+ document.documentElement.offsetHeight,
+ Math.ceil(document.body.getBoundingClientRect().height),
+ Math.ceil(document.documentElement.getBoundingClientRect().height)
+ )
+ """
+
+ webView.evaluateJavaScript(script) { [weak self] result, _ in
+ guard let value = result as? Double, value > 0 else { return }
+ let height = ceil(value) + 4
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if let html = self.lastHTML {
+ Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
+ }
+ if abs(self.parent.dynamicHeight - height) > 0.5 {
+ self.parent.dynamicHeight = height
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/ReferencesListView.swift b/Hutch/Views/Repositories/ReferencesListView.swift
new file mode 100644
index 0000000..615ba25
--- /dev/null
+++ b/Hutch/Views/Repositories/ReferencesListView.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+struct ReferencesListView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ var body: some View {
+ List {
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches, id: \.name) { ref in
+ ReferenceRow(reference: ref, prefix: "refs/heads/")
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags, id: \.name) { ref in
+ ReferenceRow(reference: ref, prefix: "refs/tags/")
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingRefs, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ SRHTLoadingStateView(message: "Loading references…")
+ } else if let error = viewModel.error, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load References",
+ message: error,
+ retryAction: { await viewModel.loadReferences() }
+ )
+ } else if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ ContentUnavailableView(
+ "No References",
+ systemImage: "arrow.triangle.branch",
+ description: Text("This repository has no branches or tags.")
+ )
+ }
+ }
+ .task {
+ if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ await viewModel.loadReferences()
+ }
+ }
+ .refreshable {
+ await viewModel.loadReferences()
+ }
+ }
+}
+
+private struct ReferenceRow: View {
+ let reference: Reference
+ let prefix: String
+
+ var body: some View {
+ HStack {
+ Label {
+ Text(shortName)
+ .font(.body.monospaced())
+ } icon: {
+ Image(systemName: icon)
+ .foregroundStyle(iconColor)
+ }
+
+ Spacer()
+
+ Text(String((reference.target ?? "").prefix(8)))
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var shortName: String {
+ if reference.name.hasPrefix(prefix) {
+ String(reference.name.dropFirst(prefix.count))
+ } else {
+ reference.name
+ }
+ }
+
+ private var icon: String {
+ prefix.contains("tags") ? "tag" : "arrow.triangle.branch"
+ }
+
+ private var iconColor: Color {
+ prefix.contains("tags") ? .orange : .blue
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
new file mode 100644
index 0000000..9044e56
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct RepositoryDetailView: View {
+ var repository: RepositorySummary
+ var onDeleted: (() -> Void)?
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryDetailViewModel?
+ @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
+ @State private var showSettings = false
+ @State private var displayName: String
+
+ init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
+ self.repository = repository
+ self.onDeleted = onDeleted
+ self._displayName = State(initialValue: repository.name)
+ }
+
+ var body: some View {
+ if repository.service == .hg {
+ HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
+ } else {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading repository…")
+ }
+ }
+ .navigationTitle(displayName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ Button {
+ showSettings = true
+ } label: {
+ Image(systemName: "gear")
+ }
+ }
+ }
+ .sheet(isPresented: $showSettings) {
+ RepositorySettingsView(
+ repository: repository,
+ branches: viewModel?.branches ?? [],
+ client: appState.client,
+ onRenamed: { newName in
+ displayName = newName
+ },
+ onDeleted: {
+ dismiss()
+ onDeleted?()
+ }
+ )
+ }
+ .task {
+ if viewModel == nil {
+ viewModel = RepositoryDetailViewModel(
+ repository: repository,
+ client: appState.client
+ )
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ Picker("Tab", selection: $selectedTab) {
+ ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
+ Text(tab.rawValue).tag(tab)
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+
+ Divider()
+
+ switch selectedTab {
+ case .summary:
+ ReadmeView(viewModel: viewModel)
+ case .tree:
+ FileTreeView(
+ repository: repository,
+ client: appState.client
+ )
+ case .log:
+ CommitLogView(viewModel: viewModel)
+ case .refs:
+ ReferencesListView(viewModel: viewModel)
+ case .artifacts:
+ ArtifactsView(viewModel: viewModel)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
new file mode 100644
index 0000000..afc748d
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -0,0 +1,397 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct LogResponse: Decodable, Sendable {
+ let repository: LogRepository?
+}
+
+private struct LogRepository: Decodable, Sendable {
+ let log: LogPage
+}
+
+private struct LogPage: Decodable, Sendable {
+ let results: [CommitSummary]
+ let cursor: String?
+}
+
+private struct RefsResponse: Decodable, Sendable {
+ let repository: RefsRepository?
+}
+
+private struct RefsRepository: Decodable, Sendable {
+ let references: RefsPage
+}
+
+private struct RefsPage: Decodable, Sendable {
+ let results: [Reference]
+ let cursor: String?
+}
+
+private struct ReadmeResponse: Decodable, Sendable {
+ let repository: ReadmeRepository?
+}
+
+private struct ReadmeRepository: Decodable, Sendable {
+ let readme: String?
+}
+
+private struct PathResponse: Decodable, Sendable {
+ let repository: PathRepository?
+}
+
+private struct PathRepository: Decodable, Sendable {
+ let readme: PathEntry?
+}
+
+private struct PathEntry: Decodable, Sendable {
+ let object: PathObject?
+}
+
+private struct PathObject: Decodable, Sendable {
+ let text: String?
+}
+
+private struct ArtifactsResponse: Decodable, Sendable {
+ let repository: ArtifactsRepository?
+}
+
+private struct ArtifactsRepository: Decodable, Sendable {
+ let references: ArtifactRefsPage
+}
+
+private struct ArtifactRefsPage: Decodable, Sendable {
+ let results: [ArtifactRef]
+ let cursor: String?
+}
+
+private struct ArtifactRef: Decodable, Sendable {
+ let name: String
+ let artifacts: ArtifactPage
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class RepositoryDetailViewModel {
+
+ enum Tab: String, CaseIterable {
+ case summary = "Summary"
+ case tree = "Tree"
+ case log = "Log"
+ case refs = "Refs"
+ case artifacts = "Artifacts"
+ }
+
+ let repository: RepositorySummary
+ private var service: SRHTService { repository.service }
+ private let client: SRHTClient
+
+ // MARK: - Commit log state
+
+ private(set) var commits: [CommitSummary] = []
+ private(set) var isLoadingCommits = false
+ private(set) var isLoadingMoreCommits = false
+ private var commitCursor: String?
+ private var hasMoreCommits = true
+
+ // MARK: - References state
+
+ private(set) var branches: [Reference] = []
+ private(set) var tags: [Reference] = []
+ private(set) var isLoadingRefs = false
+
+ // MARK: - README state
+
+ enum ReadmeContent {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+ }
+
+ private(set) var readmeContent: ReadmeContent?
+ private(set) var readmePath: String?
+ private(set) var isLoadingReadme = false
+ private(set) var readmeLoaded = false
+
+ // MARK: - Artifacts state
+
+ private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
+ private(set) var isLoadingArtifacts = false
+
+ // MARK: - Error
+
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repository = repository
+ self.client = client
+ }
+
+ // MARK: - Commit log
+
+ private static let logQuery = """
+ query repoLog($rid: ID!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ log(cursor: $cursor) {
+ results {
+ id
+ shortId
+ author { name email time }
+ message
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadCommits() async {
+ guard !isLoadingCommits else { return }
+ isLoadingCommits = true
+ error = nil
+ commitCursor = nil
+ hasMoreCommits = true
+
+ do {
+ let page = try await fetchCommitPage(cursor: nil)
+ commits = page.results
+ commitCursor = page.cursor
+ hasMoreCommits = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingCommits = false
+ }
+
+ func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
+ guard let last = commits.last,
+ last.id == currentItem.id,
+ hasMoreCommits,
+ !isLoadingMoreCommits else {
+ return
+ }
+
+ isLoadingMoreCommits = true
+
+ do {
+ let page = try await fetchCommitPage(cursor: commitCursor)
+ commits.append(contentsOf: page.results)
+ commitCursor = page.cursor
+ hasMoreCommits = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMoreCommits = false
+ }
+
+ private func fetchCommitPage(cursor: String?) async throws -> LogPage {
+ var variables: [String: any Sendable] = ["rid": repository.rid]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result: LogResponse
+ do {
+ result = try await client.execute(
+ service: service,
+ query: Self.logQuery,
+ variables: variables,
+ responseType: LogResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ return LogPage(results: [], cursor: nil)
+ }
+ throw error
+ }
+ guard let repo = result.repository else {
+ return LogPage(results: [], cursor: nil)
+ }
+ return repo.log
+ }
+
+ // MARK: - References
+
+ private static let refsQuery = """
+ query refs($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results { name target }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadReferences() async {
+ guard !isLoadingRefs else { return }
+ isLoadingRefs = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.refsQuery,
+ variables: ["rid": repository.rid],
+ responseType: RefsResponse.self
+ )
+ let allRefs = result.repository?.references.results ?? []
+ branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
+ tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingRefs = false
+ }
+
+ // MARK: - README
+
+ private static let readmeQuery = """
+ query readme($rid: ID!) {
+ repository(rid: $rid) {
+ readme
+ }
+ }
+ """
+
+ private static func readmeFileQuery(filename: String) -> String {
+ """
+ query readmeFile($rid: ID!) {
+ repository(rid: $rid) {
+ readme: path(revspec: "HEAD", path: "\(filename)") {
+ object {
+ ... on TextBlob { text }
+ }
+ }
+ }
+ }
+ """
+ }
+
+ private static let readmeFilenames = [
+ "README.md", "README.org", "README.txt", "README",
+ "readme.md", "readme.org"
+ ]
+
+ func loadReadme() async {
+ guard !isLoadingReadme, !readmeLoaded else { return }
+ isLoadingReadme = true
+ defer { isLoadingReadme = false }
+ error = nil
+
+ do {
+ // Step 1: Check the custom HTML readme set via the web UI
+ let result = try await client.execute(
+ service: service,
+ query: Self.readmeQuery,
+ variables: ["rid": repository.rid],
+ responseType: ReadmeResponse.self
+ )
+ if let html = result.repository?.readme, !html.isEmpty {
+ readmePath = nil
+ readmeContent = .html(html)
+ readmeLoaded = true
+ return
+ }
+
+ // Step 2: Try each README filename sequentially
+ for filename in Self.readmeFilenames {
+ let pathResult: PathResponse
+ do {
+ pathResult = try await client.execute(
+ service: service,
+ query: Self.readmeFileQuery(filename: filename),
+ variables: ["rid": repository.rid],
+ responseType: PathResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ readmeContent = nil
+ readmePath = nil
+ readmeLoaded = true
+ return
+ }
+ throw error
+ }
+ if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
+ readmePath = filename
+ if filename.hasSuffix(".md") {
+ readmeContent = .markdown(text)
+ } else if filename.hasSuffix(".org") {
+ readmeContent = .org(text)
+ } else {
+ readmeContent = .plainText(text)
+ }
+ readmeLoaded = true
+ return
+ }
+ }
+
+ // Step 3: No readme found
+ readmeContent = nil
+ readmePath = nil
+ readmeLoaded = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func isMissingGitReferenceError(_ error: Error) -> Bool {
+ guard let srhtError = error as? SRHTError else { return false }
+ guard case .graphQLErrors(let errors) = srhtError else { return false }
+ return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ }
+
+ // MARK: - Artifacts
+
+ private static let artifactsQuery = """
+ query artifacts($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results {
+ name
+ artifacts {
+ results {
+ id
+ filename
+ checksum
+ size
+ url
+ }
+ cursor
+ }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadArtifacts() async {
+ guard !isLoadingArtifacts else { return }
+ isLoadingArtifacts = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.artifactsQuery,
+ variables: ["rid": repository.rid],
+ responseType: ArtifactsResponse.self
+ )
+ // Only include references that have at least one artifact.
+ referenceArtifacts = (result.repository?.references.results ?? [])
+ .filter { !$0.artifacts.results.isEmpty }
+ .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingArtifacts = false
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
new file mode 100644
index 0000000..6fcfafa
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct RepositoryListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: RepositoryListViewModel?
+ @State private var searchTask: Task<Void, Never>?
+ @State private var showCreateRepositorySheet = false
+ @State private var createdRepository: RepositorySummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ }
+ }
+ .navigationTitle("Repositories")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateRepositorySheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateRepositorySheet) {
+ if let viewModel {
+ CreateRepositorySheet(viewModel: viewModel) { repository in
+ showCreateRepositorySheet = false
+ createdRepository = repository
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdRepository != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdRepository = nil
+ }
+ }
+ )) {
+ if let createdRepository {
+ RepositoryDetailView(repository: createdRepository) {
+ viewModel?.removeRepository(id: createdRepository.id)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ viewModel = RepositoryListViewModel(client: appState.client)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: RepositoryListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.repositories) { repo in
+ NavigationLink(value: repo) {
+ RepositoryRowView(repository: repo)
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: repo)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
+ .overlay {
+ if viewModel.isLoading, viewModel.repositories.isEmpty {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ } else if let error = viewModel.error, viewModel.repositories.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Repositories",
+ message: error,
+ retryAction: { await viewModel.loadRepositories() }
+ )
+ } else if viewModel.repositories.isEmpty, viewModel.error == nil {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ ContentUnavailableView(
+ "No Repositories",
+ systemImage: "book.closed",
+ description: Text("You don't have any repositories yet.")
+ )
+ } else {
+ ContentUnavailableView.search
+ }
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
+ await viewModel.loadRepositories()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadRepositories()
+ }
+ .task {
+ await viewModel.loadRepositories()
+ }
+ .onChange(of: viewModel.searchText) { oldValue, newValue in
+ // Cancel previous search task
+ searchTask?.cancel()
+
+ // Clear results immediately when search text is cleared
+ if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ viewModel.resetSearch()
+ Task {
+ await viewModel.loadRepositories()
+ }
+ return
+ }
+
+ // Debounce search to avoid excessive API calls
+ searchTask = Task {
+ try? await Task.sleep(for: .milliseconds(350))
+ guard !Task.isCancelled else { return }
+ await viewModel.loadRepositories(search: newValue)
+ }
+ }
+ .navigationDestination(for: RepositorySummary.self) { repo in
+ RepositoryDetailView(repository: repo) {
+ viewModel.removeRepository(id: repo.id)
+ }
+ }
+ }
+}
+
+private struct CreateRepositorySheet: View {
+ let viewModel: RepositoryListViewModel
+ let onCreated: (RepositorySummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var description = ""
+ @State private var cloneURL = ""
+ @State private var visibility: Visibility = .public
+ @State private var service: RepositoryCreationService = .git
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Repository Details") {
+ Picker("Version Control", selection: $service) {
+ ForEach(RepositoryCreationService.allCases) { service in
+ Text(service.displayName).tag(service)
+ }
+ }
+ TextField("Repository name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Short description (optional)", text: $description, axis: .vertical)
+ .lineLimit(2...4)
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+
+ Section("Import Existing Repository") {
+ if service == .git {
+ TextField("Remote URL (optional)", text: $cloneURL)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ Text("Import an existing Git repository from a remote URL.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let repository = await viewModel.createRepository(
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ cloneURL: cloneURL
+ ) {
+ onCreated(repository)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingRepository {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Repository")
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
new file mode 100644
index 0000000..bba0108
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -0,0 +1,548 @@
+import Foundation
+
+enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
+ case git
+ case hg
+
+ var id: String { rawValue }
+
+ var service: SRHTService {
+ switch self {
+ case .git: .git
+ case .hg: .hg
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .git: "Git"
+ case .hg: "Mercurial"
+ }
+ }
+}
+
+/// View model for the repository list screen.
+@Observable
+@MainActor
+final class RepositoryListViewModel {
+
+ private(set) var repositories: [RepositorySummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ var error: String?
+
+ var searchText = ""
+
+ private(set) var cursor: String?
+ private(set) var hasMore = false
+ private(set) var isSearching = false
+ private(set) var isCreatingRepository = false
+ private let client: SRHTClient
+
+ private static let gitCacheKey = "git.repositories"
+ private static let hgCacheKey = "hg.repositories"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let gitQuery = """
+ query repositories($cursor: Cursor, $filter: Filter) {
+ repositories(cursor: $cursor, filter: $filter) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ HEAD { name }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let hgQuery = """
+ query repositories($cursor: Cursor) {
+ repositories(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let createHgRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of repositories. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
+ func loadRepositories(search: String? = nil) async {
+ let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ let isSearch = !query.isEmpty
+
+ // Only use cache for non-search, initial loads
+ if !isSearch, repositories.isEmpty {
+ loadFromCache()
+ }
+
+ // During search, never show the full-screen loading overlay (which
+ // would remove the List and dismiss the keyboard). Use "refreshing"
+ // instead so the list stays in the hierarchy.
+ if isSearch {
+ isRefreshing = true
+ isSearching = true
+ } else if repositories.isEmpty {
+ isLoading = true
+ isSearching = false
+ } else {
+ isRefreshing = true
+ isSearching = false
+ }
+ error = nil
+ cursor = nil
+ hasMore = false
+
+ do {
+ var filteredResults: [RepositorySummary]
+
+ if isSearch {
+ // For search queries, fetch all repositories from both services.
+ filteredResults = try await fetchAllRepositories()
+
+ // Perform client-side filtering
+ let lowercasedQuery = query.lowercased()
+ filteredResults = filteredResults.filter { repo in
+ repo.name.lowercased().contains(lowercasedQuery) ||
+ repo.description?.lowercased().contains(lowercasedQuery) ?? false
+ }
+ } else {
+ let repositories = try await fetchAllRepositories(useCache: true)
+ filteredResults = repositories
+ }
+
+ repositories = filteredResults.sorted(by: repositorySortOrder)
+ } catch {
+ // Only show error if we have no cached data to fall back on
+ if repositories.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ /// Load the next page if available. Called when the user scrolls near the end.
+ /// Note: Pagination is disabled during search (client-side filtering).
+ func loadMoreIfNeeded(currentItem: RepositorySummary) async {
+ _ = currentItem
+ }
+
+ /// Remove a repository from the local list (e.g. after deletion).
+ func removeRepository(id: Int) {
+ repositories.removeAll { $0.id == id }
+ }
+
+ func createRepository(
+ service: RepositoryCreationService,
+ name: String,
+ description: String,
+ visibility: Visibility,
+ cloneURL: String
+ ) async -> RepositorySummary? {
+ guard !isCreatingRepository else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a repository name."
+ return nil
+ }
+
+ isCreatingRepository = true
+ error = nil
+ defer { isCreatingRepository = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+ let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedCloneURL.isEmpty {
+ variables["cloneUrl"] = trimmedCloneURL
+ }
+
+ do {
+ let repository: RepositorySummary
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: .git,
+ query: Self.createRepositoryMutation,
+ variables: variables,
+ responseType: CreateRepositoryResponse.self
+ )
+ repository = result.createRepository
+ case .hg:
+ variables.removeValue(forKey: "cloneUrl")
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.createHgRepositoryMutation,
+ variables: variables,
+ responseType: CreateHGRepositoryResponse.self
+ )
+ repository = result.createRepository.repositorySummary(service: .hg)
+ }
+ repositories.insert(repository, at: 0)
+ return repository
+ } catch {
+ self.error = repositoryCreationErrorMessage(for: error)
+ return nil
+ }
+ }
+
+ private func repositoryCreationErrorMessage(for error: Error) -> String {
+ let message: String
+
+ if let srhtError = error as? SRHTError {
+ switch srhtError {
+ case .graphQLErrors(let errors):
+ message = errors.map(\.message).joined(separator: "\n")
+ default:
+ message = srhtError.localizedDescription
+ }
+ } else {
+ message = error.localizedDescription
+ }
+
+ return "Couldn’t create the repository. \(message)"
+ }
+
+ /// Fetch ALL repositories by paginating through all available pages.
+ /// Used for search functionality to ensure we search through the complete dataset.
+ private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
+ async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
+ async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
+ return try await gitRepositories + hgRepositories
+ }
+
+ /// Reset search state and reload all repositories
+ func resetSearch() {
+ repositories = []
+ cursor = nil
+ hasMore = false
+ isSearching = false
+ }
+
+ // MARK: - Private
+
+ /// Page shape matching the GraphQL response without generic constraints that
+ /// conflict with strict concurrency when used from a @MainActor context.
+ private struct Page: Decodable, Sendable {
+ let results: [RepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct RepositoriesResponse: Decodable, Sendable {
+ let repositories: Page?
+ }
+
+ private struct CreateRepositoryResponse: Decodable, Sendable {
+ let createRepository: RepositorySummary
+ }
+
+ private struct CreateHGRepositoryResponse: Decodable, Sendable {
+ let createRepository: HGRepositoryPayload
+ }
+
+ private struct HGPage: Decodable, Sendable {
+ let results: [HGRepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct HGRepositoriesResponse: Decodable, Sendable {
+ let repositories: HGPage?
+ }
+
+ private static let emptyPage = Page(results: [], cursor: nil)
+
+ private struct RepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: head
+ )
+ }
+ }
+
+ private struct HGRepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let tip: HGTipReference?
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ }
+ }
+
+ private struct HGTipReference: Decodable, Sendable {
+ let branch: String
+ }
+
+ private func fetchPage(
+ service: SRHTService,
+ cursor: String?,
+ search: String? = nil,
+ useCache: Bool
+ ) async throws -> Page {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ variables["filter"] = ["search": trimmed] as [String: any Sendable]
+ }
+
+ if useCache && cursor == nil {
+ switch service {
+ case .git:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ } else {
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.execute(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ }
+ }
+
+ private func loadFromCache() {
+ let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
+ guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ switch service {
+ case .git:
+ if let response = try? decoder.decode(
+ GraphQLResponse<RepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ case .hg:
+ if let response = try? decoder.decode(
+ GraphQLResponse<HGRepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ default:
+ break
+ }
+ return []
+ }
+ if !cachedRepositories.isEmpty {
+ repositories = cachedRepositories.sorted(by: repositorySortOrder)
+ }
+ }
+
+ private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
+ var allRepositories: [RepositorySummary] = []
+ var currentCursor: String? = nil
+
+ while true {
+ let page = try await fetchPage(
+ service: service,
+ cursor: currentCursor,
+ search: nil,
+ useCache: useCache && currentCursor == nil
+ )
+ allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
+ guard let nextCursor = page.cursor else { break }
+ currentCursor = nextCursor
+ }
+
+ return allRepositories
+ }
+
+ private func cacheKey(for service: SRHTService) -> String {
+ switch service {
+ case .git:
+ Self.gitCacheKey
+ case .hg:
+ Self.hgCacheKey
+ default:
+ "\(service.rawValue).repositories"
+ }
+ }
+
+ private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
+ if lhs.updated == rhs.updated {
+ if lhs.service == rhs.service {
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ return lhs.service.rawValue < rhs.service.rawValue
+ }
+ return lhs.updated > rhs.updated
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
new file mode 100644
index 0000000..9f8cd15
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+struct RepositoryRowView: View {
+ let repository: RepositorySummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(repository.name)
+ .font(.headline)
+
+ Spacer()
+
+ if repository.service == .hg {
+ Text("HG")
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.cyan.opacity(0.15), in: Capsule())
+ .foregroundStyle(.cyan)
+ }
+
+ VisibilityBadge(visibility: repository.visibility)
+ }
+
+ Text(repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ if let description = repository.description,
+ !description.isEmpty {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack(spacing: 12) {
+ if let head = repository.head {
+ Label(head.name, systemImage: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(repository.updated.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - VisibilityBadge
+
+struct VisibilityBadge: View {
+ let visibility: Visibility
+
+ var body: some View {
+ Text(label)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.15), in: Capsule())
+ .foregroundStyle(color)
+ }
+
+ private var label: String {
+ switch visibility {
+ case .public: "PUBLIC"
+ case .unlisted: "UNLISTED"
+ case .private: "PRIVATE"
+ }
+ }
+
+ private var color: Color {
+ switch visibility {
+ case .public: .green
+ case .unlisted: .orange
+ case .private: .red
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
index 3db434c..5074138 100644
--- a/Hutch/Views/Repositories/RepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -10,6 +10,7 @@ struct RepositorySettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: RepositorySettingsViewModel?
@State private var showDeleteConfirmation = false
+ @State private var pendingACLDeletion: ACLEntry?
var body: some View {
NavigationStack {
@@ -17,7 +18,7 @@ struct RepositorySettingsView: View {
if let viewModel {
settingsForm(viewModel)
} else {
- ProgressView()
+ SRHTLoadingStateView(message: "Loading settings…")
}
}
.navigationTitle("Settings")
@@ -51,13 +52,7 @@ struct RepositorySettingsView: View {
accessSection(viewModel)
deleteSection(viewModel)
}
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
- } message: {
- if let error = viewModel.error {
- Text(error)
- }
- }
+ .srhtErrorBanner(error: $vm.error)
.alert(
"Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
isPresented: $showDeleteConfirmation
@@ -75,6 +70,27 @@ struct RepositorySettingsView: View {
} message: {
Text("This cannot be undone.")
}
+ .alert("Remove Access?", isPresented: Binding(
+ get: { pendingACLDeletion != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingACLDeletion = nil
+ }
+ }
+ )) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Access", role: .destructive) {
+ guard let entry = pendingACLDeletion else { return }
+ Task {
+ await viewModel.deleteACL(entry)
+ pendingACLDeletion = nil
+ }
+ }
+ } message: {
+ if let entry = pendingACLDeletion {
+ Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
+ }
+ }
}
// MARK: - Info Section
@@ -82,6 +98,11 @@ struct RepositorySettingsView: View {
@ViewBuilder
private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section("Info") {
+ LabeledContent("Name") {
+ Text(repository.name)
+ .font(.body.monospaced())
+ }
+
TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
.lineLimit(3...6)
@@ -107,7 +128,7 @@ struct RepositorySettingsView: View {
ProgressView()
.frame(maxWidth: .infinity)
} else {
- Text("Save")
+ Text("Save Changes")
.frame(maxWidth: .infinity)
}
}
@@ -120,7 +141,7 @@ struct RepositorySettingsView: View {
@ViewBuilder
private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section {
- TextField("Repository Name", text: Bindable(viewModel).editedName)
+ TextField("New repository name", text: Bindable(viewModel).editedName)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -141,7 +162,7 @@ struct RepositorySettingsView: View {
ProgressView()
.frame(maxWidth: .infinity)
} else {
- Text("Rename")
+ Text("Rename Repository")
.frame(maxWidth: .infinity)
}
}
@@ -163,7 +184,7 @@ struct RepositorySettingsView: View {
Spacer()
}
} else if viewModel.acls.isEmpty {
- Text("No access control entries.")
+ Text("No access entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.acls) { entry in
@@ -174,11 +195,11 @@ struct RepositorySettingsView: View {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
- .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
- Task { await viewModel.deleteACL(entry) }
+ pendingACLDeletion = entry
} label: {
- Label("Delete", systemImage: "trash")
+ Label("Remove Access", systemImage: "trash")
}
}
}
@@ -186,7 +207,7 @@ struct RepositorySettingsView: View {
// Add ACL form
HStack {
- TextField("Username", text: Bindable(viewModel).newACLEntity)
+ TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -203,11 +224,14 @@ struct RepositorySettingsView: View {
if viewModel.isAddingACL {
ProgressView()
} else {
- Image(systemName: "plus.circle.fill")
+ Text("Add")
}
}
.disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
+ Text("Add a SourceHut user and choose read-only or read/write access.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
} header: {
Text("Access")
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index bdae87f..d6f3300 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -63,6 +63,7 @@ final class RepositorySettingsViewModel {
let repositoryId: Int
let repositoryRid: String
+ let service: SRHTService
private let client: SRHTClient
// MARK: - Info fields
@@ -107,6 +108,7 @@ final class RepositorySettingsViewModel {
) {
self.repositoryId = repository.id
self.repositoryRid = repository.rid
+ self.service = repository.service
self.client = client
self.editedDescription = repository.description ?? ""
self.editedVisibility = repository.visibility
@@ -143,7 +145,7 @@ final class RepositorySettingsViewModel {
"HEAD": editedHead
]
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateRepoMutation,
variables: ["id": repositoryId, "input": input],
responseType: UpdateRepoResponse.self
@@ -165,7 +167,7 @@ final class RepositorySettingsViewModel {
"name": editedName
]
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateRepoMutation,
variables: ["id": repositoryId, "input": input],
responseType: UpdateRepoResponse.self
@@ -207,6 +209,16 @@ final class RepositorySettingsViewModel {
}
"""
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user(username: $username) {
+ id
+ username
+ canonicalName
+ }
+ }
+ """
+
func loadACLs() async {
guard !isLoadingACLs else { return }
isLoadingACLs = true
@@ -214,7 +226,7 @@ final class RepositorySettingsViewModel {
do {
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.aclsQuery,
variables: ["rid": repositoryRid],
responseType: ACLResponse.self
@@ -234,7 +246,7 @@ final class RepositorySettingsViewModel {
do {
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateACLMutation,
variables: [
"repoId": repositoryId,
@@ -262,7 +274,7 @@ final class RepositorySettingsViewModel {
do {
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.deleteACLMutation,
variables: ["id": entry.id],
responseType: DeleteACLResponse.self
@@ -288,7 +300,7 @@ final class RepositorySettingsViewModel {
do {
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.deleteRepoMutation,
variables: ["id": repositoryId],
responseType: DeleteRepoResponse.self
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index 8ad3659..a2cf699 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -39,31 +39,30 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
}
}
-struct RepositorySummaryCard<Content: View>: View {
+struct SummaryMetadataRow: View {
+ let icon: String
let title: String
- @ViewBuilder let content: Content
-
- init(_ title: String, @ViewBuilder content: () -> Content) {
- self.title = title
- self.content = content()
- }
+ var subtitle: String? = nil
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text(title)
- .font(.caption.weight(.semibold))
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: icon)
.foregroundStyle(.secondary)
- .textCase(.uppercase)
+ .frame(width: 18)
- content
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
-struct RepositorySummaryField: View {
+struct SummaryDetailRow: View {
let label: String
let value: String
var monospace: Bool = false
@@ -79,23 +78,3 @@ struct RepositorySummaryField: View {
}
}
}
-
-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: ", "))
- }
- }
- }
-}