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