krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: 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}