summaryrefslogtreecommitdiff
path: root/Hutch/Views/Repositories/HgRepositoryDetailView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:22 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:22 -0500
commit8f2057c53e9009c2529c9c4849c914666c0e4b40 (patch)
tree77b5a93625fe9ea98baf4ab0d137055524705dad /Hutch/Views/Repositories/HgRepositoryDetailView.swift
parentd27726d8755a5631fbd2105f735811a95379c4ab (diff)
downloadhutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.gz
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.tar.bz2
hutch-8f2057c53e9009c2529c9c4849c914666c0e4b40.zip
create privacy policy
Diffstat (limited to 'Hutch/Views/Repositories/HgRepositoryDetailView.swift')
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift419
1 files changed, 419 insertions, 0 deletions
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
new file mode 100644
index 0000000..8e47d33
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -0,0 +1,419 @@
+import SwiftUI
+
+struct HgRepositoryDetailView: View {
+ let repository: RepositorySummary
+ let onDeleted: (() -> Void)?
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.colorScheme) private var colorScheme
+
+ @State private var viewModel: HgRepositoryDetailViewModel?
+ @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
+ @State private var showSettings = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(repository.name)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showSettings = true
+ } label: {
+ Image(systemName: "gear")
+ }
+ }
+ }
+ .sheet(isPresented: $showSettings) {
+ HgRepositorySettingsView(
+ repository: repository,
+ client: appState.client,
+ onDeleted: {
+ dismiss()
+ onDeleted?()
+ }
+ )
+ }
+ .task {
+ if viewModel == nil {
+ let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
+ viewModel = vm
+ async let summary: () = vm.loadSummary()
+ async let browse: () = vm.loadBrowseRoot()
+ async let log: () = vm.loadLog()
+ _ = await (summary, browse, log)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ Picker("Tab", selection: $selectedTab) {
+ ForEach(HgRepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
+ Text(tab.rawValue).tag(tab)
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+
+ Divider()
+
+ switch selectedTab {
+ case .summary:
+ summaryTab(viewModel)
+ case .browse:
+ browseTab(viewModel)
+ case .log:
+ logTab(viewModel)
+ case .tags:
+ revisionsList(viewModel.tags, emptyTitle: "No Tags", emptyDescription: "This repository does not have any tags.")
+ case .branches:
+ revisionsList(viewModel.branches, emptyTitle: "No Branches", emptyDescription: "This repository does not have any named branches.")
+ case .bookmarks:
+ revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
+ }
+ }
+ .alert("Error", isPresented: .constant(viewModel.error != nil)) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ if let error = viewModel.error {
+ Text(error)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ summaryCards(viewModel)
+
+ if let readmeView = readmeContentView(viewModel) {
+ readmeView
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
+ }
+ .padding()
+ }
+ .refreshable {
+ await viewModel.loadSummary()
+ }
+ }
+ }
+
+ private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ LabeledContent("Visibility", value: visibilityLabel(repository.visibility))
+ LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
+
+ if let description = repository.description, !description.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Description")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Text(description)
+ }
+ }
+
+ if let tip = viewModel.tip {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Tip")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ Text(tip.title)
+ .font(.headline)
+ HStack {
+ Text(tip.displayShortId)
+ .font(.caption.monospaced())
+ Spacer()
+ Text(tip.author.time.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Text(tip.author.name)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding()
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+
+ @ViewBuilder
+ private func browseTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ browseBreadcrumbs(viewModel)
+ Divider()
+
+ if viewModel.isLoadingBrowse {
+ Spacer()
+ ProgressView()
+ Spacer()
+ } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
+ GeometryReader { geometry in
+ ScrollView([.vertical, .horizontal]) {
+ Text(fileContent)
+ .font(.system(.body, design: .monospaced))
+ .frame(
+ minWidth: geometry.size.width,
+ minHeight: geometry.size.height,
+ alignment: .topLeading
+ )
+ .padding()
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ Button("Back to directory") {
+ viewModel.dismissFileView()
+ }
+ .buttonStyle(.bordered)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity)
+ .background(.bar)
+ }
+ .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
+ } else if viewModel.files.isEmpty {
+ ContentUnavailableView(
+ "No Files",
+ systemImage: "folder",
+ description: Text("This revision does not contain any browsable files.")
+ )
+ } else {
+ List(viewModel.files) { file in
+ Label {
+ Text(displayFileName(file.name))
+ .font(.body.monospaced())
+ } icon: {
+ Image(systemName: file.isDirectory ? "folder.fill" : "doc")
+ .foregroundStyle(file.isDirectory ? .blue : .secondary)
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ Task { await viewModel.openFile(file) }
+ }
+ }
+ .listStyle(.plain)
+ }
+ }
+ .refreshable {
+ await viewModel.loadBrowseRoot()
+ }
+ }
+
+ private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 4) {
+ Button {
+ Task { await viewModel.navigateToPath(index: 0) }
+ } label: {
+ Text("root")
+ .font(.subheadline.monospaced())
+ }
+ .buttonStyle(.plain)
+
+ ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+
+ Button {
+ Task { await viewModel.navigateToPath(index: index + 1) }
+ } label: {
+ Text(component)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if let selectedFilePath = viewModel.selectedFilePath {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
+ .font(.subheadline.monospaced())
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .background(.bar)
+ }
+
+ @ViewBuilder
+ private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ List {
+ ForEach(viewModel.log) { revision in
+ revisionRow(revision)
+ .task {
+ await viewModel.loadMoreLogIfNeeded(currentItem: revision)
+ }
+ }
+
+ if viewModel.isLoadingMoreLog {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoadingLog {
+ ProgressView()
+ } else if viewModel.log.isEmpty {
+ ContentUnavailableView(
+ "No Revisions",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository has no revision history.")
+ )
+ }
+ }
+ .refreshable {
+ await viewModel.loadLog()
+ }
+ }
+
+ @ViewBuilder
+ private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
+ if revisions.isEmpty {
+ ContentUnavailableView(
+ emptyTitle,
+ systemImage: "tray",
+ description: Text(emptyDescription)
+ )
+ } else {
+ List(revisions) { revision in
+ revisionRow(revision)
+ }
+ .listStyle(.plain)
+ }
+ }
+
+ private func revisionRow(_ revision: HgRevision) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(revision.primaryName)
+ .font(.headline)
+ Spacer()
+ Text(revision.displayShortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+
+ Text(revision.title)
+ .font(.subheadline)
+
+ if let body = revision.body {
+ Text(body)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack {
+ Text(revision.author.name)
+ Spacer()
+ Text(revision.author.time.relativeDescription)
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+
+ @ViewBuilder
+ private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
+ let imageURLResolver = makeImageURLResolver(viewModel)
+
+ guard let content = viewModel.readmeContent else {
+ return nil
+ }
+
+ switch content {
+ case .html(let html):
+ return AnyView(
+ HTMLWebView(html: html, colorScheme: colorScheme)
+ .frame(minHeight: 400)
+ )
+ case .markdown(let text):
+ return AnyView(
+ HTMLWebView(
+ html: markdownToHTML(text, imageURLResolver: imageURLResolver),
+ colorScheme: colorScheme
+ )
+ .frame(minHeight: 400)
+ )
+ case .org(let text):
+ return AnyView(
+ HTMLWebView(
+ html: orgToHTML(text, imageURLResolver: imageURLResolver),
+ colorScheme: colorScheme
+ )
+ .frame(minHeight: 400)
+ )
+ case .plainText(let text):
+ return AnyView(
+ Text(text)
+ .font(.system(.body, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding()
+ .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ )
+ }
+ }
+
+ private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? {
+ let owner = repository.owner.canonicalName
+ let repositoryName = repository.name
+ let readmePath = viewModel.readmePath
+
+ return { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: owner,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht")
+ }
+ }
+
+ private func displayFileName(_ name: String) -> String {
+ name.hasSuffix("/") ? String(name.dropLast()) : name
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}