krz/hutch

an ios client for sourcehut

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

v3.0.4: 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                }
400                .listStyle(.plain)
401            }
402        }
403        .refreshable {
404            await viewModel.loadBrowseRoot()
405        }
406    }
407
408    private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
409        ScrollView(.horizontal, showsIndicators: false) {
410            HStack(spacing: 4) {
411                Button {
412                    Task { await viewModel.navigateToPath(index: 0) }
413                } label: {
414                    Text("root")
415                        .font(.subheadline.monospaced())
416                }
417                .buttonStyle(.plain)
418
419                ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
420                    Image(systemName: "chevron.right")
421                        .font(.caption2)
422                        .foregroundStyle(.tertiary)
423
424                    Button {
425                        Task { await viewModel.navigateToPath(index: index + 1) }
426                    } label: {
427                        Text(component)
428                            .font(.subheadline.monospaced())
429                            .foregroundStyle(.secondary)
430                    }
431                    .buttonStyle(.plain)
432                }
433
434                if let selectedFilePath = viewModel.selectedFilePath {
435                    Image(systemName: "chevron.right")
436                        .font(.caption2)
437                        .foregroundStyle(.tertiary)
438                    Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
439                        .font(.subheadline.monospaced())
440                }
441            }
442            .padding(.horizontal)
443            .padding(.vertical, 8)
444        }
445        .background(.bar)
446    }
447
448    @ViewBuilder
449    private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
450        List {
451            ForEach(viewModel.log) { revision in
452                revisionRow(revision)
453                    .task {
454                        await viewModel.loadMoreLogIfNeeded(currentItem: revision)
455                    }
456            }
457
458            if viewModel.isLoadingMoreLog {
459                HStack {
460                    Spacer()
461                    ProgressView()
462                    Spacer()
463                }
464                .listRowSeparator(.hidden)
465            }
466        }
467        .listStyle(.plain)
468        .overlay {
469            if viewModel.isLoadingLog, viewModel.log.isEmpty {
470                SRHTLoadingStateView(message: "Loading revisions…")
471            } else if let error = viewModel.error, viewModel.log.isEmpty {
472                SRHTErrorStateView(
473                    title: "Couldn't Load Revisions",
474                    message: error,
475                    retryAction: { await viewModel.loadLog() }
476                )
477            } else if viewModel.log.isEmpty {
478                ContentUnavailableView(
479                    "No Revisions",
480                    systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
481                    description: Text("This repository has no revision history.")
482                )
483            }
484        }
485        .refreshable {
486            await viewModel.loadLog()
487        }
488    }
489
490    @ViewBuilder
491    private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
492        if revisions.isEmpty {
493            ContentUnavailableView(
494                emptyTitle,
495                systemImage: "tray",
496                description: Text(emptyDescription)
497            )
498        } else {
499            List(revisions) { revision in
500                namedRevisionRow(revision)
501            }
502            .listStyle(.plain)
503        }
504    }
505
506    private func revisionRow(_ revision: HgRevision) -> some View {
507        VStack(alignment: .leading, spacing: 6) {
508            HStack(alignment: .firstTextBaseline) {
509                Text(revision.primaryName)
510                    .font(.headline)
511                Spacer()
512                Text(revision.displayShortId)
513                    .font(.caption.monospaced())
514                    .foregroundStyle(.secondary)
515            }
516
517            Text(revision.title)
518                .font(.subheadline)
519
520            if let body = revision.body {
521                Text(body)
522                    .font(.caption)
523                    .foregroundStyle(.secondary)
524                    .lineLimit(2)
525            }
526
527            HStack {
528                Text(revision.author)
529            }
530            .font(.caption)
531            .foregroundStyle(.secondary)
532        }
533        .padding(.vertical, 4)
534    }
535
536    private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
537        HStack(alignment: .firstTextBaseline) {
538            Text(revision.name)
539                .font(.headline)
540            Spacer()
541            Text(revision.displayShortId)
542                .font(.caption.monospaced())
543                .foregroundStyle(.secondary)
544        }
545        .padding(.vertical, 6)
546    }
547
548    private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
549        guard let content = viewModel.readmeContent else {
550            return nil
551        }
552
553        return AnyView(
554            RenderedMarkupContentView(
555                content: sharedReadmeContent(from: content),
556                readmePath: viewModel.readmePath,
557                colorScheme: colorScheme,
558                ownerCanonicalName: repository.owner.canonicalName,
559                repositoryName: repository.name,
560                repositoryHost: "hg.sr.ht"
561            )
562        )
563    }
564
565    private func browseRevspecLabel(_ revspec: String) -> String {
566        if revspec == "tip" {
567            return "tip"
568        }
569        return revspec
570    }
571
572    private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
573        switch content {
574        case .html(let html):
575            .html(html)
576        case .markdown(let text):
577            .markdown(text)
578        case .org(let text):
579            .org(text)
580        case .plainText(let text):
581            .plainText(text)
582        }
583    }
584
585    private func displayFileName(_ name: String) -> String {
586        name.hasSuffix("/") ? String(name.dropLast()) : name
587    }
588
589    private func shareFileContents(_ text: String) {
590        if text.isEmpty {
591            showShareUnavailableAlert = true
592        } else {
593            showFileShareSheet = true
594        }
595    }
596
597    private func copyFileContents(_ text: String) {
598        UIPasteboard.general.string = text
599        didCopyFileContents = true
600        copyResetTask?.cancel()
601        copyResetTask = Task {
602            try? await Task.sleep(for: .seconds(2))
603            guard !Task.isCancelled else { return }
604            await MainActor.run {
605                didCopyFileContents = false
606            }
607        }
608    }
609
610    private func resetCopyConfirmation() {
611        copyResetTask?.cancel()
612        copyResetTask = nil
613        didCopyFileContents = false
614    }
615
616    private func fileActionToolbar(fileContent: String, viewModel: HgRepositoryDetailViewModel) -> some View {
617        HStack(spacing: 0) {
618            toolbarButton(
619                title: "Share",
620                systemImage: "square.and.arrow.up"
621            ) {
622                if shareURL != nil {
623                    showFileShareSheet = true
624                } else {
625                    shareFileContents(fileContent)
626                }
627            }
628
629            toolbarButton(
630                title: didCopyFileContents ? "Copied" : "Copy All",
631                systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
632            ) {
633                copyFileContents(fileContent)
634            }
635
636            toolbarButton(
637                title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
638                systemImage: "text.word.spacing"
639            ) {
640                wrapRepositoryFileLines.toggle()
641            }
642
643            toolbarButton(
644                title: "Back",
645                systemImage: "chevron.left"
646            ) {
647                viewModel.dismissFileView()
648            }
649        }
650        .padding(.horizontal, 8)
651        .padding(.top, 10)
652        .padding(.bottom, 8)
653        .background(.bar)
654        .overlay(alignment: .top) {
655            Divider()
656        }
657    }
658
659    private func toolbarButton(
660        title: String,
661        systemImage: String,
662        action: @escaping () -> Void
663    ) -> some View {
664        Button(action: action) {
665            VStack(spacing: 4) {
666                Image(systemName: systemImage)
667                    .font(.system(size: 17, weight: .semibold))
668                Text(title)
669                    .font(.caption2)
670                    .lineLimit(1)
671            }
672            .frame(maxWidth: .infinity)
673            .contentShape(Rectangle())
674        }
675        .buttonStyle(.plain)
676        .foregroundStyle(.primary)
677    }
678}
679
680private struct HgBrowseRefPickerSheet: View {
681    let viewModel: HgRepositoryDetailViewModel
682    @Binding var isPresented: Bool
683
684    var body: some View {
685        NavigationStack {
686            List {
687                Section {
688                    Button {
689                        Task {
690                            await viewModel.changeBrowseRevspec("tip")
691                            isPresented = false
692                        }
693                    } label: {
694                        refRow(
695                            title: "tip",
696                            systemImage: "arrow.triangle.branch",
697                            color: .blue,
698                            isSelected: viewModel.browseRevspec == "tip"
699                        )
700                    }
701                    .buttonStyle(.plain)
702                }
703
704                if !viewModel.branches.isEmpty {
705                    Section("Branches") {
706                        ForEach(viewModel.branches) { revision in
707                            Button {
708                                Task {
709                                    await viewModel.changeBrowseRevspec(revision.name)
710                                    isPresented = false
711                                }
712                            } label: {
713                                refRow(
714                                    title: revision.name,
715                                    systemImage: "arrow.triangle.branch",
716                                    color: .blue,
717                                    isSelected: viewModel.browseRevspec == revision.name
718                                )
719                            }
720                            .buttonStyle(.plain)
721                        }
722                    }
723                }
724
725                if !viewModel.tags.isEmpty {
726                    Section("Tags") {
727                        ForEach(viewModel.tags) { revision in
728                            Button {
729                                Task {
730                                    await viewModel.changeBrowseRevspec(revision.name)
731                                    isPresented = false
732                                }
733                            } label: {
734                                refRow(
735                                    title: revision.name,
736                                    systemImage: "tag",
737                                    color: .orange,
738                                    isSelected: viewModel.browseRevspec == revision.name
739                                )
740                            }
741                            .buttonStyle(.plain)
742                        }
743                    }
744                }
745
746                if !viewModel.bookmarks.isEmpty {
747                    Section("Bookmarks") {
748                        ForEach(viewModel.bookmarks) { revision in
749                            Button {
750                                Task {
751                                    await viewModel.changeBrowseRevspec(revision.name)
752                                    isPresented = false
753                                }
754                            } label: {
755                                refRow(
756                                    title: revision.name,
757                                    systemImage: "bookmark",
758                                    color: .purple,
759                                    isSelected: viewModel.browseRevspec == revision.name
760                                )
761                            }
762                            .buttonStyle(.plain)
763                        }
764                    }
765                }
766            }
767            .listStyle(.insetGrouped)
768            .navigationTitle("Select Ref")
769            .navigationBarTitleDisplayMode(.inline)
770            .toolbar {
771                ToolbarItem(placement: .cancellationAction) {
772                    Button("Cancel") {
773                        isPresented = false
774                    }
775                }
776            }
777        }
778    }
779
780    private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
781        HStack(spacing: 12) {
782            Image(systemName: systemImage)
783                .foregroundStyle(color)
784
785            Text(title)
786                .font(.body.monospaced())
787                .foregroundStyle(.primary)
788
789            Spacer()
790
791            if isSelected {
792                Image(systemName: "checkmark")
793                    .font(.caption.weight(.semibold))
794                    .foregroundStyle(.tint)
795            }
796        }
797        .contentShape(Rectangle())
798    }
799}
800
801private struct HgFileRow: View {
802    let file: HgFile
803
804    var body: some View {
805        Label {
806            Text(displayName)
807                .font(.body.monospaced())
808                .lineLimit(1)
809        } icon: {
810            Image(systemName: file.isDirectory ? "folder.fill" : "doc")
811                .foregroundStyle(file.isDirectory ? .blue : .secondary)
812        }
813    }
814
815    private var displayName: String {
816        file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
817    }
818}