krz/hutch

an ios client for sourcehut

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

v2.16.0: 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                        // no-op: .cancel role handles alert dismissal
294                    }
295                } message: {
296                    Text(SRHTShareTarget.file.fallbackMessage)
297                }
298            } else if let error = viewModel.error, viewModel.files.isEmpty {
299                SRHTErrorStateView(
300                    title: "Couldn't Load Files",
301                    message: error,
302                    retryAction: { await viewModel.loadBrowseRoot() }
303                )
304            } else if viewModel.files.isEmpty {
305                ContentUnavailableView(
306                    "No Files",
307                    systemImage: "folder",
308                    description: Text("This revision does not contain any browsable files.")
309                )
310            } else {
311                List(viewModel.files) { file in
312                    HgFileRow(file: file)
313                    .contentShape(Rectangle())
314                    .onTapGesture {
315                        Task { await viewModel.openFile(file) }
316                    }
317                }
318                .listStyle(.plain)
319            }
320        }
321        .refreshable {
322            await viewModel.loadBrowseRoot()
323        }
324    }
325
326    private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
327        ScrollView(.horizontal, showsIndicators: false) {
328            HStack(spacing: 4) {
329                Button {
330                    Task { await viewModel.navigateToPath(index: 0) }
331                } label: {
332                    Text("root")
333                        .font(.subheadline.monospaced())
334                }
335                .buttonStyle(.plain)
336
337                ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
338                    Image(systemName: "chevron.right")
339                        .font(.caption2)
340                        .foregroundStyle(.tertiary)
341
342                    Button {
343                        Task { await viewModel.navigateToPath(index: index + 1) }
344                    } label: {
345                        Text(component)
346                            .font(.subheadline.monospaced())
347                            .foregroundStyle(.secondary)
348                    }
349                    .buttonStyle(.plain)
350                }
351
352                if let selectedFilePath = viewModel.selectedFilePath {
353                    Image(systemName: "chevron.right")
354                        .font(.caption2)
355                        .foregroundStyle(.tertiary)
356                    Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
357                        .font(.subheadline.monospaced())
358                }
359            }
360            .padding(.horizontal)
361            .padding(.vertical, 8)
362        }
363        .background(.bar)
364    }
365
366    @ViewBuilder
367    private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
368        List {
369            ForEach(viewModel.log) { revision in
370                revisionRow(revision)
371                    .task {
372                        await viewModel.loadMoreLogIfNeeded(currentItem: revision)
373                    }
374            }
375
376            if viewModel.isLoadingMoreLog {
377                HStack {
378                    Spacer()
379                    ProgressView()
380                    Spacer()
381                }
382                .listRowSeparator(.hidden)
383            }
384        }
385        .listStyle(.plain)
386        .overlay {
387            if viewModel.isLoadingLog, viewModel.log.isEmpty {
388                SRHTLoadingStateView(message: "Loading revisions…")
389            } else if let error = viewModel.error, viewModel.log.isEmpty {
390                SRHTErrorStateView(
391                    title: "Couldn't Load Revisions",
392                    message: error,
393                    retryAction: { await viewModel.loadLog() }
394                )
395            } else if viewModel.log.isEmpty {
396                ContentUnavailableView(
397                    "No Revisions",
398                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
399                    description: Text("This repository has no revision history.")
400                )
401            }
402        }
403        .refreshable {
404            await viewModel.loadLog()
405        }
406    }
407
408    @ViewBuilder
409    private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
410        if revisions.isEmpty {
411            ContentUnavailableView(
412                emptyTitle,
413                systemImage: "tray",
414                description: Text(emptyDescription)
415            )
416        } else {
417            List(revisions) { revision in
418                namedRevisionRow(revision)
419            }
420            .listStyle(.plain)
421        }
422    }
423
424    private func revisionRow(_ revision: HgRevision) -> some View {
425        VStack(alignment: .leading, spacing: 6) {
426            HStack(alignment: .firstTextBaseline) {
427                Text(revision.primaryName)
428                    .font(.headline)
429                Spacer()
430                Text(revision.displayShortId)
431                    .font(.caption.monospaced())
432                    .foregroundStyle(.secondary)
433            }
434
435            Text(revision.title)
436                .font(.subheadline)
437
438            if let body = revision.body {
439                Text(body)
440                    .font(.caption)
441                    .foregroundStyle(.secondary)
442                    .lineLimit(2)
443            }
444
445            HStack {
446                Text(revision.author)
447            }
448            .font(.caption)
449            .foregroundStyle(.secondary)
450        }
451        .padding(.vertical, 4)
452    }
453
454    private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
455        HStack(alignment: .firstTextBaseline) {
456            Text(revision.name)
457                .font(.headline)
458            Spacer()
459            Text(revision.displayShortId)
460                .font(.caption.monospaced())
461                .foregroundStyle(.secondary)
462        }
463        .padding(.vertical, 6)
464    }
465
466    private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
467        guard let content = viewModel.readmeContent else {
468            return nil
469        }
470
471        return AnyView(
472            RenderedMarkupContentView(
473                content: sharedReadmeContent(from: content),
474                readmePath: viewModel.readmePath,
475                colorScheme: colorScheme,
476                ownerCanonicalName: repository.owner.canonicalName,
477                repositoryName: repository.name,
478                repositoryHost: "hg.sr.ht"
479            )
480        )
481    }
482
483    private func browseRevspecLabel(_ revspec: String) -> String {
484        if revspec == "tip" {
485            return "tip"
486        }
487        return revspec
488    }
489
490    private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
491        switch content {
492        case .html(let html):
493            .html(html)
494        case .markdown(let text):
495            .markdown(text)
496        case .org(let text):
497            .org(text)
498        case .plainText(let text):
499            .plainText(text)
500        }
501    }
502
503    private func displayFileName(_ name: String) -> String {
504        name.hasSuffix("/") ? String(name.dropLast()) : name
505    }
506
507    private func shareFileContents(_ text: String) {
508        if text.isEmpty {
509            showShareUnavailableAlert = true
510        } else {
511            showFileShareSheet = true
512        }
513    }
514
515    private func copyFileContents(_ text: String) {
516        UIPasteboard.general.string = text
517        didCopyFileContents = true
518        copyResetTask?.cancel()
519        copyResetTask = Task {
520            try? await Task.sleep(for: .seconds(2))
521            guard !Task.isCancelled else { return }
522            await MainActor.run {
523                didCopyFileContents = false
524            }
525        }
526    }
527
528    private func resetCopyConfirmation() {
529        copyResetTask?.cancel()
530        copyResetTask = nil
531        didCopyFileContents = false
532    }
533
534    private func fileActionToolbar(fileContent: String, viewModel: HgRepositoryDetailViewModel) -> some View {
535        HStack(spacing: 0) {
536            toolbarButton(
537                title: "Share",
538                systemImage: "square.and.arrow.up"
539            ) {
540                if shareURL != nil {
541                    showFileShareSheet = true
542                } else {
543                    shareFileContents(fileContent)
544                }
545            }
546
547            toolbarButton(
548                title: didCopyFileContents ? "Copied" : "Copy All",
549                systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
550            ) {
551                copyFileContents(fileContent)
552            }
553
554            toolbarButton(
555                title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
556                systemImage: "text.word.spacing"
557            ) {
558                wrapRepositoryFileLines.toggle()
559            }
560
561            toolbarButton(
562                title: "Back",
563                systemImage: "chevron.left"
564            ) {
565                viewModel.dismissFileView()
566            }
567        }
568        .padding(.horizontal, 8)
569        .padding(.top, 10)
570        .padding(.bottom, 8)
571        .background(.bar)
572        .overlay(alignment: .top) {
573            Divider()
574        }
575    }
576
577    private func toolbarButton(
578        title: String,
579        systemImage: String,
580        action: @escaping () -> Void
581    ) -> some View {
582        Button(action: action) {
583            VStack(spacing: 4) {
584                Image(systemName: systemImage)
585                    .font(.system(size: 17, weight: .semibold))
586                Text(title)
587                    .font(.caption2)
588                    .lineLimit(1)
589            }
590            .frame(maxWidth: .infinity)
591            .contentShape(Rectangle())
592        }
593        .buttonStyle(.plain)
594        .foregroundStyle(.primary)
595    }
596}
597
598private struct HgBrowseRefPickerSheet: View {
599    let viewModel: HgRepositoryDetailViewModel
600    @Binding var isPresented: Bool
601
602    var body: some View {
603        NavigationStack {
604            List {
605                Section {
606                    Button {
607                        Task {
608                            await viewModel.changeBrowseRevspec("tip")
609                            isPresented = false
610                        }
611                    } label: {
612                        refRow(
613                            title: "tip",
614                            systemImage: "arrow.triangle.branch",
615                            color: .blue,
616                            isSelected: viewModel.browseRevspec == "tip"
617                        )
618                    }
619                    .buttonStyle(.plain)
620                }
621
622                if !viewModel.branches.isEmpty {
623                    Section("Branches") {
624                        ForEach(viewModel.branches) { revision in
625                            Button {
626                                Task {
627                                    await viewModel.changeBrowseRevspec(revision.name)
628                                    isPresented = false
629                                }
630                            } label: {
631                                refRow(
632                                    title: revision.name,
633                                    systemImage: "arrow.triangle.branch",
634                                    color: .blue,
635                                    isSelected: viewModel.browseRevspec == revision.name
636                                )
637                            }
638                            .buttonStyle(.plain)
639                        }
640                    }
641                }
642
643                if !viewModel.tags.isEmpty {
644                    Section("Tags") {
645                        ForEach(viewModel.tags) { revision in
646                            Button {
647                                Task {
648                                    await viewModel.changeBrowseRevspec(revision.name)
649                                    isPresented = false
650                                }
651                            } label: {
652                                refRow(
653                                    title: revision.name,
654                                    systemImage: "tag",
655                                    color: .orange,
656                                    isSelected: viewModel.browseRevspec == revision.name
657                                )
658                            }
659                            .buttonStyle(.plain)
660                        }
661                    }
662                }
663
664                if !viewModel.bookmarks.isEmpty {
665                    Section("Bookmarks") {
666                        ForEach(viewModel.bookmarks) { revision in
667                            Button {
668                                Task {
669                                    await viewModel.changeBrowseRevspec(revision.name)
670                                    isPresented = false
671                                }
672                            } label: {
673                                refRow(
674                                    title: revision.name,
675                                    systemImage: "bookmark",
676                                    color: .purple,
677                                    isSelected: viewModel.browseRevspec == revision.name
678                                )
679                            }
680                            .buttonStyle(.plain)
681                        }
682                    }
683                }
684            }
685            .listStyle(.insetGrouped)
686            .navigationTitle("Select Ref")
687            .navigationBarTitleDisplayMode(.inline)
688            .toolbar {
689                ToolbarItem(placement: .cancellationAction) {
690                    Button("Cancel") {
691                        isPresented = false
692                    }
693                }
694            }
695        }
696    }
697
698    private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
699        HStack(spacing: 12) {
700            Image(systemName: systemImage)
701                .foregroundStyle(color)
702
703            Text(title)
704                .font(.body.monospaced())
705                .foregroundStyle(.primary)
706
707            Spacer()
708
709            if isSelected {
710                Image(systemName: "checkmark")
711                    .font(.caption.weight(.semibold))
712                    .foregroundStyle(.tint)
713            }
714        }
715        .contentShape(Rectangle())
716    }
717}
718
719private struct HgFileRow: View {
720    let file: HgFile
721
722    var body: some View {
723        Label {
724            Text(displayName)
725                .font(.body.monospaced())
726                .lineLimit(1)
727        } icon: {
728            Image(systemName: file.isDirectory ? "folder.fill" : "doc")
729                .foregroundStyle(file.isDirectory ? .blue : .secondary)
730        }
731    }
732
733    private var displayName: String {
734        file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
735    }
736}