krz/hutch

an ios client for sourcehut

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

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