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}