krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.9.1: Hutch/Views/Repositories/HgRepositoryDetailView.swift · raw

  1import SwiftUI
  2import UIKit
  3
  4struct HgRepositoryDetailView: View {
  5    let repository: RepositorySummary
  6    let onDeleted: (() -> Void)?
  7
  8    @Environment(AppState.self) private var appState
  9    @Environment(\.dismiss) private var dismiss
 10    @Environment(\.colorScheme) private var colorScheme
 11
 12    @AppStorage(AppStorageKeys.wrapRepositoryFileLines) private var wrapRepositoryFileLines = false
 13    @State private var viewModel: HgRepositoryDetailViewModel?
 14    @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
 15    @State private var showSettings = false
 16    @State private var isShowingRepositoryDetails = false
 17    @State private var showBrowseRefPicker = false
 18    @State private var showFileShareSheet = false
 19    @State private var showShareUnavailableAlert = false
 20    @State private var didCopyFileContents = false
 21    @State private var copyResetTask: Task<Void, Never>?
 22
 23    private var canManageRepository: Bool {
 24        guard let currentUser = appState.currentUser else { return false }
 25        return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName)
 26    }
 27
 28    private var shareURL: URL? {
 29        guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
 30        return SRHTWebURL.file(
 31            repository: repository,
 32            revspec: viewModel.browseRevspec,
 33            path: selectedFilePath
 34        )
 35    }
 36
 37    var body: some View {
 38        Group {
 39            if let viewModel {
 40                content(viewModel)
 41            } else {
 42                SRHTLoadingStateView(message: "Loading repository…")
 43            }
 44        }
 45        .navigationTitle(repository.name)
 46        .navigationBarTitleDisplayMode(.inline)
 47        .toolbar {
 48            ToolbarItemGroup(placement: .topBarTrailing) {
 49                if selectedTab == .browse, let viewModel {
 50                    Button {
 51                        showBrowseRefPicker = true
 52                    } label: {
 53                        Label(
 54                            browseRevspecLabel(viewModel.browseRevspec),
 55                            systemImage: "arrow.triangle.branch"
 56                        )
 57                        .font(.subheadline)
 58                    }
 59                }
 60
 61                SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
 62                    Image(systemName: "square.and.arrow.up")
 63                }
 64
 65                if canManageRepository {
 66                    Button {
 67                        showSettings = true
 68                    } label: {
 69                        Image(systemName: "gear")
 70                    }
 71                }
 72            }
 73        }
 74        .sheet(isPresented: $showSettings) {
 75            HgRepositorySettingsView(
 76                repository: repository,
 77                client: appState.client,
 78                onDeleted: {
 79                    dismiss()
 80                    onDeleted?()
 81                }
 82            )
 83        }
 84        .sheet(isPresented: $showBrowseRefPicker) {
 85            if let viewModel {
 86                HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker)
 87            }
 88        }
 89        .onChange(of: viewModel?.selectedFilePath) { _, _ in
 90            resetCopyConfirmation()
 91        }
 92        .task {
 93            if viewModel == nil {
 94                let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
 95                viewModel = vm
 96                async let summary: () = vm.loadSummary()
 97                async let browse: () = vm.loadBrowseRoot()
 98                async let log: () = vm.loadLog()
 99                _ = await (summary, browse, log)
100            }
101        }
102    }
103
104    private func normalizedUsername(_ value: String) -> String {
105        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
106        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
107    }
108
109    @ViewBuilder
110    private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View {
111        VStack(spacing: 0) {
112            Picker("Tab", selection: $selectedTab) {
113                ForEach(HgRepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
114                    Text(tab.rawValue).tag(tab)
115                }
116            }
117            .pickerStyle(.segmented)
118            .padding(.horizontal)
119            .padding(.vertical, 8)
120
121            Divider()
122
123            switch selectedTab {
124            case .summary:
125                summaryTab(viewModel)
126            case .browse:
127                browseTab(viewModel)
128            case .log:
129                logTab(viewModel)
130            case .tags:
131                revisionsList(viewModel.tags, emptyTitle: "No Tags", emptyDescription: "This repository does not have any tags.")
132            case .branches:
133                revisionsList(viewModel.branches, emptyTitle: "No Branches", emptyDescription: "This repository does not have any named branches.")
134            case .bookmarks:
135                revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
136            }
137        }
138        .srhtErrorBanner(error: Binding(
139            get: { viewModel.error },
140            set: { viewModel.error = $0 }
141        ))
142    }
143
144    @ViewBuilder
145    private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
146        ScrollView {
147            VStack(alignment: .leading, spacing: 16) {
148                headerSection
149                metadataSection(viewModel)
150                repositoryDetailsSection(viewModel)
151                latestChangeSection(viewModel)
152                readmeSection(viewModel)
153            }
154            .padding()
155        }
156        .overlay {
157            if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
158                SRHTLoadingStateView(message: "Loading repository…")
159            } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
160                SRHTErrorStateView(
161                    title: "Couldn't Load Repository",
162                    message: error,
163                    retryAction: { await viewModel.loadSummary() }
164                )
165            }
166        }
167        .refreshable {
168            await viewModel.loadSummary()
169        }
170    }
171
172    private var headerSection: some View {
173        VStack(alignment: .leading, spacing: 6) {
174            Text(repository.owner.canonicalName)
175                .font(.subheadline)
176                .foregroundStyle(.secondary)
177            Text(repository.name)
178                .font(.largeTitle.weight(.semibold))
179            if let description = repository.description, !description.isEmpty {
180                Text(description)
181                    .font(.body)
182            }
183        }
184    }
185
186    @ViewBuilder
187    private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
188        VStack(alignment: .leading, spacing: 10) {
189            SummaryMetadataRow(
190                icon: "arrow.triangle.branch",
191                title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
192            )
193
194            if let readmePath = viewModel.readmePath {
195                SummaryMetadataRow(
196                    icon: "doc.text",
197                    title: readmePath
198                )
199            }
200        }
201    }
202
203    private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
204        DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
205            VStack(alignment: .leading, spacing: 12) {
206                SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
207                SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
208                SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
209                SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true)
210                SummaryDetailRow(label: "RID", value: repository.rid, monospace: true)
211            }
212            .padding(.top, 8)
213        } label: {
214            Text("Repository Details")
215                .font(.subheadline.weight(.medium))
216        }
217    }
218
219    @ViewBuilder
220    private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
221        VStack(alignment: .leading, spacing: 8) {
222            if viewModel.isLoadingSummary && viewModel.tip == nil {
223                SRHTLoadingStateView(message: "Loading latest change…")
224                    .frame(maxWidth: .infinity)
225            } else if let tip = viewModel.tip {
226                SummaryMetadataRow(
227                    icon: "arrow.trianglehead.clockwise",
228                    title: tip.title,
229                    subtitle: "\(tip.displayShortId)\(tip.author)"
230                )
231            } else if let error = viewModel.error, !viewModel.summaryLoaded {
232                SRHTErrorStateView(
233                    title: "Couldn't Load Latest Change",
234                    message: error,
235                    retryAction: { await viewModel.loadSummary() }
236                )
237            } else {
238                ContentUnavailableView(
239                    "No Recent Revisions",
240                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
241                    description: Text("This repository does not have any revision history yet.")
242                )
243            }
244        }
245    }
246
247    @ViewBuilder
248    private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
249        if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
250            SRHTLoadingStateView(message: "Loading README…")
251        } else if let readmeView = readmeContentView(viewModel) {
252            readmeView
253        } else if let error = viewModel.error, !viewModel.summaryLoaded {
254            SRHTErrorStateView(
255                title: "Couldn't Load README",
256                message: error,
257                retryAction: { await viewModel.loadSummary() }
258            )
259        } else {
260            ContentUnavailableView(
261                "No README",
262                systemImage: "doc.text",
263                description: Text("This repository does not have a README file.")
264            )
265        }
266    }
267
268    @ViewBuilder
269    private func browseTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
270        VStack(spacing: 0) {
271            browseBreadcrumbs(viewModel)
272            Divider()
273
274            if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil {
275                SRHTLoadingStateView(message: "Loading files…")
276            } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
277                VStack(spacing: 0) {
278                    CodeFileTextView(
279                        text: fileContent,
280                        fileName: selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath,
281                        wrapLines: wrapRepositoryFileLines
282                    )
283                }
284                .safeAreaInset(edge: .bottom) {
285                    fileActionToolbar(fileContent: fileContent, viewModel: viewModel)
286                }
287                .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
288                .sheet(isPresented: $showFileShareSheet) {
289                    FileContentShareSheet(activityItems: [shareURL ?? fileContent])
290                }
291                .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
292                    Button("OK", role: .cancel) {}
293                } message: {
294                    Text(SRHTShareTarget.file.fallbackMessage)
295                }
296            } else if let error = viewModel.error, viewModel.files.isEmpty {
297                SRHTErrorStateView(
298                    title: "Couldn't Load Files",
299                    message: error,
300                    retryAction: { await viewModel.loadBrowseRoot() }
301                )
302            } else if viewModel.files.isEmpty {
303                ContentUnavailableView(
304                    "No Files",
305                    systemImage: "folder",
306                    description: Text("This revision does not contain any browsable files.")
307                )
308            } else {
309                List(viewModel.files) { file in
310                    HgFileRow(file: file)
311                    .contentShape(Rectangle())
312                    .onTapGesture {
313                        Task { await viewModel.openFile(file) }
314                    }
315                }
316                .listStyle(.plain)
317            }
318        }
319        .refreshable {
320            await viewModel.loadBrowseRoot()
321        }
322    }
323
324    private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
325        ScrollView(.horizontal, showsIndicators: false) {
326            HStack(spacing: 4) {
327                Button {
328                    Task { await viewModel.navigateToPath(index: 0) }
329                } label: {
330                    Text("root")
331                        .font(.subheadline.monospaced())
332                }
333                .buttonStyle(.plain)
334
335                ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
336                    Image(systemName: "chevron.right")
337                        .font(.caption2)
338                        .foregroundStyle(.tertiary)
339
340                    Button {
341                        Task { await viewModel.navigateToPath(index: index + 1) }
342                    } label: {
343                        Text(component)
344                            .font(.subheadline.monospaced())
345                            .foregroundStyle(.secondary)
346                    }
347                    .buttonStyle(.plain)
348                }
349
350                if let selectedFilePath = viewModel.selectedFilePath {
351                    Image(systemName: "chevron.right")
352                        .font(.caption2)
353                        .foregroundStyle(.tertiary)
354                    Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
355                        .font(.subheadline.monospaced())
356                }
357            }
358            .padding(.horizontal)
359            .padding(.vertical, 8)
360        }
361        .background(.bar)
362    }
363
364    @ViewBuilder
365    private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
366        List {
367            ForEach(viewModel.log) { revision in
368                revisionRow(revision)
369                    .task {
370                        await viewModel.loadMoreLogIfNeeded(currentItem: revision)
371                    }
372            }
373
374            if viewModel.isLoadingMoreLog {
375                HStack {
376                    Spacer()
377                    ProgressView()
378                    Spacer()
379                }
380                .listRowSeparator(.hidden)
381            }
382        }
383        .listStyle(.plain)
384        .overlay {
385            if viewModel.isLoadingLog, viewModel.log.isEmpty {
386                SRHTLoadingStateView(message: "Loading revisions…")
387            } else if let error = viewModel.error, viewModel.log.isEmpty {
388                SRHTErrorStateView(
389                    title: "Couldn't Load Revisions",
390                    message: error,
391                    retryAction: { await viewModel.loadLog() }
392                )
393            } else if viewModel.log.isEmpty {
394                ContentUnavailableView(
395                    "No Revisions",
396                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
397                    description: Text("This repository has no revision history.")
398                )
399            }
400        }
401        .refreshable {
402            await viewModel.loadLog()
403        }
404    }
405
406    @ViewBuilder
407    private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
408        if revisions.isEmpty {
409            ContentUnavailableView(
410                emptyTitle,
411                systemImage: "tray",
412                description: Text(emptyDescription)
413            )
414        } else {
415            List(revisions) { revision in
416                namedRevisionRow(revision)
417            }
418            .listStyle(.plain)
419        }
420    }
421
422    private func revisionRow(_ revision: HgRevision) -> some View {
423        VStack(alignment: .leading, spacing: 6) {
424            HStack(alignment: .firstTextBaseline) {
425                Text(revision.primaryName)
426                    .font(.headline)
427                Spacer()
428                Text(revision.displayShortId)
429                    .font(.caption.monospaced())
430                    .foregroundStyle(.secondary)
431            }
432
433            Text(revision.title)
434                .font(.subheadline)
435
436            if let body = revision.body {
437                Text(body)
438                    .font(.caption)
439                    .foregroundStyle(.secondary)
440                    .lineLimit(2)
441            }
442
443            HStack {
444                Text(revision.author)
445            }
446            .font(.caption)
447            .foregroundStyle(.secondary)
448        }
449        .padding(.vertical, 4)
450    }
451
452    private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
453        HStack(alignment: .firstTextBaseline) {
454            Text(revision.name)
455                .font(.headline)
456            Spacer()
457            Text(revision.displayShortId)
458                .font(.caption.monospaced())
459                .foregroundStyle(.secondary)
460        }
461        .padding(.vertical, 6)
462    }
463
464    private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
465        guard let content = viewModel.readmeContent else {
466            return nil
467        }
468
469        return AnyView(
470            RenderedMarkupContentView(
471                content: sharedReadmeContent(from: content),
472                readmePath: viewModel.readmePath,
473                colorScheme: colorScheme,
474                ownerCanonicalName: repository.owner.canonicalName,
475                repositoryName: repository.name,
476                repositoryHost: "hg.sr.ht"
477            )
478        )
479    }
480
481    private func browseRevspecLabel(_ revspec: String) -> String {
482        if revspec == "tip" {
483            return "tip"
484        }
485        return revspec
486    }
487
488    private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
489        switch content {
490        case .html(let html):
491            .html(html)
492        case .markdown(let text):
493            .markdown(text)
494        case .org(let text):
495            .org(text)
496        case .plainText(let text):
497            .plainText(text)
498        }
499    }
500
501    private func displayFileName(_ name: String) -> String {
502        name.hasSuffix("/") ? String(name.dropLast()) : name
503    }
504
505    private func shareFileContents(_ text: String) {
506        if text.isEmpty {
507            showShareUnavailableAlert = true
508        } else {
509            showFileShareSheet = true
510        }
511    }
512
513    private func copyFileContents(_ text: String) {
514        UIPasteboard.general.string = text
515        didCopyFileContents = true
516        copyResetTask?.cancel()
517        copyResetTask = Task {
518            try? await Task.sleep(for: .seconds(2))
519            guard !Task.isCancelled else { return }
520            await MainActor.run {
521                didCopyFileContents = false
522            }
523        }
524    }
525
526    private func resetCopyConfirmation() {
527        copyResetTask?.cancel()
528        copyResetTask = nil
529        didCopyFileContents = false
530    }
531
532    private func fileActionToolbar(fileContent: String, viewModel: HgRepositoryDetailViewModel) -> some View {
533        HStack(spacing: 0) {
534            toolbarButton(
535                title: "Share",
536                systemImage: "square.and.arrow.up"
537            ) {
538                if shareURL != nil {
539                    showFileShareSheet = true
540                } else {
541                    shareFileContents(fileContent)
542                }
543            }
544
545            toolbarButton(
546                title: didCopyFileContents ? "Copied" : "Copy All",
547                systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
548            ) {
549                copyFileContents(fileContent)
550            }
551
552            toolbarButton(
553                title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
554                systemImage: "text.word.spacing"
555            ) {
556                wrapRepositoryFileLines.toggle()
557            }
558
559            toolbarButton(
560                title: "Back",
561                systemImage: "chevron.left"
562            ) {
563                viewModel.dismissFileView()
564            }
565        }
566        .padding(.horizontal, 8)
567        .padding(.top, 10)
568        .padding(.bottom, 8)
569        .background(.bar)
570        .overlay(alignment: .top) {
571            Divider()
572        }
573    }
574
575    private func toolbarButton(
576        title: String,
577        systemImage: String,
578        action: @escaping () -> Void
579    ) -> some View {
580        Button(action: action) {
581            VStack(spacing: 4) {
582                Image(systemName: systemImage)
583                    .font(.system(size: 17, weight: .semibold))
584                Text(title)
585                    .font(.caption2)
586                    .lineLimit(1)
587            }
588            .frame(maxWidth: .infinity)
589            .contentShape(Rectangle())
590        }
591        .buttonStyle(.plain)
592        .foregroundStyle(.primary)
593    }
594}
595
596private struct HgBrowseRefPickerSheet: View {
597    let viewModel: HgRepositoryDetailViewModel
598    @Binding var isPresented: Bool
599
600    var body: some View {
601        NavigationStack {
602            List {
603                Section {
604                    Button {
605                        Task {
606                            await viewModel.changeBrowseRevspec("tip")
607                            isPresented = false
608                        }
609                    } label: {
610                        refRow(
611                            title: "tip",
612                            systemImage: "arrow.triangle.branch",
613                            color: .blue,
614                            isSelected: viewModel.browseRevspec == "tip"
615                        )
616                    }
617                    .buttonStyle(.plain)
618                }
619
620                if !viewModel.branches.isEmpty {
621                    Section("Branches") {
622                        ForEach(viewModel.branches) { revision in
623                            Button {
624                                Task {
625                                    await viewModel.changeBrowseRevspec(revision.name)
626                                    isPresented = false
627                                }
628                            } label: {
629                                refRow(
630                                    title: revision.name,
631                                    systemImage: "arrow.triangle.branch",
632                                    color: .blue,
633                                    isSelected: viewModel.browseRevspec == revision.name
634                                )
635                            }
636                            .buttonStyle(.plain)
637                        }
638                    }
639                }
640
641                if !viewModel.tags.isEmpty {
642                    Section("Tags") {
643                        ForEach(viewModel.tags) { revision in
644                            Button {
645                                Task {
646                                    await viewModel.changeBrowseRevspec(revision.name)
647                                    isPresented = false
648                                }
649                            } label: {
650                                refRow(
651                                    title: revision.name,
652                                    systemImage: "tag",
653                                    color: .orange,
654                                    isSelected: viewModel.browseRevspec == revision.name
655                                )
656                            }
657                            .buttonStyle(.plain)
658                        }
659                    }
660                }
661
662                if !viewModel.bookmarks.isEmpty {
663                    Section("Bookmarks") {
664                        ForEach(viewModel.bookmarks) { revision in
665                            Button {
666                                Task {
667                                    await viewModel.changeBrowseRevspec(revision.name)
668                                    isPresented = false
669                                }
670                            } label: {
671                                refRow(
672                                    title: revision.name,
673                                    systemImage: "bookmark",
674                                    color: .purple,
675                                    isSelected: viewModel.browseRevspec == revision.name
676                                )
677                            }
678                            .buttonStyle(.plain)
679                        }
680                    }
681                }
682            }
683            .listStyle(.insetGrouped)
684            .navigationTitle("Select Ref")
685            .navigationBarTitleDisplayMode(.inline)
686            .toolbar {
687                ToolbarItem(placement: .cancellationAction) {
688                    Button("Cancel") {
689                        isPresented = false
690                    }
691                }
692            }
693        }
694    }
695
696    private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
697        HStack(spacing: 12) {
698            Image(systemName: systemImage)
699                .foregroundStyle(color)
700
701            Text(title)
702                .font(.body.monospaced())
703                .foregroundStyle(.primary)
704
705            Spacer()
706
707            if isSelected {
708                Image(systemName: "checkmark")
709                    .font(.caption.weight(.semibold))
710                    .foregroundStyle(.tint)
711            }
712        }
713        .contentShape(Rectangle())
714    }
715}
716
717private struct HgFileRow: View {
718    let file: HgFile
719
720    var body: some View {
721        Label {
722            Text(displayName)
723                .font(.body.monospaced())
724                .lineLimit(1)
725        } icon: {
726            Image(systemName: file.isDirectory ? "folder.fill" : "doc")
727                .foregroundStyle(file.isDirectory ? .blue : .secondary)
728        }
729    }
730
731    private var displayName: String {
732        file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
733    }
734}