krz/hutch

an ios client for sourcehut

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

v3.0.0: Hutch/Views/Repositories/HgRepositoryDetailView.swift · raw

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