krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.10.3: 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 // no-op: .cancel role handles alert dismissal
294 }
295 } message: {
296 Text(SRHTShareTarget.file.fallbackMessage)
297 }
298 } else if let error = viewModel.error, viewModel.files.isEmpty {
299 SRHTErrorStateView(
300 title: "Couldn't Load Files",
301 message: error,
302 retryAction: { await viewModel.loadBrowseRoot() }
303 )
304 } else if viewModel.files.isEmpty {
305 ContentUnavailableView(
306 "No Files",
307 systemImage: "folder",
308 description: Text("This revision does not contain any browsable files.")
309 )
310 } else {
311 List(viewModel.files) { file in
312 HgFileRow(file: file)
313 .contentShape(Rectangle())
314 .onTapGesture {
315 Task { await viewModel.openFile(file) }
316 }
317 }
318 .listStyle(.plain)
319 }
320 }
321 .refreshable {
322 await viewModel.loadBrowseRoot()
323 }
324 }
325
326 private func browseBreadcrumbs(_ viewModel: HgRepositoryDetailViewModel) -> some View {
327 ScrollView(.horizontal, showsIndicators: false) {
328 HStack(spacing: 4) {
329 Button {
330 Task { await viewModel.navigateToPath(index: 0) }
331 } label: {
332 Text("root")
333 .font(.subheadline.monospaced())
334 }
335 .buttonStyle(.plain)
336
337 ForEach(Array(viewModel.pathStack.enumerated()), id: \.offset) { index, component in
338 Image(systemName: "chevron.right")
339 .font(.caption2)
340 .foregroundStyle(.tertiary)
341
342 Button {
343 Task { await viewModel.navigateToPath(index: index + 1) }
344 } label: {
345 Text(component)
346 .font(.subheadline.monospaced())
347 .foregroundStyle(.secondary)
348 }
349 .buttonStyle(.plain)
350 }
351
352 if let selectedFilePath = viewModel.selectedFilePath {
353 Image(systemName: "chevron.right")
354 .font(.caption2)
355 .foregroundStyle(.tertiary)
356 Text(selectedFilePath.split(separator: "/").last.map(String.init) ?? selectedFilePath)
357 .font(.subheadline.monospaced())
358 }
359 }
360 .padding(.horizontal)
361 .padding(.vertical, 8)
362 }
363 .background(.bar)
364 }
365
366 @ViewBuilder
367 private func logTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
368 List {
369 ForEach(viewModel.log) { revision in
370 revisionRow(revision)
371 .task {
372 await viewModel.loadMoreLogIfNeeded(currentItem: revision)
373 }
374 }
375
376 if viewModel.isLoadingMoreLog {
377 HStack {
378 Spacer()
379 ProgressView()
380 Spacer()
381 }
382 .listRowSeparator(.hidden)
383 }
384 }
385 .listStyle(.plain)
386 .overlay {
387 if viewModel.isLoadingLog, viewModel.log.isEmpty {
388 SRHTLoadingStateView(message: "Loading revisions…")
389 } else if let error = viewModel.error, viewModel.log.isEmpty {
390 SRHTErrorStateView(
391 title: "Couldn't Load Revisions",
392 message: error,
393 retryAction: { await viewModel.loadLog() }
394 )
395 } else if viewModel.log.isEmpty {
396 ContentUnavailableView(
397 "No Revisions",
398 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
399 description: Text("This repository has no revision history.")
400 )
401 }
402 }
403 .refreshable {
404 await viewModel.loadLog()
405 }
406 }
407
408 @ViewBuilder
409 private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
410 if revisions.isEmpty {
411 ContentUnavailableView(
412 emptyTitle,
413 systemImage: "tray",
414 description: Text(emptyDescription)
415 )
416 } else {
417 List(revisions) { revision in
418 namedRevisionRow(revision)
419 }
420 .listStyle(.plain)
421 }
422 }
423
424 private func revisionRow(_ revision: HgRevision) -> some View {
425 VStack(alignment: .leading, spacing: 6) {
426 HStack(alignment: .firstTextBaseline) {
427 Text(revision.primaryName)
428 .font(.headline)
429 Spacer()
430 Text(revision.displayShortId)
431 .font(.caption.monospaced())
432 .foregroundStyle(.secondary)
433 }
434
435 Text(revision.title)
436 .font(.subheadline)
437
438 if let body = revision.body {
439 Text(body)
440 .font(.caption)
441 .foregroundStyle(.secondary)
442 .lineLimit(2)
443 }
444
445 HStack {
446 Text(revision.author)
447 }
448 .font(.caption)
449 .foregroundStyle(.secondary)
450 }
451 .padding(.vertical, 4)
452 }
453
454 private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
455 HStack(alignment: .firstTextBaseline) {
456 Text(revision.name)
457 .font(.headline)
458 Spacer()
459 Text(revision.displayShortId)
460 .font(.caption.monospaced())
461 .foregroundStyle(.secondary)
462 }
463 .padding(.vertical, 6)
464 }
465
466 private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
467 guard let content = viewModel.readmeContent else {
468 return nil
469 }
470
471 return AnyView(
472 RenderedMarkupContentView(
473 content: sharedReadmeContent(from: content),
474 readmePath: viewModel.readmePath,
475 colorScheme: colorScheme,
476 ownerCanonicalName: repository.owner.canonicalName,
477 repositoryName: repository.name,
478 repositoryHost: "hg.sr.ht"
479 )
480 )
481 }
482
483 private func browseRevspecLabel(_ revspec: String) -> String {
484 if revspec == "tip" {
485 return "tip"
486 }
487 return revspec
488 }
489
490 private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
491 switch content {
492 case .html(let html):
493 .html(html)
494 case .markdown(let text):
495 .markdown(text)
496 case .org(let text):
497 .org(text)
498 case .plainText(let text):
499 .plainText(text)
500 }
501 }
502
503 private func displayFileName(_ name: String) -> String {
504 name.hasSuffix("/") ? String(name.dropLast()) : name
505 }
506
507 private func shareFileContents(_ text: String) {
508 if text.isEmpty {
509 showShareUnavailableAlert = true
510 } else {
511 showFileShareSheet = true
512 }
513 }
514
515 private func copyFileContents(_ text: String) {
516 UIPasteboard.general.string = text
517 didCopyFileContents = true
518 copyResetTask?.cancel()
519 copyResetTask = Task {
520 try? await Task.sleep(for: .seconds(2))
521 guard !Task.isCancelled else { return }
522 await MainActor.run {
523 didCopyFileContents = false
524 }
525 }
526 }
527
528 private func resetCopyConfirmation() {
529 copyResetTask?.cancel()
530 copyResetTask = nil
531 didCopyFileContents = false
532 }
533
534 private func fileActionToolbar(fileContent: String, viewModel: HgRepositoryDetailViewModel) -> some View {
535 HStack(spacing: 0) {
536 toolbarButton(
537 title: "Share",
538 systemImage: "square.and.arrow.up"
539 ) {
540 if shareURL != nil {
541 showFileShareSheet = true
542 } else {
543 shareFileContents(fileContent)
544 }
545 }
546
547 toolbarButton(
548 title: didCopyFileContents ? "Copied" : "Copy All",
549 systemImage: didCopyFileContents ? "checkmark" : "doc.on.doc"
550 ) {
551 copyFileContents(fileContent)
552 }
553
554 toolbarButton(
555 title: wrapRepositoryFileLines ? "Wrap On" : "Wrap Off",
556 systemImage: "text.word.spacing"
557 ) {
558 wrapRepositoryFileLines.toggle()
559 }
560
561 toolbarButton(
562 title: "Back",
563 systemImage: "chevron.left"
564 ) {
565 viewModel.dismissFileView()
566 }
567 }
568 .padding(.horizontal, 8)
569 .padding(.top, 10)
570 .padding(.bottom, 8)
571 .background(.bar)
572 .overlay(alignment: .top) {
573 Divider()
574 }
575 }
576
577 private func toolbarButton(
578 title: String,
579 systemImage: String,
580 action: @escaping () -> Void
581 ) -> some View {
582 Button(action: action) {
583 VStack(spacing: 4) {
584 Image(systemName: systemImage)
585 .font(.system(size: 17, weight: .semibold))
586 Text(title)
587 .font(.caption2)
588 .lineLimit(1)
589 }
590 .frame(maxWidth: .infinity)
591 .contentShape(Rectangle())
592 }
593 .buttonStyle(.plain)
594 .foregroundStyle(.primary)
595 }
596}
597
598private struct HgBrowseRefPickerSheet: View {
599 let viewModel: HgRepositoryDetailViewModel
600 @Binding var isPresented: Bool
601
602 var body: some View {
603 NavigationStack {
604 List {
605 Section {
606 Button {
607 Task {
608 await viewModel.changeBrowseRevspec("tip")
609 isPresented = false
610 }
611 } label: {
612 refRow(
613 title: "tip",
614 systemImage: "arrow.triangle.branch",
615 color: .blue,
616 isSelected: viewModel.browseRevspec == "tip"
617 )
618 }
619 .buttonStyle(.plain)
620 }
621
622 if !viewModel.branches.isEmpty {
623 Section("Branches") {
624 ForEach(viewModel.branches) { revision in
625 Button {
626 Task {
627 await viewModel.changeBrowseRevspec(revision.name)
628 isPresented = false
629 }
630 } label: {
631 refRow(
632 title: revision.name,
633 systemImage: "arrow.triangle.branch",
634 color: .blue,
635 isSelected: viewModel.browseRevspec == revision.name
636 )
637 }
638 .buttonStyle(.plain)
639 }
640 }
641 }
642
643 if !viewModel.tags.isEmpty {
644 Section("Tags") {
645 ForEach(viewModel.tags) { revision in
646 Button {
647 Task {
648 await viewModel.changeBrowseRevspec(revision.name)
649 isPresented = false
650 }
651 } label: {
652 refRow(
653 title: revision.name,
654 systemImage: "tag",
655 color: .orange,
656 isSelected: viewModel.browseRevspec == revision.name
657 )
658 }
659 .buttonStyle(.plain)
660 }
661 }
662 }
663
664 if !viewModel.bookmarks.isEmpty {
665 Section("Bookmarks") {
666 ForEach(viewModel.bookmarks) { revision in
667 Button {
668 Task {
669 await viewModel.changeBrowseRevspec(revision.name)
670 isPresented = false
671 }
672 } label: {
673 refRow(
674 title: revision.name,
675 systemImage: "bookmark",
676 color: .purple,
677 isSelected: viewModel.browseRevspec == revision.name
678 )
679 }
680 .buttonStyle(.plain)
681 }
682 }
683 }
684 }
685 .listStyle(.insetGrouped)
686 .navigationTitle("Select Ref")
687 .navigationBarTitleDisplayMode(.inline)
688 .toolbar {
689 ToolbarItem(placement: .cancellationAction) {
690 Button("Cancel") {
691 isPresented = false
692 }
693 }
694 }
695 }
696 }
697
698 private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
699 HStack(spacing: 12) {
700 Image(systemName: systemImage)
701 .foregroundStyle(color)
702
703 Text(title)
704 .font(.body.monospaced())
705 .foregroundStyle(.primary)
706
707 Spacer()
708
709 if isSelected {
710 Image(systemName: "checkmark")
711 .font(.caption.weight(.semibold))
712 .foregroundStyle(.tint)
713 }
714 }
715 .contentShape(Rectangle())
716 }
717}
718
719private struct HgFileRow: View {
720 let file: HgFile
721
722 var body: some View {
723 Label {
724 Text(displayName)
725 .font(.body.monospaced())
726 .lineLimit(1)
727 } icon: {
728 Image(systemName: file.isDirectory ? "folder.fill" : "doc")
729 .foregroundStyle(file.isDirectory ? .blue : .secondary)
730 }
731 }
732
733 private var displayName: String {
734 file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
735 }
736}