krz/hutch

an ios client for sourcehut

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

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