krz/hutch

an ios client for sourcehut

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

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

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