diff options
Diffstat (limited to 'Hutch/Views/Repositories')
22 files changed, 5729 insertions, 202 deletions
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift new file mode 100644 index 0000000..ef3b972 --- /dev/null +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct ArtifactsView: View { + let viewModel: RepositoryDetailViewModel + @Environment(\.openURL) private var openURL + + var body: some View { + List { + ForEach(viewModel.referenceArtifacts) { refArtifacts in + Section(refArtifacts.name) { + ForEach(refArtifacts.artifacts) { artifact in + ArtifactRow(artifact: artifact) { + openURL(artifact.url) + } + } + } + } + } + .listStyle(.insetGrouped) + .overlay { + if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { + SRHTLoadingStateView(message: "Loading artifacts…") + } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Artifacts", + message: error, + retryAction: { await viewModel.loadArtifacts() } + ) + } else if viewModel.referenceArtifacts.isEmpty { + ContentUnavailableView( + "No Artifacts", + systemImage: "archivebox", + description: Text("This repository has no release artifacts.") + ) + } + } + .task { + if viewModel.referenceArtifacts.isEmpty { + await viewModel.loadArtifacts() + } + } + .refreshable { + await viewModel.loadArtifacts() + } + } +} + +private struct ArtifactRow: View { + let artifact: ArtifactInfo + let onDownload: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(artifact.filename) + .font(.subheadline) + + Text(artifact.size.formattedByteCount) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + onDownload() + } label: { + Image(systemName: "arrow.down.circle") + .imageScale(.large) + } + } + } +} diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift new file mode 100644 index 0000000..d303542 --- /dev/null +++ b/Hutch/Views/Repositories/CommitDetailView.swift @@ -0,0 +1,332 @@ +import SwiftUI + +struct CommitDetailView: View { + let commitSummary: CommitSummary + let repository: RepositorySummary + + @Environment(AppState.self) private var appState + @State private var viewModel: CommitDetailViewModel? + + var body: some View { + Group { + if let viewModel { + commitContent(viewModel) + } else { + ProgressView() + } + } + .navigationTitle(commitSummary.shortId) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { + Image(systemName: "square.and.arrow.up") + } + } + } + .task { + if viewModel == nil { + let vm = CommitDetailViewModel( + repositoryRid: repository.rid, + service: repository.service, + commitId: commitSummary.id, + client: appState.client + ) + viewModel = vm + await vm.loadCommit() + } + } + } + + @ViewBuilder + private func commitContent(_ viewModel: CommitDetailViewModel) -> some View { + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + ContentUnavailableView { + Label("Error", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { + Task { await viewModel.loadCommit() } + } + } + } else if let commit = viewModel.commit { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + // Header + commitHeader(commit) + + sectionDivider + + // Message + commitMessage(commit) + + // Trailers + if !commit.trailers.isEmpty { + sectionDivider + trailersSection(commit.trailers) + } + + // Parents + if !commit.parents.isEmpty { + sectionDivider + parentsSection(commit.parents) + } + + // Diff + if let diff = commit.diff, !diff.isEmpty { + sectionDivider + diffSection(diff) + } + + // Tree + if let tree = commit.tree, !tree.entries.results.isEmpty { + sectionDivider + treeSection(tree.entries.results) + } + } + } + .navigationDestination(for: ParentCommit.self) { parent in + CommitDetailView( + commitSummary: CommitSummary( + id: parent.id, + shortId: parent.shortId, + author: CommitAuthor(name: parent.author.name, email: nil, time: .now), + message: "" + ), + repository: repository + ) + } + } + } + + // MARK: - Header + + @ViewBuilder + private func commitHeader(_ commit: CommitDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Full hash — tappable to copy + Button { + UIPasteboard.general.string = commit.id + } label: { + HStack(spacing: 4) { + Text(commit.id) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "doc.on.doc") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + + // Author + HStack { + Label(commit.author.name, systemImage: "person") + Spacer() + Text(commit.author.time.relativeDescription) + .foregroundStyle(.secondary) + } + .font(.subheadline) + + // Committer (if different from author) + if commit.committer.name != commit.author.name + || commit.committer.email != commit.author.email { + HStack { + Label(commit.committer.name, systemImage: "person.badge.shield.checkmark") + Spacer() + Text(commit.committer.time.relativeDescription) + .foregroundStyle(.secondary) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding() + } + + // MARK: - Message + + @ViewBuilder + private func commitMessage(_ commit: CommitDetail) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Message") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Text(commit.title) + .font(.headline) + + if let body = commit.body { + Text(body) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Trailers + + @ViewBuilder + private func trailersSection(_ trailers: [CommitTrailer]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Trailers") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(trailers) { trailer in + HStack(alignment: .top, spacing: 4) { + Text("\(trailer.name):") + .font(.subheadline.monospaced().weight(.medium)) + Text(trailer.value) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Parents + + @ViewBuilder + private func parentsSection(_ parents: [ParentCommit]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Parents") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(parents) { parent in + NavigationLink(value: parent) { + HStack { + Text(parent.shortId) + .font(.subheadline.monospaced()) + Text(parent.author.name) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Diff + + @ViewBuilder + private func diffSection(_ diff: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Diff") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal) + .padding(.top) + + DiffView(diff: invertDiff(diff)) + .padding(.bottom) + } + } + + /// The sr.ht API returns diffs comparing current→parent (inverted). + /// This swaps +/- prefixes so the diff reads as parent→current. + private func invertDiff(_ diff: String) -> String { + diff.split(separator: "\n", omittingEmptySubsequences: false) + .map { line in + let s = String(line) + if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") { + return s + } + if s.hasPrefix("---") { + return "+++" + s.dropFirst(3) + } + if s.hasPrefix("+++") { + return "---" + s.dropFirst(3) + } + if s.hasPrefix("+") { + return "-" + s.dropFirst(1) + } + if s.hasPrefix("-") { + return "+" + s.dropFirst(1) + } + return s + } + .joined(separator: "\n") + } + + // MARK: - Tree + + @ViewBuilder + private func treeSection(_ entries: [CommitTreeEntry]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tree") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(entries) { entry in + HStack(spacing: 8) { + Image(systemName: treeEntryIcon(for: entry)) + .foregroundStyle(treeEntryColor(for: entry)) + .frame(width: 20) + Text(entry.name) + .font(.subheadline.monospaced()) + Spacer() + if let obj = entry.object, let shortId = obj.shortId { + Text(shortId) + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Helpers + + private var sectionDivider: some View { + Divider().padding(.horizontal) + } + + private func treeEntryIcon(for entry: CommitTreeEntry) -> String { + guard let type = entry.object?.type else { + return "doc" + } + switch type { + case "tree": return "folder" + case "blob": return "doc.text" + case "tag": return "tag" + case "commit": return "arrow.triangle.branch" + default: return "doc" + } + } + + private func treeEntryColor(for entry: CommitTreeEntry) -> Color { + guard let type = entry.object?.type else { + return .secondary + } + switch type { + case "tree": return .blue + case "blob": return .secondary + case "tag": return .orange + case "commit": return .purple + default: return .secondary + } + } +} diff --git a/Hutch/Views/Repositories/CommitDetailViewModel.swift b/Hutch/Views/Repositories/CommitDetailViewModel.swift new file mode 100644 index 0000000..5a8a23d --- /dev/null +++ b/Hutch/Views/Repositories/CommitDetailViewModel.swift @@ -0,0 +1,102 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct CommitResponse: Decodable, Sendable { + let repository: CommitRepository? +} + +private struct CommitRepository: Decodable, Sendable { + // swiftlint:disable:next identifier_name + let revparse_single: CommitDetail +} + +// MARK: - View Model + +@Observable +@MainActor +final class CommitDetailViewModel { + + let repositoryRid: String + let service: SRHTService + private let client: SRHTClient + + private(set) var commit: CommitDetail? + private(set) var isLoading = false + var error: String? + + init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) { + self.repositoryRid = repositoryRid + self.service = service + self.commitId = commitId + self.client = client + } + + private let commitId: String + + // MARK: - Query + + private static let query = """ + query commit($rid: ID!, $id: String!) { + repository(rid: $rid) { + revparse_single(revspec: $id) { + id + shortId + author { name email time } + committer { name email time } + message + diff + trailers { name value } + parents { id shortId author { name } } + tree { + entries { + results { id name mode object { type id shortId } } + cursor + } + } + } + } + } + """ + + func loadCommit() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await executeWithRetry() + commit = result.repository?.revparse_single + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + /// Execute the commit query, retrying once after a 1-second delay on 502/503. + private func executeWithRetry() async throws -> CommitResponse { + do { + return try await client.execute( + service: service, + query: Self.query, + variables: [ + "rid": repositoryRid, + "id": commitId + ], + responseType: CommitResponse.self + ) + } catch let SRHTError.httpError(code) where code == 502 || code == 503 { + try await Task.sleep(for: .seconds(1)) + return try await client.execute( + service: service, + query: Self.query, + variables: [ + "rid": repositoryRid, + "id": commitId + ], + responseType: CommitResponse.self + ) + } + } +} diff --git a/Hutch/Views/Repositories/CommitLogView.swift b/Hutch/Views/Repositories/CommitLogView.swift new file mode 100644 index 0000000..d63c5eb --- /dev/null +++ b/Hutch/Views/Repositories/CommitLogView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CommitLogView: View { + let viewModel: RepositoryDetailViewModel + + var body: some View { + List { + ForEach(viewModel.commits) { commit in + NavigationLink(value: commit) { + CommitRowView(commit: commit) + } + .task { + await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) + } + } + + if viewModel.isLoadingMoreCommits { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoadingCommits, viewModel.commits.isEmpty { + SRHTLoadingStateView(message: "Loading commits…") + } else if let error = viewModel.error, viewModel.commits.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Commits", + message: error, + retryAction: { await viewModel.loadCommits() } + ) + } else if viewModel.commits.isEmpty { + ContentUnavailableView( + "No Commits", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository has no commit history.") + ) + } + } + .task { + if viewModel.commits.isEmpty { + await viewModel.loadCommits() + } + } + .refreshable { + await viewModel.loadCommits() + } + .navigationDestination(for: CommitSummary.self) { commit in + CommitDetailView( + commitSummary: commit, + repository: viewModel.repository + ) + } + } +} diff --git a/Hutch/Views/Repositories/CommitRowView.swift b/Hutch/Views/Repositories/CommitRowView.swift new file mode 100644 index 0000000..b5eb31d --- /dev/null +++ b/Hutch/Views/Repositories/CommitRowView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct CommitRowView: View { + let commit: CommitSummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(commit.title) + .font(.subheadline) + .lineLimit(1) + + HStack(spacing: 8) { + Text(commit.shortId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + + Text(commit.author.name) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Text(commit.author.time.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift new file mode 100644 index 0000000..b1db464 --- /dev/null +++ b/Hutch/Views/Repositories/DiffView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Renders a unified diff string with syntax highlighting: +/// - Green background for added lines (+) +/// - Red background for removed lines (-) +/// - Gray for hunk headers (@@) +/// - File headers (--- / +++ / diff) in bold +struct DiffView: View { + let diff: String + + var body: some View { + let lines = diff.components(separatedBy: "\n") + + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + DiffLineView(line: line) + } + } + .font(.caption.monospaced()) + } +} + +private struct DiffLineView: View { + let line: String + + var body: some View { + Text(line.isEmpty ? " " : line) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .fontWeight(isHeader ? .semibold : .regular) + } + + private var kind: DiffLineKind { + if line.hasPrefix("@@") { return .hunk } + if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader } + if line.hasPrefix("diff ") { return .fileHeader } + if line.hasPrefix("index ") { return .meta } + if line.hasPrefix("+") { return .added } + if line.hasPrefix("-") { return .removed } + return .context + } + + private var backgroundColor: Color { + switch kind { + case .added: .green.opacity(0.15) + case .removed: .red.opacity(0.15) + case .hunk: .gray.opacity(0.12) + case .fileHeader: .gray.opacity(0.08) + case .meta: .gray.opacity(0.05) + case .context: .clear + } + } + + private var foregroundColor: Color { + switch kind { + case .added: .green + case .removed: .red + case .hunk: .secondary + case .meta: .secondary + default: .primary + } + } + + private var isHeader: Bool { + kind == .fileHeader + } +} + +private enum DiffLineKind { + case added + case removed + case hunk + case fileHeader + case meta + case context +} diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift new file mode 100644 index 0000000..3750adb --- /dev/null +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +struct FileTreeView: View { + let repository: RepositorySummary + let client: SRHTClient + + @State private var viewModel: FileTreeViewModel? + + var body: some View { + Group { + if let viewModel { + FileTreeContentView(repository: repository, viewModel: viewModel) + } else { + SRHTLoadingStateView(message: "Loading files…") + } + } + .task { + if viewModel == nil { + let vm = FileTreeViewModel( + repositoryRid: repository.rid, + service: repository.service, + client: client + ) + viewModel = vm + async let loadTree: () = vm.loadRootTree() + async let loadRefs: () = vm.loadReferences() + _ = await (loadTree, loadRefs) + } + } + } +} + +// MARK: - Content View + +private struct FileTreeContentView: View { + let repository: RepositorySummary + let viewModel: FileTreeViewModel + + @State private var showRefPicker = false + + var body: some View { + VStack(spacing: 0) { + breadcrumbBar + Divider() + contentArea + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showRefPicker = true + } label: { + Label( + revspecLabel, + systemImage: "arrow.triangle.branch" + ) + .font(.subheadline) + } + } + } + .sheet(isPresented: $showRefPicker) { + RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker) + } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) + .refreshable { + await viewModel.loadRootTree() + } + } + + private var shareURL: URL? { + guard let viewingEntry = viewModel.viewingEntry else { return nil } + return SRHTWebURL.file( + repository: repository, + revspec: viewModel.revspec, + path: currentFilePath(for: viewingEntry) + ) + } + + private var revspecLabel: String { + let revspec = viewModel.revspec + if revspec == "HEAD" { + return "HEAD" + } + if revspec.hasPrefix("refs/heads/") { + return String(revspec.dropFirst("refs/heads/".count)) + } else if revspec.hasPrefix("refs/tags/") { + return String(revspec.dropFirst("refs/tags/".count)) + } + return revspec + } + + // MARK: - Breadcrumb Bar + + private var breadcrumbBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + HStack(spacing: 4) { + ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in + if index > 0 { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Button { + Task { + await viewModel.navigateToBreadcrumb(at: index) + } + } label: { + Text(navEntry.name) + .font(.subheadline.monospaced()) + .foregroundStyle( + index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil + ? .primary : .secondary + ) + } + .buttonStyle(.plain) + } + + if let viewing = viewModel.viewingEntry { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + + Text(viewing.name) + .font(.subheadline.monospaced()) + .foregroundStyle(.primary) + } + } + + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(.bar) + } + + private func currentFilePath(for entry: TreeEntry) -> String { + let directoryComponents = viewModel.navStack + .dropFirst() + .map(\.name) + return (directoryComponents + [entry.name]).joined(separator: "/") + } + + // MARK: - Content Area + + @ViewBuilder + private var contentArea: some View { + if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil { + SRHTLoadingStateView(message: "Loading files…") + } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject { + // Viewing a file + fileContentView(entry: entry, object: object) + } else if let error = viewModel.error, viewModel.entries.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Files", + message: error, + retryAction: { await viewModel.loadRootTree() } + ) + } else if !viewModel.entries.isEmpty { + // Viewing a directory listing + treeListView + } else if viewModel.navStack.isEmpty { + ContentUnavailableView( + "No Files", + systemImage: "folder", + description: Text("This repository could not be loaded.") + ) + } else { + ContentUnavailableView( + "Empty Directory", + systemImage: "folder", + description: Text("This directory has no files.") + ) + } + } + + // MARK: - File Content View + + @ViewBuilder + private func fileContentView(entry: TreeEntry, object: GitObject) -> some View { + switch object { + case .textBlob(let blob): + textBlobView(entry: entry, blob: blob) + case .binaryBlob(let blob): + binaryBlobView(entry: entry, blob: blob) + default: + ContentUnavailableView( + "Unknown Object", + systemImage: "questionmark.folder", + description: Text("Cannot display this object type.") + ) + } + } + + // MARK: - Tree List + + private var treeListView: some View { + let sorted = viewModel.entries.sorted { a, b in + let aIsTree = a.object?.isTree == true + let bIsTree = b.object?.isTree == true + if aIsTree != bIsTree { return aIsTree } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } + + return List(sorted) { entry in + TreeEntryRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + Task { + await viewModel.navigateInto(entry: entry) + } + } + } + .listStyle(.plain) + } + + // MARK: - Text Blob + + @ViewBuilder + private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View { + VStack(spacing: 0) { + HStack { + Spacer() + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.top, 12) + + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + Text(blob.text) + .font(.system(.body, design: .monospaced)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading) + .padding() + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + } + + // MARK: - Binary Blob + + @ViewBuilder + private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "doc.zipper") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + + Text(entry.name) + .font(.headline) + + if let size = blob.size { + Text(formatBytes(size)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Text("Binary file — cannot be displayed inline.") + .font(.subheadline) + .foregroundStyle(.tertiary) + + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + + if let content = blob.content, let url = URL(string: content) { + Link(destination: url) { + Label("Open in Safari", systemImage: "safari") + } + .buttonStyle(.borderedProminent) + } + + Button { + viewModel.dismissFileView() + } label: { + Text("Back to directory") + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Helpers + + private func formatBytes(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +// MARK: - Tree Entry Row + +private struct TreeEntryRow: View { + let entry: TreeEntry + + var body: some View { + Label { + Text(entry.name) + .font(.body.monospaced()) + .lineLimit(1) + } icon: { + Image(systemName: iconName) + .foregroundStyle(iconColor) + } + } + + private var iconName: String { + switch entry.object { + case .tree: "folder.fill" + case .unknown: "questionmark.circle" + default: "doc" + } + } + + private var iconColor: Color { + switch entry.object { + case .tree: .blue + case .unknown: .orange + default: .secondary + } + } +} + +// MARK: - Ref Picker Sheet + +private struct RefPickerSheet: View { + let viewModel: FileTreeViewModel + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List { + Section { + Button { + Task { + await viewModel.changeRevspec("HEAD") + isPresented = false + } + } label: { + refRow( + title: "HEAD", + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == "HEAD" + ) + } + .buttonStyle(.plain) + } + + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""), + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags, id: \.name) { ref in + Button { + Task { + await viewModel.changeRevspec(ref.name) + isPresented = false + } + } label: { + refRow( + title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""), + systemImage: "tag", + color: .orange, + isSelected: viewModel.revspec == ref.name + ) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Ref") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + } + .overlay { + if viewModel.isLoadingRefs { + SRHTLoadingStateView(message: "Loading references…") + } + } + } + } + + private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(color) + + Text(title) + .font(.body.monospaced()) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } +} diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift new file mode 100644 index 0000000..f270779 --- /dev/null +++ b/Hutch/Views/Repositories/FileTreeViewModel.swift @@ -0,0 +1,524 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct RevparseResponse: Decodable, Sendable { + let repository: RevparseRepository? +} + +private struct RevparseRepository: Decodable, Sendable { + let revparse_single: RevparseCommit? +} + +private struct RevparseCommit: Decodable, Sendable { + let tree: GitTree? +} + +private struct SubtreeResponse: Decodable, Sendable { + let repository: SubtreeRepository? +} + +private struct SubtreeRepository: Decodable, Sendable { + let object: SubtreeObject? +} + +/// Dedicated decoding struct for the subtree query response. +/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment. +private struct SubtreeObject: Decodable, Sendable { + let entries: GitTreeEntryPage? +} + +private struct BlobResponse: Decodable, Sendable { + let repository: BlobRepository? +} + +private struct BlobRepository: Decodable, Sendable { + let object: GitObject? +} + +// MARK: - Navigation Stack Entry + +struct FileNavEntry: Hashable { + let name: String + let treeId: String +} + +// MARK: - View Model + +@Observable +@MainActor +final class FileTreeViewModel { + + let repositoryRid: String + let service: SRHTService + private let client: SRHTClient + + /// The current revspec. "HEAD" by default, or a full ref name. + var revspec: String = "HEAD" + + /// Navigation stack: each entry is a (name, treeId) pair. + /// The first entry is always the root. + private(set) var navStack: [FileNavEntry] = [] + + /// The tree entries at the current directory level. + private(set) var entries: [TreeEntry] = [] + + /// When viewing a file (text blob or binary blob), this holds the object. + private(set) var viewingEntry: TreeEntry? + private(set) var viewingObject: GitObject? + + private(set) var isLoading = false + var error: String? + + // Available references for the branch/tag picker + private(set) var branches: [Reference] = [] + private(set) var tags: [Reference] = [] + private(set) var isLoadingRefs = false + + init(repositoryRid: String, service: SRHTService, client: SRHTClient) { + self.repositoryRid = repositoryRid + self.service = service + self.client = client + } + + // MARK: - Queries + + private static let rootTreeQuery = """ + query files($rid: ID!, $revspec: String!) { + repository(rid: $rid) { + revparse_single(revspec: $revspec) { + tree { + id + entries { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + /// Query to fetch additional pages of tree entries by tree ID + cursor. + private static let treeEntriesPageQuery = """ + query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) { + repository(rid: $rid) { + object(id: $treeId) { + type + id + ... on Tree { + entries(cursor: $cursor) { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + private static let subtreeQuery = """ + query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) { + repository(rid: $rid) { + object(id: $treeId) { + type + id + ... on Tree { + entries(cursor: $cursor) { + results { + id + name + mode + object { + type + id + shortId + ... on Tree { + entries { + results { + id + name + mode + object { type id shortId } + } + cursor + } + } + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + cursor + } + } + } + } + } + """ + + private static let blobQuery = """ + query blob($rid: ID!, $blobId: String!) { + repository(rid: $rid) { + object(id: $blobId) { + type + id + ... on TextBlob { + text + size + } + ... on BinaryBlob { + size + content + } + } + } + } + """ + + private static let refsQuery = """ + query refs($rid: ID!) { + repository(rid: $rid) { + references { + results { name target } + cursor + } + } + } + """ + + // MARK: - Load Root Tree + + func loadRootTree() async { + guard !isLoading else { return } + isLoading = true + error = nil + viewingEntry = nil + viewingObject = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "revspec": revspec + ] + + do { + let result: RevparseResponse + do { + result = try await client.execute( + service: service, + query: Self.rootTreeQuery, + variables: variables, + responseType: RevparseResponse.self + ) + } catch { + if isMissingGitReferenceError(error) { + navStack = [FileNavEntry(name: "root", treeId: "")] + entries = [] + return + } + throw error + } + if let tree = result.repository?.revparse_single?.tree, + let rootId = tree.id { + navStack = [FileNavEntry(name: "root", treeId: rootId)] + var allEntries = tree.entries?.results ?? [] + var cursor = tree.entries?.cursor + // Follow cursor pagination for remaining pages + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } else { + navStack = [] + entries = [] + } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Navigate Into Folder + + func navigateInto(entry: TreeEntry) async { + guard let object = entry.object else { return } + + switch object { + case .tree(let tree): + // Use the git object SHA from entry.object, NOT entry.id + guard let objectSHA = tree.id else { return } + // If we already have the entries inline and no further pages, use them directly + if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil { + navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA)) + entries = inlineEntries + viewingEntry = nil + viewingObject = nil + return + } + // Otherwise fetch the subtree (handles pagination) + await loadSubtree(name: entry.name, treeId: objectSHA) + + case .textBlob: + viewingEntry = entry + viewingObject = object + + case .binaryBlob(let blob): + if blob.content != nil || blob.size != nil { + viewingEntry = entry + viewingObject = object + } else if let blobId = blob.id { + await loadBlob(entry: entry, blobId: blobId) + } + + case .unknown: + break + } + } + + private func loadSubtree(name: String, treeId: String) async { + guard !isLoading else { return } + isLoading = true + error = nil + viewingEntry = nil + viewingObject = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": treeId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.subtreeQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + navStack.append(FileNavEntry(name: name, treeId: treeId)) + var allEntries = result.repository?.object?.entries?.results ?? [] + var cursor = result.repository?.object?.entries?.cursor + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } catch { + self.error = error.localizedDescription + } + } + + private func loadBlob(entry: TreeEntry, blobId: String) async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "blobId": blobId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.blobQuery, + variables: variables, + responseType: BlobResponse.self + ) + viewingEntry = entry + viewingObject = result.repository?.object ?? .unknown + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Navigate to Breadcrumb + + func navigateToBreadcrumb(at index: Int) async { + guard index >= 0, index < navStack.count else { return } + + // If tapping current level, do nothing + if index == navStack.count - 1, viewingEntry == nil { + return + } + + // Clear file view + viewingEntry = nil + viewingObject = nil + + // Trim the stack + let targetEntry = navStack[index] + navStack = Array(navStack.prefix(index + 1)) + + if index == 0 { + // Go back to root — reload from revparse_single + await loadRootTree() + } else { + // Load the subtree at this level + isLoading = true + error = nil + defer { isLoading = false } + + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": targetEntry.treeId + ] + + do { + let result = try await client.execute( + service: service, + query: Self.subtreeQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + var allEntries = result.repository?.object?.entries?.results ?? [] + var cursor = result.repository?.object?.entries?.cursor + while let nextCursor = cursor { + let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor) + allEntries.append(contentsOf: pageEntries.results) + cursor = pageEntries.cursor + } + entries = allEntries + } catch { + self.error = error.localizedDescription + } + } + } + + /// Fetch a single page of tree entries by tree ID and cursor. + private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage { + let variables: [String: any Sendable] = [ + "rid": repositoryRid, + "treeId": treeId, + "cursor": cursor + ] + let result = try await client.execute( + service: service, + query: Self.treeEntriesPageQuery, + variables: variables, + responseType: SubtreeResponse.self + ) + return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) + } + + /// Dismiss the file view and go back to the directory listing. + func dismissFileView() { + viewingEntry = nil + viewingObject = nil + } + + // MARK: - Change Revspec + + func changeRevspec(_ newRevspec: String) async { + revspec = newRevspec + await loadRootTree() + } + + // MARK: - Load References + + func loadReferences() async { + guard !isLoadingRefs else { return } + isLoadingRefs = true + + do { + let result = try await client.execute( + service: service, + query: Self.refsQuery, + variables: ["rid": repositoryRid], + responseType: RefsResponseLocal.self + ) + let allRefs = result.repository?.references.results ?? [] + branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } + tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } + } catch { + // Silently fail for refs — non-critical + } + + isLoadingRefs = false + } + + private func isMissingGitReferenceError(_ error: Error) -> Bool { + guard let srhtError = error as? SRHTError else { return false } + guard case .graphQLErrors(let errors) = srhtError else { return false } + return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } + } +} + +// File-private refs response to avoid collision with RepositoryDetailViewModel's private type +private struct RefsResponseLocal: Decodable, Sendable { + let repository: RefsRepoLocal? +} + +private struct RefsRepoLocal: Decodable, Sendable { + let references: RefsPageLocal +} + +private struct RefsPageLocal: Decodable, Sendable { + let results: [Reference] + let cursor: String? +} diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift index 8e47d33..6779165 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift @@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View { @State private var viewModel: HgRepositoryDetailViewModel? @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary @State private var showSettings = false + @State private var isShowingRepositoryDetails = false + @State private var showBrowseRefPicker = false + + private var shareURL: URL? { + guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } + return SRHTWebURL.file( + repository: repository, + revspec: viewModel.browseRevspec, + path: selectedFilePath + ) + } var body: some View { Group { if let viewModel { content(viewModel) } else { - ProgressView() + SRHTLoadingStateView(message: "Loading repository…") } } .navigationTitle(repository.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { + if selectedTab == .browse, let viewModel { + Button { + showBrowseRefPicker = true + } label: { + Label( + browseRevspecLabel(viewModel.browseRevspec), + systemImage: "arrow.triangle.branch" + ) + .font(.subheadline) + } + } + + SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { + Image(systemName: "square.and.arrow.up") + } + Button { showSettings = true } label: { @@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View { } ) } + .sheet(isPresented: $showBrowseRefPicker) { + if let viewModel { + HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker) + } + } .task { if viewModel == nil { let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client) @@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View { revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.") } } - .alert("Error", isPresented: .constant(viewModel.error != nil)) { - Button("OK") { viewModel.error = nil } - } message: { - if let error = viewModel.error { - Text(error) - } - } + .srhtErrorBanner(error: Binding( + get: { viewModel.error }, + set: { viewModel.error = $0 } + )) } @ViewBuilder private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View { - if viewModel.isLoadingSummary && !viewModel.summaryLoaded { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - summaryCards(viewModel) - - if let readmeView = readmeContentView(viewModel) { - readmeView - } else { - ContentUnavailableView( - "No README", - systemImage: "doc.text", - description: Text("This repository does not have a README file.") - ) - } - } - .padding() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + metadataSection(viewModel) + repositoryDetailsSection(viewModel) + latestChangeSection(viewModel) + readmeSection(viewModel) } - .refreshable { - await viewModel.loadSummary() + .padding() + } + .overlay { + if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { + SRHTLoadingStateView(message: "Loading repository…") + } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { + SRHTErrorStateView( + title: "Couldn't Load Repository", + message: error, + retryAction: { await viewModel.loadSummary() } + ) } } + .refreshable { + await viewModel.loadSummary() + } } - private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View { - VStack(alignment: .leading, spacing: 12) { - LabeledContent("Visibility", value: visibilityLabel(repository.visibility)) - LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") - + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(repository.owner.canonicalName) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(repository.name) + .font(.largeTitle.weight(.semibold)) if let description = repository.description, !description.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("Description") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Text(description) - } + Text(description) + .font(.body) } + } + } - if let tip = viewModel.tip { - VStack(alignment: .leading, spacing: 6) { - Text("Tip") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Text(tip.title) - .font(.headline) - HStack { - Text(tip.displayShortId) - .font(.caption.monospaced()) - Spacer() - Text(tip.author.time.relativeDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - Text(tip.author.name) - .font(.subheadline) - .foregroundStyle(.secondary) - } + @ViewBuilder + private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: 10) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) + ) + + if let readmePath = viewModel.readmePath { + SummaryMetadataRow( + icon: "doc.text", + title: readmePath + ) } } - .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { + VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) + SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") + SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true) + SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true) + SummaryDetailRow(label: "RID", value: repository.rid, monospace: true) + } + .padding(.top, 8) + } label: { + Text("Repository Details") + .font(.subheadline.weight(.medium)) + } + } + + @ViewBuilder + private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isLoadingSummary && viewModel.tip == nil { + SRHTLoadingStateView(message: "Loading latest change…") + .frame(maxWidth: .infinity) + } else if let tip = viewModel.tip { + SummaryMetadataRow( + icon: "arrow.trianglehead.clockwise", + title: tip.title, + subtitle: "\(tip.displayShortId) — \(tip.author)" + ) + } else if let error = viewModel.error, !viewModel.summaryLoaded { + SRHTErrorStateView( + title: "Couldn't Load Latest Change", + message: error, + retryAction: { await viewModel.loadSummary() } + ) + } else { + ContentUnavailableView( + "No Recent Revisions", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository does not have any revision history yet.") + ) + } + } + } + + @ViewBuilder + private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { + if viewModel.isLoadingSummary && !viewModel.summaryLoaded { + SRHTLoadingStateView(message: "Loading README…") + } else if let readmeView = readmeContentView(viewModel) { + readmeView + } else if let error = viewModel.error, !viewModel.summaryLoaded { + SRHTErrorStateView( + title: "Couldn't Load README", + message: error, + retryAction: { await viewModel.loadSummary() } + ) + } else { + ContentUnavailableView( + "No README", + systemImage: "doc.text", + description: Text("This repository does not have a README file.") + ) + } } @ViewBuilder @@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View { browseBreadcrumbs(viewModel) Divider() - if viewModel.isLoadingBrowse { - Spacer() - ProgressView() - Spacer() + if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil { + SRHTLoadingStateView(message: "Loading files…") } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent { - GeometryReader { geometry in - ScrollView([.vertical, .horizontal]) { - Text(fileContent) - .font(.system(.body, design: .monospaced)) - .frame( - minWidth: geometry.size.width, - minHeight: geometry.size.height, - alignment: .topLeading - ) - .padding() + VStack(spacing: 0) { + HStack { + Spacer() + SRHTShareButton(url: shareURL, target: .file) { + Label("Share File", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.top, 12) + + GeometryReader { geometry in + ScrollView([.vertical, .horizontal]) { + Text(fileContent) + .font(.system(.body, design: .monospaced)) + .frame( + minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading + ) + .padding() + } } } .safeAreaInset(edge: .bottom) { @@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View { .background(.bar) } .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name) + } else if let error = viewModel.error, viewModel.files.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Files", + message: error, + retryAction: { await viewModel.loadBrowseRoot() } + ) } else if viewModel.files.isEmpty { ContentUnavailableView( "No Files", @@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View { ) } else { List(viewModel.files) { file in - Label { - Text(displayFileName(file.name)) - .font(.body.monospaced()) - } icon: { - Image(systemName: file.isDirectory ? "folder.fill" : "doc") - .foregroundStyle(file.isDirectory ? .blue : .secondary) - } + HgFileRow(file: file) .contentShape(Rectangle()) .onTapGesture { Task { await viewModel.openFile(file) } @@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View { } .listStyle(.plain) .overlay { - if viewModel.isLoadingLog { - ProgressView() + if viewModel.isLoadingLog, viewModel.log.isEmpty { + SRHTLoadingStateView(message: "Loading revisions…") + } else if let error = viewModel.error, viewModel.log.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Revisions", + message: error, + retryAction: { await viewModel.loadLog() } + ) } else if viewModel.log.isEmpty { ContentUnavailableView( "No Revisions", @@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View { } @ViewBuilder - private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View { + private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View { if revisions.isEmpty { ContentUnavailableView( emptyTitle, @@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View { ) } else { List(revisions) { revision in - revisionRow(revision) + namedRevisionRow(revision) } .listStyle(.plain) } @@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View { } HStack { - Text(revision.author.name) - Spacer() - Text(revision.author.time.relativeDescription) + Text(revision.author) } .font(.caption) .foregroundStyle(.secondary) @@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View { .padding(.vertical, 4) } - @ViewBuilder - private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { - let imageURLResolver = makeImageURLResolver(viewModel) + private func namedRevisionRow(_ revision: HgNamedRevision) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(revision.name) + .font(.headline) + Spacer() + Text(revision.displayShortId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + } + private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { guard let content = viewModel.readmeContent else { return nil } + return AnyView( + RenderedMarkupContentView( + content: sharedReadmeContent(from: content), + readmePath: viewModel.readmePath, + colorScheme: colorScheme, + ownerCanonicalName: repository.owner.canonicalName, + repositoryName: repository.name, + repositoryHost: "hg.sr.ht" + ) + ) + } + + private func browseRevspecLabel(_ revspec: String) -> String { + if revspec == "tip" { + return "tip" + } + return revspec + } + + private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { switch content { case .html(let html): - return AnyView( - HTMLWebView(html: html, colorScheme: colorScheme) - .frame(minHeight: 400) - ) + .html(html) case .markdown(let text): - return AnyView( - HTMLWebView( - html: markdownToHTML(text, imageURLResolver: imageURLResolver), - colorScheme: colorScheme - ) - .frame(minHeight: 400) - ) + .markdown(text) case .org(let text): - return AnyView( - HTMLWebView( - html: orgToHTML(text, imageURLResolver: imageURLResolver), - colorScheme: colorScheme - ) - .frame(minHeight: 400) - ) + .org(text) case .plainText(let text): - return AnyView( - Text(text) - .font(.system(.body, design: .monospaced)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - ) + .plainText(text) } } - private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? { - let owner = repository.owner.canonicalName - let repositoryName = repository.name - let readmePath = viewModel.readmePath - - return { source in - resolveRepositoryAssetURL( - source, - owner: owner, - repositoryName: repositoryName, - readmePath: readmePath - )? - .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht") + private func displayFileName(_ name: String) -> String { + name.hasSuffix("/") ? String(name.dropLast()) : name + } + +} + +private struct HgBrowseRefPickerSheet: View { + let viewModel: HgRepositoryDetailViewModel + @Binding var isPresented: Bool + + var body: some View { + NavigationStack { + List { + Section { + Button { + Task { + await viewModel.changeBrowseRevspec("tip") + isPresented = false + } + } label: { + refRow( + title: "tip", + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.browseRevspec == "tip" + ) + } + .buttonStyle(.plain) + } + + if !viewModel.branches.isEmpty { + Section("Branches") { + ForEach(viewModel.branches) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "arrow.triangle.branch", + color: .blue, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.tags.isEmpty { + Section("Tags") { + ForEach(viewModel.tags) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "tag", + color: .orange, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + + if !viewModel.bookmarks.isEmpty { + Section("Bookmarks") { + ForEach(viewModel.bookmarks) { revision in + Button { + Task { + await viewModel.changeBrowseRevspec(revision.name) + isPresented = false + } + } label: { + refRow( + title: revision.name, + systemImage: "bookmark", + color: .purple, + isSelected: viewModel.browseRevspec == revision.name + ) + } + .buttonStyle(.plain) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Select Ref") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isPresented = false + } + } + } } } - private func displayFileName(_ name: String) -> String { - name.hasSuffix("/") ? String(name.dropLast()) : name + private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(color) + + Text(title) + .font(.body.monospaced()) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) } +} - private func visibilityLabel(_ visibility: Visibility) -> String { - switch visibility { - case .public: - return "Public" - case .unlisted: - return "Unlisted" - case .private: - return "Private" +private struct HgFileRow: View { + let file: HgFile + + var body: some View { + Label { + Text(displayName) + .font(.body.monospaced()) + .lineLimit(1) + } icon: { + Image(systemName: file.isDirectory ? "folder.fill" : "doc") + .foregroundStyle(file.isDirectory ? .blue : .secondary) } } + + private var displayName: String { + file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name + } } diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift new file mode 100644 index 0000000..30ee3bb --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift @@ -0,0 +1,545 @@ +import Foundation + +private struct HgRepositorySummaryResponse: Decodable, Sendable { + let repository: HgRepositorySummaryPayload? +} + +private struct HgRepositorySummaryPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let description: String? + let visibility: Visibility + let readme: String? + let nonPublishing: Bool? + let tip: HgSummaryTip? + let branches: HgNamedRevisionPage? + let tags: HgNamedRevisionPage? + let bookmarks: HgNamedRevisionPage? +} + +private struct HgSummaryTip: Decodable, Sendable { + let id: String? + let author: String? + let description: String? + let branch: String? + let tags: [String]? + + var resolvedRevision: HgRevision? { + guard + let id, + let author, + let description + else { + return nil + } + + return HgRevision( + id: id, + author: author, + description: description, + branch: branch, + tags: tags + ) + } +} + +private struct HgRevisionLogResponse: Decodable, Sendable { + let repository: HgRevisionLogRepository? +} + +private struct HgRevisionLogRepository: Decodable, Sendable { + let log: HgRevisionPage? +} + +private struct HgReadmeFileResponse: Decodable, Sendable { + let repository: HgReadmeFileRepository? +} + +private struct HgReadmeFileRepository: Decodable, Sendable { + let readme: String? +} + +private struct HgFilesResponse: Decodable, Sendable { + let repository: HgFilesRepository? +} + +private struct HgFilesRepository: Decodable, Sendable { + let files: HgFilePage? +} + +private struct HgFilePage: Decodable, Sendable { + let results: [HgFile] + let cursor: String? +} + +private struct HgNamedRevisionPage: Decodable, Sendable { + let results: [HgNamedRevision] + let cursor: String? + + private enum CodingKeys: String, CodingKey { + case results + case cursor + } + + init(results: [HgNamedRevision], cursor: String?) { + self.results = results + self.cursor = cursor + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? [] + self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor) + } +} + +private struct HgCatResponse: Decodable, Sendable { + let repository: HgCatRepository? +} + +private struct HgCatRepository: Decodable, Sendable { + let cat: String? +} + +struct HgRevisionPage: Decodable, Sendable { + let results: [HgRevision] + let cursor: String? +} + +struct HgRevision: Decodable, Sendable, Identifiable, Hashable { + let id: String + let author: String + let description: String + let branch: String? + let tags: [String]? + + var displayShortId: String { + String(id.prefix(12)) + } + + var title: String { + description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: String? { + let body = description + .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) + .dropFirst() + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return body?.isEmpty == false ? body : nil + } + + var primaryName: String { + if let tag = tags?.first, !tag.isEmpty { + return tag + } + if let branch, !branch.isEmpty { + return branch + } + return displayShortId + } +} + +struct HgFile: Decodable, Sendable, Hashable, Identifiable { + let name: String + + var id: String { name } + + var isDirectory: Bool { + name.hasSuffix("/") + } +} + +struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable { + let name: String + let id: String + + var displayShortId: String { + String(id.prefix(12)) + } +} + +@Observable +@MainActor +final class HgRepositoryDetailViewModel { + enum Tab: String, CaseIterable { + case summary = "Summary" + case browse = "Browse" + case log = "Log" + case tags = "Tags" + case branches = "Branches" + case bookmarks = "Bookmarks" + } + + enum ReadmeContent { + case html(String) + case markdown(String) + case org(String) + case plainText(String) + } + + let repository: RepositorySummary + private let client: SRHTClient + + private(set) var summaryLoaded = false + private(set) var isLoadingSummary = false + private(set) var readmeContent: ReadmeContent? + private(set) var readmePath: String? + private(set) var nonPublishing = false + private(set) var tip: HgRevision? + private(set) var branches: [HgNamedRevision] = [] + private(set) var tags: [HgNamedRevision] = [] + private(set) var bookmarks: [HgNamedRevision] = [] + + private(set) var log: [HgRevision] = [] + private(set) var isLoadingLog = false + private(set) var isLoadingMoreLog = false + private var logCursor: String? + private var hasMoreLog = true + + private(set) var currentBrowsePath = "" + private(set) var pathStack: [String] = [] + private(set) var files: [HgFile] = [] + private(set) var fileContent: String? + private(set) var selectedFilePath: String? + private(set) var isLoadingBrowse = false + private(set) var browseRevspec = "tip" + + var error: String? + + init(repository: RepositorySummary, client: SRHTClient) { + self.repository = repository + self.client = client + } + + private static let summaryQuery = """ + query hgRepositorySummary($rid: ID!) { + repository(rid: $rid) { + id + rid + name + description + visibility + readme + nonPublishing + tip { + id + author + description + branch + tags + } + branches { + results { + name + id + } + cursor + } + tags { + results { + name + id + } + cursor + } + bookmarks { + results { + name + id + } + cursor + } + } + } + """ + + private static let logQuery = """ + query hgRepositoryLog($rid: ID!, $cursor: Cursor) { + repository(rid: $rid) { + log(cursor: $cursor) { + results { + id + author + description + branch + tags + } + cursor + } + } + } + """ + + private static func readmeFileQuery(filename: String) -> String { + """ + query hgReadmeFile($rid: ID!) { + repository(rid: $rid) { + readme: cat(path: "\(filename)", revspec: "tip") + } + } + """ + } + + private static let readmeFilenames = [ + "README.md", "README.org", "README.txt", "README", + "readme.md", "readme.org" + ] + + private static let filesQuery = """ + query hgFiles($rid: ID!, $path: String!, $revspec: String!) { + repository(rid: $rid) { + files(path: $path, revspec: $revspec) { + results { + name + } + cursor + } + } + } + """ + + private static let catQuery = """ + query hgCat($rid: ID!, $path: String!, $revspec: String!) { + repository(rid: $rid) { + cat(path: $path, revspec: $revspec) + } + } + """ + + func loadSummary() async { + guard !isLoadingSummary, !summaryLoaded else { return } + isLoadingSummary = true + defer { isLoadingSummary = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.summaryQuery, + variables: ["rid": repository.rid], + responseType: HgRepositorySummaryResponse.self + ) + + guard let repository = result.repository else { + summaryLoaded = true + return + } + + tip = repository.tip?.resolvedRevision + branches = repository.branches?.results ?? [] + tags = repository.tags?.results ?? [] + bookmarks = repository.bookmarks?.results ?? [] + nonPublishing = repository.nonPublishing ?? false + + if let html = repository.readme, !html.isEmpty { + readmePath = nil + readmeContent = .html(html) + } else { + await loadReadmeFile() + } + + summaryLoaded = true + } catch { + self.error = error.localizedDescription + } + } + + func loadLog() async { + guard !isLoadingLog else { return } + isLoadingLog = true + defer { isLoadingLog = false } + error = nil + logCursor = nil + hasMoreLog = true + + do { + let page = try await fetchLogPage(cursor: nil) + log = page.results + logCursor = page.cursor + hasMoreLog = page.cursor != nil + } catch { + if isEmptyRepositoryError(error) { + log = [] + logCursor = nil + hasMoreLog = false + } else { + self.error = error.localizedDescription + } + } + } + + func loadMoreLogIfNeeded(currentItem: HgRevision) async { + guard let last = log.last, + last.id == currentItem.id, + hasMoreLog, + !isLoadingMoreLog else { + return + } + + isLoadingMoreLog = true + defer { isLoadingMoreLog = false } + + do { + let page = try await fetchLogPage(cursor: logCursor) + log.append(contentsOf: page.results) + logCursor = page.cursor + hasMoreLog = page.cursor != nil + } catch { + self.error = error.localizedDescription + } + } + + private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage { + var variables: [String: any Sendable] = ["rid": repository.rid] + if let cursor { + variables["cursor"] = cursor + } + + let result = try await client.execute( + service: .hg, + query: Self.logQuery, + variables: variables, + responseType: HgRevisionLogResponse.self + ) + return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil) + } + + private func loadReadmeFile() async { + for filename in Self.readmeFilenames { + do { + let result = try await client.execute( + service: .hg, + query: Self.readmeFileQuery(filename: filename), + variables: ["rid": repository.rid], + responseType: HgReadmeFileResponse.self + ) + + if let text = result.repository?.readme, !text.isEmpty { + readmePath = filename + if filename.hasSuffix(".md") { + readmeContent = .markdown(text) + } else if filename.hasSuffix(".org") { + readmeContent = .org(text) + } else { + readmeContent = .plainText(text) + } + return + } + } catch { + if isEmptyRepositoryError(error) { + readmeContent = nil + readmePath = nil + return + } + continue + } + } + } + + func loadBrowseRoot() async { + await loadFiles(at: "") + } + + func openFile(_ file: HgFile) async { + let path = joinedPath(for: file.name) + if file.isDirectory { + await loadFiles(at: path) + return + } + + isLoadingBrowse = true + defer { isLoadingBrowse = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.catQuery, + variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], + responseType: HgCatResponse.self + ) + + if let text = result.repository?.cat { + selectedFilePath = path + fileContent = text + } else { + await loadFiles(at: path) + } + } catch { + self.error = error.localizedDescription + } + } + + func navigateToPath(index: Int) async { + guard index >= 0, index <= pathStack.count else { return } + let targetPath = Array(pathStack.prefix(index)).joined(separator: "/") + await loadFiles(at: targetPath) + } + + func dismissFileView() { + selectedFilePath = nil + fileContent = nil + } + + func changeBrowseRevspec(_ newRevspec: String) async { + guard browseRevspec != newRevspec else { return } + browseRevspec = newRevspec + await loadBrowseRoot() + } + + private func loadFiles(at path: String) async { + isLoadingBrowse = true + defer { isLoadingBrowse = false } + error = nil + selectedFilePath = nil + fileContent = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.filesQuery, + variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], + responseType: HgFilesResponse.self + ) + + currentBrowsePath = path + pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) + files = result.repository?.files?.results ?? [] + } catch { + if isEmptyRepositoryError(error) { + currentBrowsePath = path + pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) + files = [] + } else { + self.error = error.localizedDescription + } + } + } + + private func joinedPath(for name: String) -> String { + let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name + return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)" + } + + private func isEmptyRepositoryError(_ error: Error) -> Bool { + if let srhtError = error as? SRHTError, + case .graphQLErrors(let errors) = srhtError { + return errors.contains { + let message = $0.message.localizedLowercase + return message.contains("missing") + || message.contains("not found") + || message.contains("unknown revision") + || message.contains("unknown revision or path not in the working tree") + } + } + + let message = error.localizedDescription.localizedLowercase + return message.contains("missing") + || message.contains("not found") + || message.contains("unknown revision") + } +} diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift new file mode 100644 index 0000000..3894b9d --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +struct HgRepositorySettingsView: View { + let repository: RepositorySummary + let client: SRHTClient + let onDeleted: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: HgRepositorySettingsViewModel? + @State private var showDeleteConfirmation = false + @State private var pendingACLDeletion: HgACLEntry? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + settingsForm(viewModel) + } else { + SRHTLoadingStateView(message: "Loading settings…") + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { + if viewModel == nil { + let vm = HgRepositorySettingsViewModel(repository: repository, client: client) + viewModel = vm + async let info: () = vm.loadRepositoryInfo() + async let acls: () = vm.loadACLs() + _ = await (info, acls) + } + } + } + + @ViewBuilder + private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View { + @Bindable var vm = viewModel + + Form { + infoSection(viewModel) + accessSection(viewModel) + featuresSection(viewModel) + histeditSection(viewModel) + deleteSection(viewModel) + } + .srhtErrorBanner(error: $vm.error) + .alert( + "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", + isPresented: $showDeleteConfirmation + ) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + Task { + await viewModel.deleteRepository() + if viewModel.didDelete { + dismiss() + onDeleted() + } + } + } + } message: { + Text("This cannot be undone.") + } + .alert("Remove Access?", isPresented: Binding( + get: { pendingACLDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingACLDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Remove Access", role: .destructive) { + guard let entry = pendingACLDeletion else { return } + Task { + await viewModel.deleteACL(entry) + pendingACLDeletion = nil + } + } + } message: { + if let entry = pendingACLDeletion { + Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") + } + } + } + + @ViewBuilder + private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Info") { + LabeledContent("Name") { + Text(repository.name) + .font(.body.monospaced()) + } + + TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) + .lineLimit(3...6) + + Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + + Button { + Task { await viewModel.saveInfo() } + } label: { + if viewModel.isSavingInfo { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isSavingInfo) + } + } + + @ViewBuilder + private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Access") { + if viewModel.isLoadingACLs { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.acls.isEmpty { + Text("No access entries yet.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.acls) { entry in + HStack { + Text(entry.entity.canonicalName) + Spacer() + Text(entry.mode) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingACLDeletion = entry + } label: { + Label("Remove Access", systemImage: "trash") + } + } + } + } + + HStack { + TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Picker("", selection: Bindable(viewModel).newACLMode) { + Text("RO").tag("RO") + Text("RW").tag("RW") + } + .pickerStyle(.segmented) + .frame(width: 100) + + Button { + Task { await viewModel.addACL() } + } label: { + if viewModel.isAddingACL { + ProgressView() + } else { + Text("Add") + } + } + .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + Text("Add a SourceHut user and choose read-only or read/write access.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Features") { + Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing) + + Button { + Task { await viewModel.saveInfo() } + } label: { + if viewModel.isSavingInfo { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isSavingInfo) + } + } + + @ViewBuilder + private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section("Histedit") { + TextField("Revision hash", text: Bindable(viewModel).histeditRevision) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(true) + + Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.") + .font(.caption) + .foregroundStyle(.secondary) + + Button("Remove Revision", role: .destructive) {} + .disabled(true) + } + } + + @ViewBuilder + private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { + Section { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + if viewModel.isDeleting { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Delete Repository") + .frame(maxWidth: .infinity) + } + } + .disabled(viewModel.isDeleting) + } + } +} diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift new file mode 100644 index 0000000..3e431f8 --- /dev/null +++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift @@ -0,0 +1,287 @@ +import Foundation + +private struct HgUpdateRepositoryResponse: Decodable, Sendable { + let updateRepository: HgUpdatedRepository +} + +private struct HgUpdatedRepository: Decodable, Sendable { + let id: Int +} + +private struct HgRepositoryInfoResponse: Decodable, Sendable { + let repository: HgRepositoryInfo? +} + +private struct HgRepositoryInfo: Decodable, Sendable { + let description: String? + let visibility: Visibility + let nonPublishing: Bool? +} + +private struct HgACLResponse: Decodable, Sendable { + let repository: HgACLRepository? +} + +private struct HgACLRepository: Decodable, Sendable { + let accessControlList: HgACLPage +} + +private struct HgACLPage: Decodable, Sendable { + let results: [HgACLEntry] + let cursor: String? +} + +private struct HgUpdateACLResponse: Decodable, Sendable { + let updateACL: HgACLEntry +} + +private struct HgDeleteACLResponse: Decodable, Sendable { + let deleteACL: HgDeletedACL +} + +private struct HgDeletedACL: Decodable, Sendable { + let id: Int +} + +private struct HgDeleteRepositoryResponse: Decodable, Sendable { + let deleteRepository: HgDeletedRepository +} + +private struct HgDeletedRepository: Decodable, Sendable { + let id: Int +} + +struct HgACLEntry: Decodable, Sendable, Identifiable { + let id: Int + let mode: String + let entity: Entity +} + +@Observable +@MainActor +final class HgRepositorySettingsViewModel { + let repositoryId: Int + let repositoryRid: String + let repositoryName: String + private let client: SRHTClient + + var editedDescription: String + var editedVisibility: Visibility + var editedNonPublishing: Bool + var isSavingInfo = false + + private(set) var acls: [HgACLEntry] = [] + private(set) var isLoadingACLs = false + var newACLEntity = "" + var newACLMode = "RO" + var isAddingACL = false + var isDeletingACL = false + + var histeditRevision = "" + var isDeleting = false + var didDelete = false + var error: String? + + init(repository: RepositorySummary, client: SRHTClient) { + self.repositoryId = repository.id + self.repositoryRid = repository.rid + self.repositoryName = repository.name + self.client = client + self.editedDescription = repository.description ?? "" + self.editedVisibility = repository.visibility + self.editedNonPublishing = false + } + + private static let updateRepositoryMutation = """ + mutation updateRepository($id: Int!, $input: RepoInput!) { + updateRepository(id: $id, input: $input) { + id + } + } + """ + + private static let accessControlListQuery = """ + query hgAccessControlList($rid: ID!) { + repository(rid: $rid) { + accessControlList { + results { + id + mode + entity { canonicalName } + } + cursor + } + } + } + """ + + private static let updateACLMutation = """ + mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) { + updateACL(repoId: $repoId, mode: $mode, entity: $entity) { + id + mode + entity { canonicalName } + } + } + """ + + private static let deleteACLMutation = """ + mutation deleteACL($id: Int!) { + deleteACL(id: $id) { id } + } + """ + + private static let deleteRepositoryMutation = """ + mutation deleteRepository($id: Int!) { + deleteRepository(id: $id) { id } + } + """ + + private static let repositoryInfoQuery = """ + query hgRepositoryInfo($rid: ID!) { + repository(rid: $rid) { + description + visibility + nonPublishing + } + } + """ + + func loadRepositoryInfo() async { + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.repositoryInfoQuery, + variables: ["rid": repositoryRid], + responseType: HgRepositoryInfoResponse.self + ) + + if let repository = result.repository { + editedDescription = repository.description ?? "" + editedVisibility = repository.visibility + editedNonPublishing = repository.nonPublishing ?? false + } + } catch { + self.error = error.localizedDescription + } + } + + func saveInfo() async { + isSavingInfo = true + defer { isSavingInfo = false } + error = nil + + do { + let input: [String: any Sendable] = [ + "description": editedDescription, + "visibility": editedVisibility.rawValue, + "nonPublishing": editedNonPublishing + ] + _ = try await client.execute( + service: .hg, + query: Self.updateRepositoryMutation, + variables: ["id": repositoryId, "input": input], + responseType: HgUpdateRepositoryResponse.self + ) + } catch { + self.error = error.localizedDescription + } + } + + func loadACLs() async { + guard !isLoadingACLs else { return } + isLoadingACLs = true + defer { isLoadingACLs = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.accessControlListQuery, + variables: ["rid": repositoryRid], + responseType: HgACLResponse.self + ) + acls = result.repository?.accessControlList.results ?? [] + } catch { + self.error = error.localizedDescription + } + } + + func addACL() async { + let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawEntity.isEmpty else { return } + let entity = hgCanonicalEntity(from: rawEntity) + isAddingACL = true + defer { isAddingACL = false } + error = nil + + do { + let result = try await client.execute( + service: .hg, + query: Self.updateACLMutation, + variables: [ + "repoId": repositoryId, + "mode": newACLMode, + "entity": entity + ], + responseType: HgUpdateACLResponse.self + ) + if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) { + acls[index] = result.updateACL + } else { + acls.append(result.updateACL) + } + newACLEntity = "" + } catch { + let message = error.localizedDescription + if message.localizedCaseInsensitiveContains("No such repository or user found") { + self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first." + } else { + self.error = message + } + } + } + + private func hgCanonicalEntity(from input: String) -> String { + let username = input.hasPrefix("~") ? String(input.dropFirst()) : input + return "~\(username)" + } + + func deleteACL(_ entry: HgACLEntry) async { + isDeletingACL = true + defer { isDeletingACL = false } + error = nil + + do { + _ = try await client.execute( + service: .hg, + query: Self.deleteACLMutation, + variables: ["id": entry.id], + responseType: HgDeleteACLResponse.self + ) + acls.removeAll { $0.id == entry.id } + } catch { + self.error = error.localizedDescription + } + } + + func deleteRepository() async { + isDeleting = true + defer { isDeleting = false } + error = nil + + do { + _ = try await client.execute( + service: .hg, + query: Self.deleteRepositoryMutation, + variables: ["id": repositoryId], + responseType: HgDeleteRepositoryResponse.self + ) + didDelete = true + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift new file mode 100644 index 0000000..b53885e --- /dev/null +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -0,0 +1,1125 @@ +import SwiftUI +import WebKit + +struct ReadmeView: View { + let viewModel: RepositoryDetailViewModel + + @Environment(\.colorScheme) private var colorScheme + @State private var isShowingRepositoryDetails = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + metadataSection + repositoryDetailsSection + latestChangeSection + readmeSection + } + .padding() + } + .task { + async let readme: () = viewModel.loadReadme() + async let commits: () = viewModel.loadCommits() + async let refs: () = viewModel.loadReferences() + _ = await (readme, commits, refs) + } + .navigationDestination(for: CommitSummary.self) { commit in + CommitDetailView( + commitSummary: commit, + repository: viewModel.repository + ) + } + } + + @ViewBuilder + private var headerSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(viewModel.repository.owner.canonicalName) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(viewModel.repository.name) + .font(.largeTitle.weight(.semibold)) + if let description = viewModel.repository.description, !description.isEmpty { + Text(description) + .font(.body) + } + } + } + + @ViewBuilder + private var metadataSection: some View { + VStack(alignment: .leading, spacing: 10) { + SummaryMetadataRow( + icon: "arrow.triangle.branch", + title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) + ) + + if let readmePath = viewModel.readmePath { + SummaryMetadataRow( + icon: "doc.text", + title: readmePath + ) + } + } + } + + private var repositoryDetailsSection: some View { + DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { + VStack(alignment: .leading, spacing: 12) { + SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) + SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) + SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) + SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true) + } + .padding(.top, 8) + } label: { + Text("Repository Details") + .font(.subheadline.weight(.medium)) + } + } + + @ViewBuilder + private var latestChangeSection: some View { + VStack(alignment: .leading, spacing: 8) { + if viewModel.isLoadingCommits && viewModel.commits.isEmpty { + SRHTLoadingStateView(message: "Loading latest change…") + .frame(maxWidth: .infinity) + } else if let commit = viewModel.commits.first { + NavigationLink(value: commit) { + SummaryMetadataRow( + icon: "arrow.trianglehead.clockwise", + title: commit.title, + subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)" + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else if let error = viewModel.error, viewModel.commits.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Latest Change", + message: error, + retryAction: { await viewModel.loadCommits() } + ) + } else { + ContentUnavailableView( + "No Recent Commits", + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", + description: Text("This repository does not have any commit history yet.") + ) + } + } + } + + @ViewBuilder + private var readmeSection: some View { + if viewModel.isLoadingReadme { + SRHTLoadingStateView(message: "Loading README…") + } else if let content = viewModel.readmeContent { + RenderedMarkupContentView( + content: sharedReadmeContent(from: content), + readmePath: viewModel.readmePath, + colorScheme: colorScheme, + ownerCanonicalName: viewModel.repository.owner.canonicalName, + repositoryName: viewModel.repository.name + ) + } else if let error = viewModel.error, !viewModel.readmeLoaded { + SRHTErrorStateView( + title: "Couldn't Load README", + message: error, + retryAction: { await viewModel.loadReadme() } + ) + } else { + ContentUnavailableView( + "No README", + systemImage: "doc.text", + description: Text("This repository does not have a README file.") + ) + } + } + + private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { + switch content { + case .html(let html): + .html(html) + case .markdown(let text): + .markdown(text) + case .org(let text): + .org(text) + case .plainText(let text): + .plainText(text) + } + } +} + +enum RenderedMarkupContent: Sendable { + case html(String) + case markdown(String) + case org(String) + case plainText(String) +} + +struct RenderedMarkupContentView: View { + let content: RenderedMarkupContent + let readmePath: String? + let colorScheme: ColorScheme + let ownerCanonicalName: String + let repositoryName: String + var repositoryHost = "git.sr.ht" + + @State private var renderedHTML: String? + + private var cacheKey: String { + switch content { + case .html(let html): + "html:\(readmePath ?? "custom"):\(html)" + case .markdown(let text): + "markdown:\(readmePath ?? ""):\(text)" + case .org(let text): + "org:\(readmePath ?? ""):\(text)" + case .plainText(let text): + "plain:\(readmePath ?? ""):\(text)" + } + } + + var body: some View { + Group { + switch content { + case .html(let html): + HTMLWebView(html: html, colorScheme: colorScheme) + case .markdown, .org: + if let renderedHTML { + HTMLWebView(html: renderedHTML, colorScheme: colorScheme) + } else { + SRHTLoadingStateView(message: "Preparing README…") + } + case .plainText(let text): + Text(text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .task(id: cacheKey) { + await prepareHTMLIfNeeded() + } + } + + private func prepareHTMLIfNeeded() async { + switch content { + case .html, .plainText: + renderedHTML = nil + case .markdown(let text): + if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { + renderedHTML = cached + return + } + let html = await Task.detached(priority: .userInitiated) { + markdownToHTML(text) { source in + resolveRepositoryAssetURL( + source, + owner: ownerCanonicalName, + repositoryName: repositoryName, + readmePath: readmePath + )? + .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) + } + }.value + RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) + guard !Task.isCancelled else { return } + renderedHTML = html + case .org(let text): + if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { + renderedHTML = cached + return + } + let html = await Task.detached(priority: .userInitiated) { + orgToHTML(text) { source in + resolveRepositoryAssetURL( + source, + owner: ownerCanonicalName, + repositoryName: repositoryName, + readmePath: readmePath + )? + .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) + } + }.value + RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) + guard !Task.isCancelled else { return } + renderedHTML = html + } + } +} + +private final class RenderedReadmeHTMLCache: @unchecked Sendable { + static let shared = RenderedReadmeHTMLCache() + + private let storage = NSCache<NSString, NSString>() + + func html(forKey key: String) -> String? { + storage.object(forKey: key as NSString) as String? + } + + func setHTML(_ html: String, forKey key: String) { + storage.setObject(html as NSString, forKey: key as NSString) + } + + func removeAll() { + storage.removeAllObjects() + } +} + +@MainActor +func clearWebContentRenderCaches() { + RenderedReadmeHTMLCache.shared.removeAll() + HTMLWebViewCoordinator.heightCache.removeAllObjects() +} + +// MARK: - Markdown to HTML + +nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + let normalizedText = text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var html = "" + var inCodeBlock = false + var inList = false + var paragraph: [String] = [] + + func flushParagraph() { + if !paragraph.isEmpty { + let normalizedParagraph = paragraph + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + html += "<p>" + normalizedParagraph + "</p>\n" + paragraph = [] + } + } + + func closeList() { + if inList { + html += "</ul>\n" + inList = false + } + } + + for line in lines { + // Fenced code blocks + if line.hasPrefix("```") { + if inCodeBlock { + html += "</code></pre>\n" + inCodeBlock = false + } else { + flushParagraph() + closeList() + html += "<pre><code>" + inCodeBlock = true + } + continue + } + + if inCodeBlock { + html += escapeHTML(line) + "\n" + continue + } + + // Headings + if line.hasPrefix("### ") { + flushParagraph() + closeList() + html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n" + continue + } + if line.hasPrefix("## ") { + flushParagraph() + closeList() + html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n" + continue + } + if line.hasPrefix("# ") { + flushParagraph() + closeList() + html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n" + continue + } + + // List items + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { + flushParagraph() + if !inList { + html += "<ul>\n" + inList = true + } + html += "<li>" + renderTaskListItem( + String(trimmed.dropFirst(2)), + inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } + ) + "</li>\n" + continue + } + + // Blank line + if trimmed.isEmpty { + flushParagraph() + closeList() + continue + } + + // Regular text — accumulate into paragraph + paragraph.append(processInline(line, imageURLResolver: imageURLResolver)) + } + + // Flush remaining state + if inCodeBlock { + html += "</code></pre>\n" + } + flushParagraph() + closeList() + + return html +} + +nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { + var result = escapeHTML(text) + + // Images:  + 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: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) +} + +nonisolated private func escapeHTMLAttribute(_ text: String) -> String { + escapeHTML(text).replacingOccurrences(of: "'", with: "'") +} + +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: ", ")) - } - } - } -} |
