summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Auth/AuthView.swift95
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift383
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift235
-rw-r--r--Hutch/Views/Builds/BuildListView.swift217
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift220
-rw-r--r--Hutch/Views/Builds/BuildRowView.swift106
-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
-rw-r--r--Hutch/Views/Settings/SettingsView.swift724
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift394
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift828
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift551
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift389
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift215
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift214
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift166
36 files changed, 10466 insertions, 202 deletions
diff --git a/Hutch/Views/Auth/AuthView.swift b/Hutch/Views/Auth/AuthView.swift
new file mode 100644
index 0000000..cbf8669
--- /dev/null
+++ b/Hutch/Views/Auth/AuthView.swift
@@ -0,0 +1,95 @@
+import SwiftUI
+
+/// Token entry screen shown when the user is not authenticated.
+struct TokenEntryView: View {
+ private let createAccountURL = URL(string: "https://meta.sr.ht/register")!
+ private let personalAccessTokensURL = URL(string: "https://meta.sr.ht/oauth/personal-access-tokens")!
+
+ @Environment(AppState.self) private var appState
+ @State private var token = ""
+ @State private var isConnecting = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ Text("Enter your SourceHut personal access token to connect.")
+ } header: {
+ Text("Welcome to Hutch")
+ } footer: {
+ Text("Hutch stores your SourceHut personal access token securely in the iOS keychain.")
+ }
+
+ Section {
+ SecureField("Personal Access Token", text: $token)
+ .textContentType(.password)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .disabled(isConnecting)
+ } header: {
+ Text("Token")
+ }
+
+ if let errorMessage {
+ Section {
+ Label {
+ Text(errorMessage)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+
+ Section {
+ Button {
+ connect()
+ } label: {
+ HStack {
+ Text("Connect")
+ if isConnecting {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(tokenTrimmed.isEmpty || isConnecting)
+ }
+
+ Section {
+ Link(destination: createAccountURL) {
+ Label("Create SourceHut account", systemImage: "person.badge.plus")
+ }
+
+ Link(destination: personalAccessTokensURL) {
+ Label("Create Personal Access Token", systemImage: "key")
+ }
+ } header: {
+ Text("Need an account?")
+ } footer: {
+ Text("These links open SourceHut in your browser. After signing up, create a Personal Access Token there and paste it here.")
+ }
+ }
+ .navigationTitle("Hutch")
+ }
+ }
+
+ private var tokenTrimmed: String {
+ token.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func connect() {
+ errorMessage = nil
+ isConnecting = true
+ Task {
+ do {
+ try await appState.connect(with: tokenTrimmed)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isConnecting = false
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
new file mode 100644
index 0000000..977cd87
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -0,0 +1,383 @@
+import SwiftUI
+
+struct BuildDetailView: View {
+ let jobId: Int
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildDetailViewModel?
+ @State private var rebuiltJobId: Int?
+ @State private var showEditResubmitSheet = false
+ @State private var showCancelConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading build…")
+ }
+ }
+ .navigationTitle("Job #\(jobId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
+ target: .build
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { rebuiltJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ rebuiltJobId = nil
+ }
+ }
+ )) {
+ if let rebuiltJobId {
+ BuildDetailView(jobId: rebuiltJobId)
+ }
+ }
+ .sheet(isPresented: $showEditResubmitSheet) {
+ if let viewModel, let job = viewModel.job {
+ EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
+ showEditResubmitSheet = false
+ rebuiltJobId = jobId
+ }
+ }
+ }
+ .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
+ Button("Keep Running", role: .cancel) {}
+ Button("Cancel Build", role: .destructive) {
+ Task { await viewModel?.cancelJob() }
+ }
+ } message: {
+ Text("The build will stop as soon as possible.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
+ viewModel = vm
+ await vm.loadJob()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
+ if viewModel.isLoading, viewModel.job == nil {
+ SRHTLoadingStateView(message: "Loading build…")
+ } else if let error = viewModel.error, viewModel.job == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Build",
+ message: error,
+ retryAction: { await viewModel.loadJob() }
+ )
+ } else if let job = viewModel.job {
+ List {
+ // Status & metadata
+ Section("Details") {
+ HStack {
+ Text("Status")
+ Spacer()
+ HStack(spacing: 6) {
+ JobStatusIcon(status: job.status)
+ Text(job.status.rawValue)
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ if let note = job.note, !note.isEmpty {
+ LabeledContent("Note", value: note)
+ }
+
+ if let image = job.image {
+ LabeledContent("Image", value: image)
+ }
+
+ if !job.tags.isEmpty {
+ LabeledContent("Tags", value: job.tags.joined(separator: ", "))
+ }
+
+ if let visibility = job.visibility {
+ LabeledContent("Visibility", value: visibility.rawValue.capitalized)
+ }
+
+ LabeledContent("Owner", value: job.owner.canonicalName)
+ LabeledContent("Created", value: job.created.relativeDescription)
+ LabeledContent("Updated", value: job.updated.relativeDescription)
+ }
+
+ // Per-task logs
+ if !job.tasks.isEmpty {
+ ForEach(job.tasks) { task in
+ Section {
+ TaskLogSection(task: task, viewModel: viewModel)
+ } header: {
+ HStack(spacing: 6) {
+ TaskStatusIcon(status: task.status)
+ Text(task.name)
+ }
+ }
+ }
+ }
+
+ // Cancel button
+ if job.status.isCancellable {
+ Section {
+ Button(role: .destructive) {
+ showCancelConfirmation = true
+ } label: {
+ HStack {
+ Text("Cancel Build")
+ if viewModel.isCancelling {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isCancelling)
+ }
+ }
+
+ if let manifest = job.manifest,
+ !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Section {
+ Button {
+ Task {
+ rebuiltJobId = await viewModel.rebuildJob()
+ }
+ } label: {
+ HStack {
+ Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
+ if viewModel.isRebuilding {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isRebuilding)
+
+ Button {
+ showEditResubmitSheet = true
+ } label: {
+ Text("Edit & Resubmit")
+ }
+ .disabled(viewModel.isSubmittingEditedBuild)
+ } footer: {
+ Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
+ }
+ }
+ }
+ .refreshable {
+ await viewModel.loadJob()
+ }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ }
+ }
+}
+
+private struct EditResubmitBuildSheet: View {
+ let viewModel: BuildDetailViewModel
+ let job: JobDetail
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildDetailViewModel
+ @State private var manifest: String
+ @State private var tagsText: String
+ @State private var note: String
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility
+
+ init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.job = job
+ self.onSubmitted = onSubmitted
+ _manifest = State(initialValue: job.manifest ?? "")
+ _tagsText = State(initialValue: job.tags.joined(separator: ", "))
+ _note = State(initialValue: job.note ?? "")
+ _visibility = State(initialValue: job.visibility ?? .public)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Edit & Resubmit")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmittingEditedBuild {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Task Log Section
+
+private struct TaskLogSection: View {
+ let task: BuildTask
+ let viewModel: BuildDetailViewModel
+
+ @State private var isExpanded: Bool
+
+ init(task: BuildTask, viewModel: BuildDetailViewModel) {
+ self.task = task
+ self.viewModel = viewModel
+ self._isExpanded = State(initialValue: task.status == .failed)
+ }
+
+ var body: some View {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ if viewModel.loadingTaskLogs.contains(task.name) {
+ HStack {
+ Spacer()
+ ProgressView("Loading log…")
+ Spacer()
+ }
+ } else if let logText = viewModel.taskLogs[task.name] {
+ ScrollView(.horizontal, showsIndicators: false) {
+ Text(logText)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } else if task.log == nil {
+ Text("No log available.")
+ .foregroundStyle(.secondary)
+ }
+ } label: {
+ HStack {
+ Text(task.status.rawValue)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .task {
+ if isExpanded {
+ await viewModel.loadTaskLog(task: task)
+ }
+ }
+ .onChange(of: isExpanded) { _, expanded in
+ if expanded {
+ Task { await viewModel.loadTaskLog(task: task) }
+ }
+ }
+ }
+}
+
+// MARK: - Task Status Icon
+
+private struct TaskStatusIcon: View {
+ let status: TaskStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .frame(width: 20)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "circle.dashed"
+ case .skipped: "forward.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed: .red
+ case .running: .yellow
+ case .pending: .gray
+ case .skipped: .secondary
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
new file mode 100644
index 0000000..c14e47b
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -0,0 +1,235 @@
+import Foundation
+
+// MARK: - Response types
+
+private struct JobDetailResponse: Decodable, Sendable {
+ let job: JobDetail
+}
+
+private struct CancelResponse: Decodable, Sendable {
+ let cancel: CancelResult
+}
+
+private struct CancelResult: Decodable, Sendable {
+ let id: Int
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildDetailViewModel {
+
+ let jobId: Int
+ private let client: SRHTClient
+
+ private(set) var job: JobDetail?
+ private(set) var isLoading = false
+ private(set) var taskLogs: [String: String] = [:]
+ private(set) var loadingTaskLogs: Set<String> = []
+ private(set) var isCancelling = false
+ private(set) var isRebuilding = false
+ private(set) var isSubmittingEditedBuild = false
+ var error: String?
+
+ init(jobId: Int, client: SRHTClient) {
+ self.jobId = jobId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query job($id: Int!) {
+ job(id: $id) {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ manifest
+ tasks { name status log { fullURL } }
+ log { fullURL }
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let cancelMutation = """
+ mutation cancel($id: Int!) {
+ cancel(jobId: $id) {
+ id
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ private static let editableSubmitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadJob() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.detailQuery,
+ variables: ["id": jobId],
+ responseType: JobDetailResponse.self
+ )
+ job = result.job
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadTaskLog(task: BuildTask) async {
+ guard let log = task.log,
+ let logURL = URL(string: log.fullURL),
+ !loadingTaskLogs.contains(task.name),
+ taskLogs[task.name] == nil else { return }
+ loadingTaskLogs.insert(task.name)
+
+ do {
+ taskLogs[task.name] = try await client.fetchText(url: logURL)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ loadingTaskLogs.remove(task.name)
+ }
+
+ func cancelJob() async {
+ guard let job, job.status.isCancellable, !isCancelling else { return }
+ isCancelling = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .builds,
+ query: Self.cancelMutation,
+ variables: ["id": jobId],
+ responseType: CancelResponse.self
+ )
+ // Reload job to get updated status.
+ await loadJob()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isCancelling = false
+ }
+
+ func rebuildJob() async -> Int? {
+ guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
+ return nil
+ }
+
+ isRebuilding = true
+ error = nil
+ defer { isRebuilding = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ ]
+ if !job.tags.isEmpty {
+ variables["tags"] = job.tags
+ }
+ if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let visibility = job.visibility {
+ variables["visibility"] = visibility.rawValue
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmittingEditedBuild else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmittingEditedBuild = true
+ error = nil
+ defer { isSubmittingEditedBuild = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.editableSubmitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
new file mode 100644
index 0000000..875a836
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+struct BuildListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildListViewModel?
+ @State private var showSubmitSheet = false
+ @State private var submittedJobId: Int?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading builds…")
+ }
+ }
+ .navigationTitle("Builds")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showSubmitSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showSubmitSheet) {
+ if let viewModel {
+ SubmitBuildSheet(viewModel: viewModel) { jobId in
+ showSubmitSheet = false
+ submittedJobId = jobId
+ }
+ }
+ }
+ .navigationDestination(for: JobSummary.self) { job in
+ BuildDetailView(jobId: job.id)
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { submittedJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ submittedJobId = nil
+ }
+ }
+ )) {
+ if let submittedJobId {
+ BuildDetailView(jobId: submittedJobId)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadJobs()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: BuildListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.jobs) { job in
+ NavigationLink(value: job) {
+ BuildRowView(job: job)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: job)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.jobs.isEmpty {
+ SRHTLoadingStateView(message: "Loading builds…")
+ } else if let error = viewModel.error, viewModel.jobs.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Builds",
+ message: error,
+ retryAction: { await viewModel.loadJobs() }
+ )
+ } else if viewModel.jobs.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Builds",
+ systemImage: "hammer",
+ description: Text("Your build jobs will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
+ await viewModel.loadJobs()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadJobs()
+ }
+ }
+}
+
+private struct SubmitBuildSheet: View {
+ let viewModel: BuildListViewModel
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildListViewModel
+ @State private var manifest = ""
+ @State private var tagsText = ""
+ @State private var note = ""
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility = .public
+
+ init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.onSubmitted = onSubmitted
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Paste a build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Submit Build")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
new file mode 100644
index 0000000..8d0f961
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -0,0 +1,220 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct JobsResponse: Decodable, Sendable {
+ let jobs: JobsPage
+}
+
+private struct JobsPage: Decodable, Sendable {
+ let results: [JobSummary]
+ let cursor: String?
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildListViewModel {
+
+ private(set) var jobs: [JobSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isSubmitting = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ private static let cacheKey = "builds.jobs"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query jobs($cursor: Cursor) {
+ jobs(cursor: $cursor) {
+ results {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ tasks { name status }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of jobs. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ func loadJobs() async {
+ // Show cached data immediately on first load
+ if jobs.isEmpty {
+ loadFromCache()
+ }
+
+ if jobs.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil, useCache: true)
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if jobs.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: JobSummary) async {
+ guard let last = jobs.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor, useCache: false)
+ jobs.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmitting else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmitting = true
+ error = nil
+ defer { isSubmitting = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ await loadJobs()
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ if useCache && cursor == nil {
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self,
+ cacheKey: Self.cacheKey
+ )
+ return result.jobs
+ } else {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self
+ )
+ return result.jobs
+ }
+ }
+
+ private func loadFromCache() {
+ guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ if let response = try? decoder.decode(
+ GraphQLResponse<JobsResponse>.self,
+ from: data
+ ), let page = response.data?.jobs {
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift
new file mode 100644
index 0000000..e2afd27
--- /dev/null
+++ b/Hutch/Views/Builds/BuildRowView.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+
+struct BuildRowView: View {
+ let job: JobSummary
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: job.status)
+ .frame(width: 28)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(job.displayLabel)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ if let image = job.image {
+ Text(image)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !job.tasks.isEmpty {
+ TaskProgressView(tasks: job.tasks)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Job Status Icon
+
+struct JobStatusIcon: View {
+ let status: JobStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .symbolEffect(.pulse, isActive: status == .running)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed, .timeout: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .queued: "clock.fill"
+ case .pending: "circle.dashed"
+ case .cancelled: "minus.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed, .timeout: .red
+ case .running: .yellow
+ case .queued: .orange
+ case .pending, .cancelled: .gray
+ }
+ }
+}
+
+// MARK: - Task Progress
+
+struct TaskProgressView: View {
+ let tasks: [JobTaskSummary]
+
+ var body: some View {
+ HStack(spacing: 6) {
+ ProgressView(value: progress, total: 1.0)
+ .tint(progressColor)
+ .frame(maxWidth: 80)
+
+ Text("\(completedCount)/\(tasks.count) tasks")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var completedCount: Int {
+ tasks.filter { $0.status == .success }.count
+ }
+
+ private var progress: Double {
+ tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count)
+ }
+
+ private var progressColor: Color {
+ if tasks.contains(where: { $0.status == .failed }) {
+ return .red
+ }
+ if completedCount == tasks.count {
+ return .green
+ }
+ return .blue
+ }
+}
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: ", "))
- }
- }
- }
-}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..5bbded8
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -0,0 +1,724 @@
+import PhotosUI
+import SwiftUI
+
+struct SettingsView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: SettingsViewModel?
+ @State private var pendingDestructiveAction: SettingsDestructiveAction?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ settingsContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading profile…")
+ }
+ }
+ .navigationTitle("Settings")
+ .task {
+ if viewModel == nil {
+ let vm = SettingsViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadProfile()
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ if let profile = viewModel.profile {
+ // Profile section
+ profileSection(profile, viewModel: viewModel)
+
+ // SSH Keys
+ sshKeysSection(viewModel)
+
+ // PGP Keys
+ pgpKeysSection(viewModel)
+
+ // Personal Access Tokens
+ patSection(viewModel)
+ }
+
+ // Token / Sign Out
+ tokenSection()
+
+ aboutSection()
+ }
+ .overlay {
+ if viewModel.isLoading, viewModel.profile == nil {
+ SRHTLoadingStateView(message: "Loading profile…")
+ } else if let error = viewModel.error, viewModel.profile == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Profile",
+ message: error,
+ retryAction: { await viewModel.loadProfile() }
+ )
+ }
+ }
+ .sheet(isPresented: $vm.isEditingProfile) {
+ if let profile = viewModel.profile {
+ EditProfileSheet(
+ profile: profile,
+ viewModel: viewModel
+ )
+ }
+ }
+ .alert("Error", isPresented: Binding(
+ get: { viewModel.error != nil && viewModel.profile != nil },
+ set: { isPresented in
+ if !isPresented {
+ viewModel.error = nil
+ }
+ }
+ )) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ if let error = viewModel.error {
+ Text(error)
+ }
+ }
+ .alert(
+ pendingDestructiveAction?.title ?? "",
+ isPresented: Binding(
+ get: { pendingDestructiveAction != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingDestructiveAction = nil
+ }
+ }
+ )
+ ) {
+ Button("Cancel", role: .cancel) {}
+ Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
+ guard let action = pendingDestructiveAction else { return }
+ pendingDestructiveAction = nil
+ Task {
+ switch action {
+ case .resetAppData:
+ await appState.resetAppData()
+ case .signOut:
+ await appState.signOut()
+ case .deleteSSHKey(let key):
+ await viewModel.deleteSSHKey(key)
+ case .deletePGPKey(let key):
+ await viewModel.deletePGPKey(key)
+ }
+ }
+ }
+ } message: {
+ if let pendingDestructiveAction {
+ Text(pendingDestructiveAction.message)
+ }
+ }
+ .refreshable {
+ await viewModel.loadProfile()
+ }
+ }
+
+ // MARK: - Profile Section
+
+ @ViewBuilder
+ private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
+ Section("Profile") {
+ HStack(spacing: 12) {
+ AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .scaledToFill()
+ default:
+ Image(systemName: "person.crop.circle.fill")
+ .resizable()
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 56, height: 56)
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(profile.canonicalName)
+ .font(.headline)
+ Text(profile.email)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ if let userType = profile.userType {
+ Text(userType.capitalized)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+
+ if let bio = profile.bio, !bio.isEmpty {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Bio")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ RenderedMarkupContentView(
+ content: .markdown(bio),
+ readmePath: nil,
+ colorScheme: colorScheme,
+ ownerCanonicalName: "",
+ repositoryName: ""
+ )
+ }
+ }
+
+ if let location = profile.location, !location.isEmpty {
+ LabeledContent("Location", value: location)
+ }
+
+ if let url = profile.url, !url.isEmpty {
+ LabeledContent("URL", value: url)
+ }
+
+ if let status = profile.paymentStatus {
+ LabeledContent("Payment", value: status.capitalized)
+ }
+
+ if let sub = profile.subscription {
+ if let status = sub.status {
+ LabeledContent("Subscription", value: status.capitalized)
+ }
+ if let interval = sub.interval {
+ LabeledContent("Interval", value: interval.capitalized)
+ }
+ }
+
+ Button("Edit Profile") {
+ viewModel.isEditingProfile = true
+ }
+
+ SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
+ SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
+ }
+ }
+ }
+
+ // MARK: - SSH Keys Section
+
+ @ViewBuilder
+ private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Section {
+ ForEach(viewModel.sshKeys) { key in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(key.fingerprint)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ HStack {
+ if let comment = key.comment, !comment.isEmpty {
+ Text(comment)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(key.created.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ if let lastUsed = key.lastUsed {
+ Text("Last used \(lastUsed.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ pendingDestructiveAction = .deleteSSHKey(key)
+ }
+ }
+ }
+
+ if viewModel.isAddingSSHKey {
+ TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...6)
+
+ HStack {
+ Button("Cancel") {
+ viewModel.isAddingSSHKey = false
+ viewModel.newSSHKey = ""
+ }
+ Spacer()
+ Button("Add") {
+ Task { await viewModel.addSSHKey() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ } else {
+ Button {
+ viewModel.isAddingSSHKey = true
+ } label: {
+ SwiftUI.Label("Add SSH Key", systemImage: "key")
+ }
+ }
+ } header: {
+ Text("SSH Keys")
+ } footer: {
+ Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
+ }
+ }
+
+ // MARK: - PGP Keys Section
+
+ @ViewBuilder
+ private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Section {
+ ForEach(viewModel.pgpKeys) { key in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(key.fingerprint)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ Text(key.created.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ pendingDestructiveAction = .deletePGPKey(key)
+ }
+ }
+ }
+
+ if viewModel.isAddingPGPKey {
+ TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...6)
+
+ HStack {
+ Button("Cancel") {
+ viewModel.isAddingPGPKey = false
+ viewModel.newPGPKey = ""
+ }
+ Spacer()
+ Button("Add") {
+ Task { await viewModel.addPGPKey() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ } else {
+ Button {
+ viewModel.isAddingPGPKey = true
+ } label: {
+ SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
+ }
+ }
+ } header: {
+ Text("PGP Keys")
+ } footer: {
+ Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
+ }
+ }
+
+ // MARK: - Personal Access Tokens Section
+
+ @ViewBuilder
+ private func patSection(_ viewModel: SettingsViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingPATs {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if viewModel.personalAccessTokens.isEmpty {
+ Button("Load Tokens") {
+ Task { await viewModel.loadPersonalAccessTokens() }
+ }
+ } else {
+ ForEach(viewModel.personalAccessTokens) { token in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(token.comment ?? "Token #\(token.id)")
+ .font(.subheadline)
+ Spacer()
+ }
+
+ HStack(spacing: 12) {
+ Text("Issued \(token.issued.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ if let expires = token.expires {
+ Text("Expires \(expires.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(expires < Date.now ? .red : .secondary)
+ }
+ }
+
+ if let grants = token.grants, !grants.isEmpty {
+ Text(grants)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ }
+ }
+ }
+ } header: {
+ Text("Personal Access Tokens")
+ } footer: {
+ if !viewModel.personalAccessTokens.isEmpty {
+ Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
+ }
+ }
+ }
+
+ // MARK: - Token / Sign Out Section
+
+ @ViewBuilder
+ private func tokenSection() -> some View {
+ Section {
+ HStack {
+ Image(systemName: "key.fill")
+ .foregroundStyle(.secondary)
+ Text("Personal access token in use")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+
+ Button("Reset App Data", role: .destructive) {
+ pendingDestructiveAction = .resetAppData
+ }
+
+ Button("Sign Out", role: .destructive) {
+ pendingDestructiveAction = .signOut
+ }
+ } header: {
+ Text("Authentication")
+ } footer: {
+ Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.")
+ }
+ }
+
+ @ViewBuilder
+ private func aboutSection() -> some View {
+ Section("App") {
+ NavigationLink {
+ AboutView()
+ } label: {
+ SwiftUI.Label("About Hutch", systemImage: "info.circle")
+ }
+ }
+ }
+}
+
+// MARK: - Edit Profile Sheet
+
+private struct EditProfileSheet: View {
+ let profile: UserProfile
+ let viewModel: SettingsViewModel
+
+ @State private var email: String
+ @State private var url: String
+ @State private var location: String
+ @State private var bio: String
+ @State private var selectedPhoto: PhotosPickerItem?
+ @State private var avatarPreview: UIImage?
+ @State private var isShowingRemoveAvatarConfirmation = false
+
+ @Environment(\.dismiss) private var dismiss
+
+ init(profile: UserProfile, viewModel: SettingsViewModel) {
+ self.profile = profile
+ self.viewModel = viewModel
+ _email = State(initialValue: profile.email)
+ _url = State(initialValue: profile.url ?? "")
+ _location = State(initialValue: profile.location ?? "")
+ _bio = State(initialValue: profile.bio ?? "")
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ HStack {
+ Spacer()
+ VStack(spacing: 8) {
+ PhotosPicker(selection: $selectedPhoto, matching: .images) {
+ Group {
+ if let avatarPreview {
+ Image(uiImage: avatarPreview)
+ .resizable()
+ .scaledToFill()
+ } else {
+ AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .scaledToFill()
+ default:
+ Image(systemName: "person.crop.circle.fill")
+ .resizable()
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .frame(width: 80, height: 80)
+ .clipShape(Circle())
+ .overlay(
+ Circle()
+ .stroke(.secondary.opacity(0.3), lineWidth: 1)
+ )
+ }
+
+ Text("Tap to change avatar")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if viewModel.isUploadingAvatar {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ Spacer()
+ }
+ .listRowBackground(Color.clear)
+
+ if profile.avatar != nil || avatarPreview != nil {
+ HStack {
+ Spacer()
+ Button(role: .destructive) {
+ isShowingRemoveAvatarConfirmation = true
+ } label: {
+ Text("Remove Avatar")
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.isUploadingAvatar)
+ Spacer()
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ }
+
+ Section("Edit Profile") {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Email")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter email", text: $email)
+ .textContentType(.emailAddress)
+ .keyboardType(.emailAddress)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("URL")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter URL", text: $url)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Location")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter location", text: $location)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Bio")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter bio", text: $bio, axis: .vertical)
+ .lineLimit(3...6)
+ }
+ }
+ }
+ .navigationTitle("Edit Profile")
+ .navigationBarTitleDisplayMode(.inline)
+ .onChange(of: selectedPhoto) { _, newItem in
+ guard let newItem else { return }
+ Task {
+ if let data = try? await newItem.loadTransferable(type: Data.self),
+ let image = UIImage(data: data) {
+ avatarPreview = image
+ // Encode as JPEG and upload
+ if let jpegData = image.jpegData(compressionQuality: 0.85) {
+ await viewModel.uploadAvatar(jpegData: jpegData)
+ }
+ }
+ }
+ }
+ .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Avatar", role: .destructive) {
+ Task {
+ await viewModel.removeAvatar()
+ if viewModel.error == nil {
+ avatarPreview = nil
+ selectedPhoto = nil
+ }
+ }
+ }
+ } message: {
+ Text("Your profile avatar will be removed from SourceHut.")
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ await viewModel.saveProfile(
+ email: email,
+ url: url,
+ location: location,
+ bio: bio
+ )
+ if viewModel.error == nil {
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isSavingProfile {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Save")
+ }
+ }
+ .disabled(viewModel.isSavingProfile)
+ }
+ }
+ }
+ }
+}
+
+private enum SettingsDestructiveAction {
+ case resetAppData
+ case signOut
+ case deleteSSHKey(SSHKey)
+ case deletePGPKey(PGPKey)
+
+ var title: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data?"
+ case .signOut:
+ "Sign Out?"
+ case .deleteSSHKey:
+ "Remove SSH Key?"
+ case .deletePGPKey:
+ "Remove PGP Key?"
+ }
+ }
+
+ var confirmationLabel: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data"
+ case .signOut:
+ "Sign Out"
+ case .deleteSSHKey:
+ "Remove SSH Key"
+ case .deletePGPKey:
+ "Remove PGP Key"
+ }
+ }
+
+ var message: String {
+ switch self {
+ case .resetAppData:
+ "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
+ case .signOut:
+ "This signs you out of Hutch and clears saved authentication state on this device."
+ case .deleteSSHKey(let key):
+ "Remove SSH key \(key.fingerprint) from your account?"
+ case .deletePGPKey(let key):
+ "Remove PGP key \(key.fingerprint) from your account?"
+ }
+ }
+}
+
+private struct AboutView: View {
+ private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
+ ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
+ ?? "Hutch"
+ private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "Unknown"
+ private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+ ?? "Unknown"
+
+ var body: some View {
+ Form {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(appName)
+ .font(.title2.weight(.semibold))
+ Text("A native SourceHut client for iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+
+ LabeledContent("Version", value: version)
+ LabeledContent("Build", value: build)
+ }
+
+ Section("Links") {
+ Link(destination: URL(string: "https://sr.ht")!) {
+ SwiftUI.Label("SourceHut", systemImage: "link")
+ }
+ Link(destination: URL(string: "https://man.sr.ht")!) {
+ SwiftUI.Label("SourceHut Manuals", systemImage: "book")
+ }
+ Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) {
+ SwiftUI.Label("Project Repository", systemImage: "folder")
+ }
+ }
+
+ Section("Support") {
+ Link(destination: URL(string: "mailto:[email protected]")!) {
+ SwiftUI.Label("Email Support", systemImage: "envelope")
+ }
+ }
+
+ Section("Privacy") {
+ Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Acknowledgements") {
+ Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("About")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
new file mode 100644
index 0000000..204afbf
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -0,0 +1,394 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct MeProfileResponse: Decodable, Sendable {
+ let me: UserProfile
+}
+
+private struct UpdateUserResponse: Decodable, Sendable {
+ let updateUser: UpdatedUser
+}
+
+private struct UpdatedUser: Decodable, Sendable {
+ let username: String
+ let email: String
+ let url: String?
+ let location: String?
+ let bio: String?
+ let avatar: String?
+}
+
+private struct CreateSSHKeyResponse: Decodable, Sendable {
+ let createSSHKey: SSHKey
+}
+
+private struct DeleteSSHKeyResponse: Decodable, Sendable {
+ let deleteSSHKey: DeleteResult
+}
+
+private struct CreatePGPKeyResponse: Decodable, Sendable {
+ let createPGPKey: PGPKey
+}
+
+private struct DeletePGPKeyResponse: Decodable, Sendable {
+ let deletePGPKey: DeleteResult
+}
+
+private struct DeleteResult: Decodable, Sendable {
+ let id: Int?
+}
+
+private struct PATListResponse: Decodable, Sendable {
+ let personalAccessTokens: [PersonalAccessToken]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class SettingsViewModel {
+
+ private(set) var profile: UserProfile?
+ private(set) var sshKeys: [SSHKey] = []
+ private(set) var pgpKeys: [PGPKey] = []
+ private(set) var personalAccessTokens: [PersonalAccessToken] = []
+
+ private(set) var isLoading = false
+ private(set) var isLoadingPATs = false
+ private(set) var isSavingProfile = false
+ private(set) var isUploadingAvatar = false
+ var error: String?
+
+ var isEditingProfile = false
+
+ // Add SSH key fields
+ var newSSHKey = ""
+ var isAddingSSHKey = false
+
+ // Add PGP key fields
+ var newPGPKey = ""
+ var isAddingPGPKey = false
+
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let profileQuery = """
+ query me {
+ me {
+ username
+ canonicalName
+ email
+ url
+ location
+ bio
+ avatar
+ userType
+ sshKeys {
+ results { id fingerprint comment created lastUsed }
+ cursor
+ }
+ pgpKeys {
+ results { id fingerprint created }
+ cursor
+ }
+ paymentStatus
+ subscription { status autorenew interval }
+ }
+ }
+ """
+
+ private static let updateUserMutation = """
+ mutation updateUser($input: UserInput!) {
+ updateUser(input: $input) {
+ username email url location bio avatar
+ }
+ }
+ """
+
+ private static let createSSHKeyMutation = """
+ mutation createSSHKey($key: String!) {
+ createSSHKey(key: $key) {
+ id fingerprint comment created lastUsed
+ }
+ }
+ """
+
+ private static let deleteSSHKeyMutation = """
+ mutation deleteSSHKey($id: Int!) {
+ deleteSSHKey(id: $id) { id }
+ }
+ """
+
+ private static let createPGPKeyMutation = """
+ mutation createPGPKey($key: String!) {
+ createPGPKey(key: $key) {
+ id fingerprint created
+ }
+ }
+ """
+
+ private static let deletePGPKeyMutation = """
+ mutation deletePGPKey($id: Int!) {
+ deletePGPKey(id: $id) { id }
+ }
+ """
+
+ private static let personalAccessTokensQuery = """
+ query personalAccessTokens {
+ personalAccessTokens { id issued expires comment grants }
+ }
+ """
+
+ // MARK: - Load Profile
+
+ func loadProfile() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.profileQuery,
+ responseType: MeProfileResponse.self
+ )
+ profile = result.me
+ sshKeys = result.me.sshKeys.results
+ pgpKeys = result.me.pgpKeys.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ // MARK: - Update Profile
+
+ func saveProfile(email: String, url: String, location: String, bio: String) async {
+ guard !isSavingProfile else { return }
+ isSavingProfile = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "email": email,
+ "url": url.isEmpty ? nil as String? as Any : url,
+ "location": location.isEmpty ? nil as String? as Any : location,
+ "bio": bio.isEmpty ? nil as String? as Any : bio
+ ]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ isEditingProfile = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSavingProfile = false
+ }
+
+ // MARK: - Avatar
+
+ func uploadAvatar(jpegData: Data) async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ // The input variable has avatar set to null; the actual file
+ // is sent as a separate multipart part per graphql-multipart-request-spec.
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.executeMultipart(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ fileVariablePath: "input.avatar",
+ fileData: jpegData,
+ fileName: "avatar.jpg",
+ mimeType: "image/jpeg",
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ func removeAvatar() async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: nil,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ // MARK: - SSH Keys
+
+ func addSSHKey() async {
+ let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createSSHKeyMutation,
+ variables: ["key": key],
+ responseType: CreateSSHKeyResponse.self
+ )
+ sshKeys.append(result.createSSHKey)
+ newSSHKey = ""
+ isAddingSSHKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteSSHKey(_ key: SSHKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deleteSSHKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeleteSSHKeyResponse.self
+ )
+ sshKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - PGP Keys
+
+ func addPGPKey() async {
+ let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createPGPKeyMutation,
+ variables: ["key": key],
+ responseType: CreatePGPKeyResponse.self
+ )
+ pgpKeys.append(result.createPGPKey)
+ newPGPKey = ""
+ isAddingPGPKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deletePGPKey(_ key: PGPKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deletePGPKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeletePGPKeyResponse.self
+ )
+ pgpKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Personal Access Tokens
+
+ func loadPersonalAccessTokens() async {
+ guard !isLoadingPATs else { return }
+ isLoadingPATs = true
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.personalAccessTokensQuery,
+ responseType: PATListResponse.self
+ )
+ personalAccessTokens = result.personalAccessTokens
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingPATs = false
+ }
+
+}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
new file mode 100644
index 0000000..6b2be4f
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -0,0 +1,828 @@
+import SwiftUI
+import WebKit
+
+struct TicketDetailView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: TicketDetailViewModel?
+
+ // Sheet state
+ @State private var showResolveSheet = false
+ @State private var showAssignSheet = false
+ @State private var showLabelsSheet = false
+
+ // Comment composer mode
+ @State private var commentMode: CommentMode = .write
+
+ private enum CommentMode: String, CaseIterable {
+ case write = "Write"
+ case preview = "Preview"
+ }
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ }
+ }
+ .navigationTitle("#\(ticketId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if let viewModel, viewModel.ticket != nil {
+ actionsMenu(viewModel)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketDetailViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ trackerRid: trackerRid,
+ ticketId: ticketId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Actions Menu
+
+ @ViewBuilder
+ private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
+ Menu {
+ if let ticket = viewModel.ticket {
+ if ticket.status == .resolved {
+ Button {
+ Task {
+ await viewModel.updateStatus(
+ status: .reported,
+ resolution: .unresolved
+ )
+ }
+ } label: {
+ SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
+ }
+ } else {
+ Button {
+ showResolveSheet = true
+ } label: {
+ SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
+ }
+ }
+ }
+
+ Button {
+ showAssignSheet = true
+ } label: {
+ SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
+ }
+
+ Button {
+ showLabelsSheet = true
+ Task { await viewModel.loadTrackerLabels() }
+ } label: {
+ SwiftUI.Label("Manage Labels", systemImage: "tag")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .sheet(isPresented: $showResolveSheet) {
+ ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showAssignSheet) {
+ AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showLabelsSheet) {
+ LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
+ .presentationDetents([.medium])
+ }
+ }
+
+ // MARK: - Detail Content
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.ticket == nil {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ } else if let error = viewModel.error, viewModel.ticket == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Ticket",
+ message: error,
+ retryAction: { await viewModel.loadTicket() }
+ )
+ } else if let ticket = viewModel.ticket {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header
+ ticketHeader(ticket)
+
+ Divider()
+ .padding(.vertical, 12)
+
+ // Description
+ if let description = ticket.description, !description.isEmpty {
+ MarkdownContentView(markdown: description)
+ .padding(.horizontal)
+ .padding(.bottom, 16)
+
+ Divider()
+ .padding(.bottom, 12)
+ }
+
+ // Event timeline
+ if !viewModel.events.isEmpty {
+ Text("Activity")
+ .font(.headline)
+ .padding(.horizontal)
+ .padding(.bottom, 8)
+
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(viewModel.events) { event in
+ EventRow(
+ event: event,
+ ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
+ ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
+ )
+ if event.id != viewModel.events.last?.id {
+ Divider()
+ .padding(.leading, 40)
+ }
+ }
+ }
+
+ Divider()
+ .padding(.vertical, 12)
+ }
+
+ // Comment input
+ commentInput(viewModel)
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func ticketHeader(_ ticket: TicketDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(ticket.title)
+ .font(.title3.weight(.semibold))
+
+ HStack(spacing: 8) {
+ TicketStatusIcon(status: ticket.status)
+ Text(ticket.status.displayName)
+ .font(.subheadline.weight(.medium))
+
+ if ticket.status == .resolved, let resolution = ticket.resolution {
+ Text("(\(resolution.displayName))")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ HStack(spacing: 4) {
+ Text("Opened by")
+ .foregroundStyle(.secondary)
+ Text(ticket.submitter.canonicalName)
+ .fontWeight(.medium)
+ Text(ticket.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption)
+
+ if !ticket.assignees.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "person.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Comment Input
+
+ @ViewBuilder
+ private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("New Comment")
+ .font(.headline)
+
+ Picker("Mode", selection: $commentMode) {
+ ForEach(CommentMode.allCases, id: \.self) { mode in
+ Text(mode.rawValue).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+
+ if commentMode == .write {
+ TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(3...8)
+ } else {
+ // Markdown preview
+ if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Text("Nothing to preview")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
+ .background(Color(.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ } else {
+ MarkdownContentView(markdown: viewModel.commentText)
+ .frame(minHeight: 80, maxHeight: 200)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button {
+ Task { await viewModel.submitComment() }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Post Comment")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ .padding()
+ }
+}
+
+// MARK: - Self-Sizing Markdown Web View
+
+/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
+/// fit the rendered content. Reuses the same `markdownToHTML` converter and
+/// styling as the README renderer.
+private struct MarkdownContentView: View {
+ let markdown: String
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var renderedHTML: String?
+
+ var body: some View {
+ Group {
+ if let renderedHTML {
+ HTMLWebView(
+ html: renderedHTML,
+ colorScheme: colorScheme,
+ style: .commentPreview
+ )
+ } else {
+ SRHTLoadingStateView(message: "Preparing content…")
+ .frame(minHeight: 80)
+ }
+ }
+ .task(id: markdown) {
+ if renderedHTML != nil {
+ try? await Task.sleep(for: .milliseconds(150))
+ guard !Task.isCancelled else { return }
+ }
+
+ let html = await Task.detached(priority: .userInitiated) {
+ markdownToHTML(markdown)
+ }.value
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ }
+ }
+}
+
+// MARK: - Event Row
+
+private struct EventRow: View {
+ let event: TicketEvent
+ let ticketSubmitter: String?
+ let ticketAssignees: [String]?
+ @State private var isShowingSystemStatusInfo = false
+
+ var body: some View {
+ ForEach(event.changes) { change in
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: icon(for: change))
+ .foregroundStyle(color(for: change))
+ .frame(width: 24)
+ .padding(.top, 2)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ if kind(for: change) == .comment {
+ Text(change.author?.canonicalName ?? "")
+ .font(.subheadline.weight(.medium))
+ } else {
+ let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
+ if descriptionText.hasPrefix("System") {
+ HStack(spacing: 4) {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Button {
+ isShowingSystemStatusInfo = true
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+ } else {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Text(event.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ if kind(for: change) == .comment, let text = change.text {
+ MarkdownContentView(markdown: text)
+ }
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("This status change was recorded automatically or without a named user attached to the event.")
+ }
+ }
+ }
+
+ private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
+ let eventKind = kind(for: change)
+ let authorName: String
+
+ if let commentAuthor = event.changes.first(where: {
+ kind(for: $0) == .comment && $0.author != nil
+ })?.author?.canonicalName {
+ authorName = commentAuthor
+ } else {
+ switch eventKind {
+ case .created:
+ authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
+ case .statusChange:
+ authorName = "System"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ authorName = change.labeler?.canonicalName ?? "Someone"
+ case .assigned, .unassigned:
+ authorName = change.assigner?.canonicalName ?? "Someone"
+ case .comment:
+ authorName = change.author?.canonicalName ?? "Someone"
+ case .ticketMention, .userMention:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? "Someone"
+ case .unknown:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? change.assignee?.canonicalName
+ ?? change.mentioned?.canonicalName
+ ?? ticketAssignees?.first
+ ?? "Someone"
+ }
+ }
+
+ switch eventKind {
+ case .statusChange:
+ let oldStatus = change.oldStatus?.displayName ?? "unknown"
+ let newStatus = change.newStatus?.displayName ?? "unknown"
+ return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
+ case .labelUpdated, .labelAdded:
+ let labelName = change.label?.name ?? "a label"
+ let verb = eventKind == .labelAdded ? "added" : "updated"
+ return "\(authorName) \(verb) label \"\(labelName)\""
+ case .labelRemoved:
+ let labelName = change.label?.name ?? "a label"
+ return "\(authorName) removed label \"\(labelName)\""
+ case .assigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) assigned \(assigneeName)"
+ case .unassigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) unassigned \(assigneeName)"
+ case .ticketMention:
+ if let ticketId = change.mentioned?.id {
+ return "\(authorName) mentioned ticket #\(ticketId)"
+ }
+ return "\(authorName) mentioned another ticket"
+ case .userMention:
+ let user = change.mentioned?.canonicalName ?? "someone"
+ return "\(authorName) mentioned \(user)"
+ case .created:
+ return "\(authorName) opened this ticket"
+ case .comment:
+ return "\(authorName) commented"
+ case .unknown:
+ return "\(authorName) updated this ticket"
+ }
+ }
+
+ private func icon(for change: EventChange) -> String {
+ switch kind(for: change) {
+ case .comment:
+ "text.bubble"
+ case .statusChange:
+ "arrow.triangle.2.circlepath"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ "tag"
+ case .assigned:
+ "person.badge.plus"
+ case .unassigned:
+ "person.badge.minus"
+ case .ticketMention, .userMention:
+ "at"
+ case .created:
+ "plus.circle"
+ case .unknown:
+ "circle.fill"
+ }
+ }
+
+ private func color(for change: EventChange) -> Color {
+ switch kind(for: change) {
+ case .comment:
+ .blue
+ case .statusChange:
+ change.newStatus == .resolved ? .green : .orange
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ .purple
+ case .assigned, .unassigned:
+ .cyan
+ case .ticketMention, .userMention:
+ .indigo
+ case .created:
+ .green
+ case .unknown:
+ .gray
+ }
+ }
+
+ private func kind(for change: EventChange) -> EventKind {
+ switch change.eventType {
+ case "COMMENT", "Comment":
+ .comment
+ case "STATUS_CHANGE", "StatusChange":
+ .statusChange
+ case "LABEL_UPDATE", "LabelUpdate":
+ .labelUpdated
+ case "LABEL_ADDED", "LabelAdded":
+ .labelAdded
+ case "LABEL_REMOVED", "LabelRemoved":
+ .labelRemoved
+ case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
+ .assigned
+ case "UNASSIGNED_USER", "UnassignedUser":
+ .unassigned
+ case "TICKET_MENTION", "TicketMention":
+ .ticketMention
+ case "USER_MENTION", "UserMention":
+ .userMention
+ case "CREATED", "Created":
+ .created
+ default:
+ .unknown
+ }
+ }
+
+ private enum EventKind: Equatable {
+ case comment
+ case statusChange
+ case labelUpdated
+ case labelAdded
+ case labelRemoved
+ case assigned
+ case unassigned
+ case ticketMention
+ case userMention
+ case created
+ case unknown
+ }
+}
+
+// MARK: - Resolve Sheet
+
+private struct ResolveSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var selectedResolution: TicketResolution = .fixed
+
+ private static let resolutionOptions: [TicketResolution] = [
+ .closed, .fixed, .implemented, .wontFix,
+ .byDesign, .invalid, .duplicate, .notOurBug
+ ]
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Resolution") {
+ Picker("Resolution", selection: $selectedResolution) {
+ ForEach(Self.resolutionOptions, id: \.self) { resolution in
+ Text(resolution.displayName).tag(resolution)
+ }
+ }
+ .pickerStyle(.inline)
+ .labelsHidden()
+ }
+ }
+ .navigationTitle("Resolve Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Mark Resolved") {
+ Task {
+ await viewModel.updateStatus(
+ status: .resolved,
+ resolution: selectedResolution
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Assign Sheet
+
+private struct AssignSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var username = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ // Current assignees with remove buttons
+ if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
+ Section("Current Assignees") {
+ ForEach(ticket.assignees, id: \.canonicalName) { assignee in
+ HStack {
+ Text(assignee.canonicalName)
+ Spacer()
+ Button(role: .destructive) {
+ Task {
+ await viewModel.unassignUser(
+ username: assignee.canonicalName
+ )
+ }
+ } label: {
+ Image(systemName: "minus.circle.fill")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ Section("Add Assignee") {
+ TextField("Username or ~username", text: $username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Button("Add Assignee") {
+ let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
+ Task {
+ await viewModel.assignUser(username: name)
+ username = ""
+ }
+ }
+ .disabled(
+ username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .navigationTitle("Assignees")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { isPresented = false }
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Labels Sheet
+
+private struct LabelsSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var showCreateLabel = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.trackerLabels.isEmpty {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ } else {
+ ContentUnavailableView(
+ "No Labels",
+ systemImage: "tag",
+ description: Text("This tracker has no labels defined.")
+ )
+ }
+ } else {
+ List {
+ ForEach(viewModel.trackerLabels) { label in
+ LabelToggleRow(
+ label: label,
+ isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
+ isLoading: viewModel.isPerformingAction
+ ) { shouldApply in
+ Task {
+ if shouldApply {
+ await viewModel.labelTicket(labelId: label.id)
+ } else {
+ await viewModel.unlabelTicket(labelId: label.id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Labels")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { isPresented = false }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ showCreateLabel = true
+ } label: {
+ SwiftUI.Label("New Label", systemImage: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateLabel) {
+ CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
+ .presentationDetents([.medium])
+ }
+ }
+ }
+}
+
+// MARK: - Create Label Sheet
+
+private struct CreateLabelSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var labelName = ""
+ @State private var backgroundColor = Color.blue
+ @State private var foregroundColor = Color.white
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Label Details") {
+ TextField("Label name", text: $labelName)
+ .autocorrectionDisabled()
+ }
+
+ Section("Colors") {
+ ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
+ ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
+ }
+
+ Section("Preview") {
+ HStack {
+ Spacer()
+ Text(labelName.isEmpty ? "Label" : labelName)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ Spacer()
+ }
+ }
+ }
+ .navigationTitle("New Label")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Create Label") {
+ Task {
+ await viewModel.createLabel(
+ name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ backgroundColor: backgroundColor.hexString,
+ foregroundColor: foregroundColor.hexString
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(
+ labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+private struct LabelToggleRow: View {
+ let label: TicketLabel
+ let isApplied: Bool
+ let isLoading: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ Button {
+ onToggle(!isApplied)
+ } label: {
+ HStack {
+ LabelPill(label: label)
+ Spacer()
+ if isApplied {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ }
+ .disabled(isLoading)
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
new file mode 100644
index 0000000..70eaf0a
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -0,0 +1,551 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TicketDetailResponse: Decodable, Sendable {
+ let user: UserTrackerTicketWrapper
+}
+
+private struct UserTrackerTicketWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketWrapper
+}
+
+private struct TrackerTicketWrapper: Decodable, Sendable {
+ let ticket: TicketDetailPayload
+}
+
+private struct TicketDetailPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+ let events: EventsPage
+}
+
+private struct EventsPage: Decodable, Sendable {
+ let results: [TicketEvent]
+ let cursor: String?
+}
+
+private struct SubmitCommentResponse: Decodable, Sendable {
+ let submitComment: SubmittedEvent
+}
+
+private struct SubmittedEvent: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let changes: [EventChange]
+}
+
+private struct MutationEventResponse: Decodable, Sendable {
+ let id: Int
+}
+
+private struct UpdateStatusResponse: Decodable, Sendable {
+ let updateTicketStatus: UpdatedStatusEvent
+}
+
+private struct UpdatedStatusEvent: Decodable, Sendable {
+ let eventType: String
+}
+
+private struct AssignUserResponse: Decodable, Sendable {
+ let assignUser: MutationEventResponse
+}
+
+private struct UnassignUserResponse: Decodable, Sendable {
+ let unassignUser: MutationEventResponse
+}
+
+private struct LabelTicketResponse: Decodable, Sendable {
+ let labelTicket: MutationEventResponse
+}
+
+private struct UnlabelTicketResponse: Decodable, Sendable {
+ let unlabelTicket: MutationEventResponse
+}
+
+private struct UserLookupResponse: Decodable, Sendable {
+ let user: UserIdPayload
+}
+
+private struct UserIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
+private struct CreateLabelResponse: Decodable, Sendable {
+ let createLabel: TicketLabel
+}
+
+private struct TrackerLabelsResponse: Decodable, Sendable {
+ let user: UserTrackerLabelsWrapper
+}
+
+private struct UserTrackerLabelsWrapper: Decodable, Sendable {
+ let tracker: TrackerLabelsWrapper
+}
+
+private struct TrackerLabelsWrapper: Decodable, Sendable {
+ let labels: LabelsPage
+}
+
+private struct LabelsPage: Decodable, Sendable {
+ let results: [TicketLabel]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketDetailViewModel {
+
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ private(set) var ticket: TicketDetail?
+ private(set) var events: [TicketEvent] = []
+ private(set) var isLoading = false
+ private(set) var isSubmitting = false
+ private(set) var isPerformingAction = false
+ private(set) var trackerLabels: [TicketLabel] = []
+ var commentText = ""
+ var error: String?
+
+ private let client: SRHTClient
+
+ private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
+ if lhs.created == rhs.created {
+ return lhs.id < rhs.id
+ }
+ return lhs.created < rhs.created
+ }
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.trackerRid = trackerRid
+ self.ticketId = ticketId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ ticket(id: $ticketId) {
+ id
+ created
+ updated
+ title: subject
+ description: body
+ status
+ resolution
+ authenticity
+ submitter { canonicalName }
+ assignees { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ events {
+ results {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ ... on StatusChange {
+ oldStatus
+ newStatus
+ }
+ ... on LabelUpdate {
+ labeler { canonicalName }
+ label { name }
+ }
+ ... on Assignment {
+ assigner { canonicalName }
+ assignee { canonicalName }
+ }
+ ... on TicketMention {
+ mentioned { id }
+ }
+ ... on UserMention {
+ mentioned { canonicalName }
+ }
+ ... on Created {
+ author { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitCommentMutation = """
+ mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
+ submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ }
+ }
+ }
+ """
+
+ private static let updateStatusMutation = """
+ mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
+ updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ eventType: __typename
+ }
+ }
+ """
+
+ private static let assignUserMutation = """
+ mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let unassignUserMutation = """
+ mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let labelTicketMutation = """
+ mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let unlabelTicketMutation = """
+ mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user(username: $username) { id }
+ }
+ """
+
+ private static let trackerLabelsQuery = """
+ query trackerLabels($owner: String!, $tracker: String!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ labels {
+ results { id name backgroundColor foregroundColor }
+ }
+ }
+ }
+ }
+ """
+
+ private static let createLabelMutation = """
+ mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
+ createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
+ id
+ name
+ backgroundColor
+ foregroundColor
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTicket() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.detailQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName,
+ "ticketId": ticketId
+ ],
+ responseType: TicketDetailResponse.self
+ )
+ let payload = result.user.tracker.ticket
+ ticket = TicketDetail(
+ id: payload.id,
+ created: payload.created,
+ updated: payload.updated,
+ title: payload.title,
+ description: payload.description,
+ status: payload.status,
+ resolution: payload.resolution,
+ authenticity: payload.authenticity,
+ submitter: payload.submitter,
+ assignees: payload.assignees,
+ labels: payload.labels
+ )
+ events = payload.events.results.sorted(by: Self.timelineOrder)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func submitComment() async {
+ let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, !isSubmitting else { return }
+ isSubmitting = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["text": text]
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitCommentMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: SubmitCommentResponse.self
+ )
+ // Append the returned event so the comment shows immediately.
+ let submitted = result.submitComment
+ let event = TicketEvent(
+ id: submitted.id,
+ created: submitted.created,
+ changes: submitted.changes
+ )
+ events.append(event)
+ events.sort(by: Self.timelineOrder)
+ commentText = ""
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSubmitting = false
+ }
+
+ func updateComment(commentId: Int, text: String) async {
+ _ = commentId
+ _ = text
+ error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
+ }
+
+ // MARK: - Ticket Actions
+
+ func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "status": status.rawValue,
+ "resolution": resolution.rawValue
+ ]
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.updateStatusMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: UpdateStatusResponse.self
+ )
+ // Re-fetch the ticket to get updated status/resolution
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func assignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": username],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.assignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: AssignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unassignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": stripped],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unassignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: UnassignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func labelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.labelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: LabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unlabelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unlabelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: UnlabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func loadTrackerLabels() async {
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackerLabelsQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ],
+ responseType: TrackerLabelsResponse.self
+ )
+ trackerLabels = result.user.tracker.labels.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createLabelMutation,
+ variables: [
+ "trackerId": trackerId,
+ "name": name,
+ "backgroundColor": backgroundColor,
+ "foregroundColor": foregroundColor
+ ],
+ responseType: CreateLabelResponse.self
+ )
+ trackerLabels.append(result.createLabel)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
new file mode 100644
index 0000000..7e16276
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -0,0 +1,389 @@
+import SwiftUI
+
+struct TicketListView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TicketListViewModel?
+ @State private var showCreateTicketSheet = false
+ @State private var createdTicket: TicketSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ }
+ }
+ .navigationTitle(trackerName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Button {
+ showCreateTicketSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTicketSheet) {
+ if let viewModel {
+ CreateTicketSheet(viewModel: viewModel) { ticket in
+ showCreateTicketSheet = false
+ createdTicket = ticket
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTicket != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTicket = nil
+ }
+ }
+ )) {
+ if let createdTicket {
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketListViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTickets()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TicketListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ // Filter picker
+ Section {
+ Picker("Filter", selection: $vm.filter) {
+ ForEach(TicketFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
+ // Tickets
+ ForEach(viewModel.filteredTickets) { ticket in
+ NavigationLink(value: ticket) {
+ TicketRowView(ticket: ticket)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: ticket)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.tickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ } else if let error = viewModel.error, viewModel.tickets.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Tickets",
+ message: error,
+ retryAction: { await viewModel.loadTickets() }
+ )
+ } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Tickets",
+ systemImage: "ticket",
+ description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
+ await viewModel.loadTickets()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTickets()
+ }
+ .navigationDestination(for: TicketSummary.self) { ticket in
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
+ }
+ }
+}
+
+private struct CreateTicketSheet: View {
+ let viewModel: TicketListViewModel
+ let onCreated: (TicketSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var subject = ""
+ @State private var descriptionText = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Ticket Details") {
+ TextField("Title", text: $subject)
+ TextField("Description (optional)", text: $descriptionText, axis: .vertical)
+ .lineLimit(6...12)
+ }
+ }
+ .navigationTitle("New Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
+ onCreated(ticket)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTicket {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Ticket")
+ }
+ }
+ .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Ticket Row
+
+private struct TicketRowView: View {
+ let ticket: TicketSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top) {
+ TicketStatusIcon(status: ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("#\(ticket.id)")
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ + Text(" ")
+ + Text(ticket.title)
+ .font(.subheadline)
+ }
+
+ Spacer()
+ }
+
+ HStack(spacing: 8) {
+ Text(ticket.submitter.canonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(ticket.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+
+ if !ticket.assignees.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "person.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Ticket Status Icon
+
+struct TicketStatusIcon: View {
+ let status: TicketStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .reported: "circle"
+ case .confirmed: "circle.inset.filled"
+ case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "clock.fill"
+ case .resolved: "checkmark.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .reported: .gray
+ case .confirmed: .blue
+ case .inProgress: .yellow
+ case .pending: .orange
+ case .resolved: .green
+ }
+ }
+}
+
+// MARK: - Label Pill
+
+struct LabelPill: View {
+ let label: TicketLabel
+
+ var body: some View {
+ Text(label.name)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ }
+
+ private var backgroundColor: Color {
+ Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
+ }
+
+ private var foregroundColor: Color {
+ Color(hex: label.foregroundColor) ?? .primary
+ }
+}
+
+// MARK: - Color from hex string
+
+extension Color {
+ init?(hex: String) {
+ var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if hexString.hasPrefix("#") {
+ hexString.removeFirst()
+ }
+
+ guard hexString.count == 6,
+ let hexNumber = UInt64(hexString, radix: 16) else {
+ return nil
+ }
+
+ let r = Double((hexNumber & 0xFF0000) >> 16) / 255
+ let g = Double((hexNumber & 0x00FF00) >> 8) / 255
+ let b = Double(hexNumber & 0x0000FF) / 255
+
+ self.init(red: r, green: g, blue: b)
+ }
+
+ /// Returns a `#rrggbb` hex string for this color.
+ var hexString: String {
+ let resolved = resolve(in: .init())
+ let r = Int(max(0, min(1, resolved.red)) * 255)
+ let g = Int(max(0, min(1, resolved.green)) * 255)
+ let b = Int(max(0, min(1, resolved.blue)) * 255)
+ return String(format: "#%02x%02x%02x", r, g, b)
+ }
+}
+
+// MARK: - Flow Layout (for label pills)
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 4
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(
+ at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
+ proposal: ProposedViewSize(result.sizes[index])
+ )
+ }
+ }
+
+ private struct LayoutResult {
+ var positions: [CGPoint]
+ var sizes: [CGSize]
+ var size: CGSize
+ }
+
+ private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var sizes: [CGSize] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var totalHeight: CGFloat = 0
+ var totalWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ sizes.append(size)
+
+ if currentX + size.width > maxWidth, currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ totalWidth = max(totalWidth, currentX - spacing)
+ totalHeight = currentY + lineHeight
+ }
+
+ return LayoutResult(
+ positions: positions,
+ sizes: sizes,
+ size: CGSize(width: totalWidth, height: totalHeight)
+ )
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
new file mode 100644
index 0000000..916037e
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -0,0 +1,215 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackerTicketsResponse: Decodable, Sendable {
+ let user: UserTrackerWrapper
+}
+
+private struct UserTrackerWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketsWrapper
+}
+
+private struct TrackerTicketsWrapper: Decodable, Sendable {
+ let tickets: TicketsPage
+}
+
+private struct TicketsPage: Decodable, Sendable {
+ let results: [TicketSummary]
+ let cursor: String?
+}
+
+// MARK: - Filter
+
+enum TicketFilter: String, CaseIterable, Sendable {
+ case open = "Open"
+ case resolved = "Resolved"
+ case all = "All"
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketListViewModel {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+
+ private(set) var tickets: [TicketSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTicket = false
+ var error: String?
+ var filter: TicketFilter = .open
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets(cursor: $cursor) {
+ results {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitTicketMutation = """
+ mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
+ submitTicket(trackerId: $trackerId, input: $input) {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Computed
+
+ /// Tickets filtered by the selected status filter.
+ var filteredTickets: [TicketSummary] {
+ switch filter {
+ case .open:
+ tickets.filter { $0.status.isOpen }
+ case .resolved:
+ tickets.filter { !$0.status.isOpen }
+ case .all:
+ tickets
+ }
+ }
+
+ // MARK: - Public API
+
+ func loadTickets() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ tickets = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TicketSummary) async {
+ guard let last = tickets.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ tickets.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTicket(subject: String, body: String) async -> TicketSummary? {
+ guard !isCreatingTicket else { return nil }
+
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSubject.isEmpty else {
+ error = "Enter a ticket title."
+ return nil
+ }
+
+ isCreatingTicket = true
+ error = nil
+ defer { isCreatingTicket = false }
+
+ var input: [String: any Sendable] = [
+ "subject": trimmedSubject
+ ]
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedBody.isEmpty {
+ input["body"] = trimmedBody
+ }
+ let variables: [String: any Sendable] = [
+ "trackerId": trackerId,
+ "input": input
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitTicketMutation,
+ variables: variables,
+ responseType: SubmitTicketResponse.self
+ )
+ let ticket = result.submitTicket
+ tickets.insert(ticket, at: 0)
+ return ticket
+ } catch {
+ self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TicketsPage {
+ var variables: [String: any Sendable] = [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables,
+ responseType: TrackerTicketsResponse.self
+ )
+ return result.user.tracker.tickets
+ }
+
+ private struct SubmitTicketResponse: Decodable, Sendable {
+ let submitTicket: TicketSummary
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
new file mode 100644
index 0000000..d16246b
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -0,0 +1,214 @@
+import SwiftUI
+
+struct TrackerListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TrackerListViewModel?
+ @State private var showCreateTrackerSheet = false
+ @State private var createdTracker: TrackerSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ }
+ }
+ .navigationTitle("Trackers")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateTrackerSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTrackerSheet) {
+ if let viewModel {
+ CreateTrackerSheet(viewModel: viewModel) { tracker in
+ showCreateTrackerSheet = false
+ createdTracker = tracker
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTracker != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTracker = nil
+ }
+ }
+ )) {
+ if let createdTracker {
+ TicketListView(
+ ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
+ trackerName: createdTracker.name,
+ trackerId: createdTracker.id,
+ trackerRid: createdTracker.rid
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TrackerListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadTrackers()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TrackerListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.trackers) { tracker in
+ NavigationLink(value: tracker) {
+ TrackerRowView(tracker: tracker)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: tracker)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.trackers.isEmpty {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ } else if let error = viewModel.error, viewModel.trackers.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Trackers",
+ message: error,
+ retryAction: { await viewModel.loadTrackers() }
+ )
+ } else if viewModel.trackers.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Trackers",
+ systemImage: "checklist",
+ description: Text("Your bug trackers will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
+ await viewModel.loadTrackers()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTrackers()
+ }
+ .navigationDestination(for: TrackerSummary.self) { tracker in
+ TicketListView(
+ ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+ trackerName: tracker.name,
+ trackerId: tracker.id,
+ trackerRid: tracker.rid
+ )
+ }
+ }
+}
+
+private struct CreateTrackerSheet: View {
+ let viewModel: TrackerListViewModel
+ let onCreated: (TrackerSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var description = ""
+ @State private var visibility: Visibility = .public
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Tracker Details") {
+ TextField("Tracker 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)
+ }
+ }
+ }
+ .navigationTitle("New Tracker")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let tracker = await viewModel.createTracker(
+ name: name,
+ description: description,
+ visibility: visibility
+ ) {
+ onCreated(tracker)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTracker {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Tracker")
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Tracker Row
+
+private struct TrackerRowView: View {
+ let tracker: TrackerSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(tracker.name)
+ .font(.subheadline.weight(.medium))
+
+ Spacer()
+
+ VisibilityBadge(visibility: tracker.visibility)
+ }
+
+ if let owner = tracker.owner.canonicalName.split(separator: "~").last {
+ Text("~\(owner)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let description = tracker.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Text(tracker.updated.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
new file mode 100644
index 0000000..9704071
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackersResponse: Decodable, Sendable {
+ let trackers: TrackersPage
+}
+
+private struct TrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TrackerListViewModel {
+
+ private(set) var trackers: [TrackerSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTracker = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createTrackerMutation = """
+ mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
+ createTracker(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTrackers() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ trackers = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TrackerSummary) async {
+ guard let last = trackers.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ trackers.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
+ guard !isCreatingTracker else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a tracker name."
+ return nil
+ }
+
+ isCreatingTracker = true
+ error = nil
+ defer { isCreatingTracker = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createTrackerMutation,
+ variables: variables,
+ responseType: CreateTrackerResponse.self
+ )
+ let tracker = result.createTracker
+ trackers.insert(tracker, at: 0)
+ return tracker
+ } catch {
+ self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TrackersPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: TrackersResponse.self
+ )
+ return result.trackers
+ }
+
+ private struct CreateTrackerResponse: Decodable, Sendable {
+ let createTracker: TrackerSummary
+ }
+}