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