diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:22 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:22 -0500 |
| commit | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (patch) | |
| tree | 77b5a93625fe9ea98baf4ab0d137055524705dad /Hutch/Views/Repositories/HgRepositoryDetailView.swift | |
| parent | d27726d8755a5631fbd2105f735811a95379c4ab (diff) | |
| download | hutch-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.swift | 419 |
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" + } + } +} |
