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