gitbay/Views/Repos/RepoView.swift
163 lines · 6630 bytes
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 private let path: String
7
8 init(client: GitbayClient, path: String) {
9 self.path = path
10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
11 }
12
13 @State private var confirmingArchive = false
14
15 var body: some View {
16 List {
17 if let detail = model.state.value {
18 if let error = model.actionError {
19 Section {
20 Label(error, systemImage: "hand.raised")
21 .foregroundStyle(.orange)
22 .font(.subheadline)
23 }
24 }
25 header(detail)
26
27 Section {
28 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
29 Label("Files", systemImage: "folder")
30 }
31 NavigationLink(value: RepoRoute.log(repo: path)) {
32 Label("History", systemImage: "clock")
33 }
34 NavigationLink(value: MRRoute.list(repo: path)) {
35 Label("Merge Requests", systemImage: "arrow.triangle.merge")
36 }
37 NavigationLink(value: IssueRoute.list(repo: path)) {
38 Label("Issues", systemImage: "smallcircle.filled.circle")
39 }
40 NavigationLink(value: BuildRoute.list(repo: path)) {
41 Label("Builds", systemImage: "hammer")
42 }
43 NavigationLink(value: ReleaseRoute.list(repo: path)) {
44 Label("Releases", systemImage: "shippingbox")
45 }
46 NavigationLink(value: RepoRoute.grep(repo: path)) {
47 Label("Search in Files", systemImage: "text.magnifyingglass")
48 }
49 NavigationLink(value: RepoRoute.settings(repo: path)) {
50 Label("Settings", systemImage: "gearshape")
51 }
52 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
53 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
54 }
55 }
56
57 if let readme = model.readme {
58 Section("README") {
59 MarkdownView(markdown: readme)
60 .padding(.vertical, 4)
61 }
62 }
63 }
64 }
65 .overlay { LoadStateOverlay(state: model.state) }
66 .navigationTitle(String(path.split(separator: "/").last ?? ""))
67 .navigationBarTitleDisplayMode(.inline)
68 .toolbar { toolbar }
69 .task { await model.load() }
70 .refreshable { await model.load() }
71 .confirmationDialog(
72 model.state.value?.isArchived == true
73 ? "Unarchive \(path)?"
74 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
75 isPresented: $confirmingArchive
76 ) {
77 Button(
78 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
79 role: model.state.value?.isArchived == true ? nil : .destructive
80 ) {
81 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
82 }
83 Button("Cancel", role: .cancel) {}
84 }
85 }
86
87 @ToolbarContentBuilder
88 private var toolbar: some ToolbarContent {
89 ToolbarItem(placement: .topBarTrailing) {
90 if let detail = model.state.value {
91 Menu {
92 if let pinned = model.isPinned {
93 Button {
94 Task { await model.setPinned(!pinned) }
95 } label: {
96 Label(pinned ? "Unpin" : "Pin",
97 systemImage: pinned ? "pin.slash" : "pin")
98 }
99 }
100 Divider()
101 Button(role: detail.isArchived ? nil : .destructive) {
102 confirmingArchive = true
103 } label: {
104 Label(detail.isArchived ? "Unarchive" : "Archive",
105 systemImage: "archivebox")
106 }
107 } label: {
108 if model.working {
109 ProgressView()
110 } else {
111 Image(systemName: "ellipsis.circle")
112 }
113 }
114 .disabled(model.working)
115 .accessibilityIdentifier("repo-actions-menu")
116 }
117 }
118 }
119
120 @ViewBuilder
121 private func header(_ detail: RepoDetail) -> some View {
122 Section {
123 VStack(alignment: .leading, spacing: 8) {
124 HStack(spacing: 6) {
125 Text(detail.path)
126 .font(.headline)
127 if detail.visibility == "private" {
128 Image(systemName: "lock.fill")
129 .font(.caption)
130 .foregroundStyle(.secondary)
131 }
132 if detail.isArchived {
133 Text("archived")
134 .font(.caption2)
135 .padding(.horizontal, 5)
136 .padding(.vertical, 1)
137 .background(.quaternary, in: Capsule())
138 }
139 }
140 if let description = detail.description, !description.isEmpty {
141 Text(description)
142 .font(.subheadline)
143 .foregroundStyle(.secondary)
144 }
145 if let website = detail.website, let url = URL(string: website) {
146 Link(website, destination: url)
147 .font(.caption)
148 .lineLimit(1)
149 }
150 HStack(spacing: 12) {
151 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
152 if let topics = detail.topics, !topics.isEmpty {
153 Text(topics.joined(separator: " · "))
154 .lineLimit(1)
155 }
156 }
157 .font(.caption)
158 .foregroundStyle(.secondary)
159 }
160 .padding(.vertical, 4)
161 }
162 }
163}