gitbay/Views/Repos/RepoView.swift
280 lines · 11987 bytes
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 @State private var actionsModel: RepoActionsViewModel
7 private let client: GitbayClient
8 private let path: String
9 private let host: String
10
11 init(client: GitbayClient, path: String) {
12 self.client = client
13 self.path = path
14 self.host = client.instance.baseURL.host() ?? ""
15 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
16 _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path))
17 }
18
19 @State private var confirmingArchive = false
20 @State private var confirmingFork = false
21 @State private var forkName = ""
22 @State private var forkDestination: RepoRoute?
23
24 var body: some View {
25 List {
26 if let detail = model.state.value {
27 if let error = model.actionError {
28 Section {
29 GBNotice(error, .gbWarn)
30 }
31 }
32 if let error = actionsModel.actionError {
33 Section {
34 GBNotice(error, .gbWarn)
35 }
36 }
37 if let notice = actionsModel.notice {
38 Section {
39 GBNotice(notice, .gbOK)
40 }
41 }
42 header(detail)
43
44 Section {
45 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
46 Label("Files", systemImage: "folder")
47 }
48 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
49 Label("History", systemImage: "clock")
50 }
51 NavigationLink(value: RepoRoute.refs(repo: path)) {
52 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
53 }
54 NavigationLink(value: MRRoute.list(repo: path)) {
55 Label("Merge Requests", systemImage: "arrow.triangle.merge")
56 }
57 NavigationLink(value: IssueRoute.list(repo: path)) {
58 Label("Issues", systemImage: "smallcircle.filled.circle")
59 }
60 NavigationLink(value: RepoRoute.milestones(repo: path)) {
61 Label("Milestones", systemImage: "flag")
62 }
63 NavigationLink(value: RepoRoute.labels(repo: path)) {
64 Label("Labels", systemImage: "tag")
65 }
66 NavigationLink(value: BuildRoute.list(repo: path)) {
67 Label("Builds", systemImage: "hammer")
68 }
69 NavigationLink(value: ReleaseRoute.list(repo: path)) {
70 Label("Releases", systemImage: "shippingbox")
71 }
72 NavigationLink(value: RepoRoute.wiki(repo: path)) {
73 Label("Wiki", systemImage: "book")
74 }
75 NavigationLink(value: RepoRoute.grep(repo: path)) {
76 Label("Search in Files", systemImage: "text.magnifyingglass")
77 }
78 NavigationLink(value: RepoRoute.settings(repo: path)) {
79 Label("Settings", systemImage: "gearshape")
80 }
81 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
82 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
83 }
84 }
85
86 if let readme = model.readme {
87 Section("README") {
88 ReadmeView(
89 name: model.readmeName ?? "README.md",
90 content: readme,
91 host: host,
92 repoPath: path
93 )
94 .padding(.vertical, 4)
95 }
96 }
97 }
98 }
99 .overlay { LoadStateOverlay(state: model.state) }
100 .navigationTitle(String(path.split(separator: "/").last ?? ""))
101 .navigationBarTitleDisplayMode(.inline)
102 .toolbar { toolbar }
103 .task { await model.load() }
104 .refreshable { await model.load() }
105 .navigationDestination(item: $forkDestination) { route in
106 if case .repo(let forkedPath) = route {
107 RepoView(client: client, path: forkedPath)
108 }
109 }
110 .alert("Fork \(path)", isPresented: $confirmingFork) {
111 TextField("New name (optional)", text: $forkName)
112 .autocorrectionDisabled()
113 .textInputAutocapitalization(.never)
114 Button("Fork") {
115 Task {
116 if let result = await actionsModel.fork(named: forkName) {
117 forkDestination = .repo(result.path)
118 }
119 }
120 }
121 Button("Cancel", role: .cancel) {}
122 }
123 .confirmationDialog(
124 model.state.value?.isArchived == true
125 ? "Unarchive \(path)?"
126 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
127 isPresented: $confirmingArchive
128 ) {
129 Button(
130 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
131 role: model.state.value?.isArchived == true ? nil : .destructive
132 ) {
133 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
134 }
135 Button("Cancel", role: .cancel) {}
136 }
137 }
138
139 @ToolbarContentBuilder
140 private var toolbar: some ToolbarContent {
141 ToolbarItem(placement: .topBarTrailing) {
142 if let detail = model.state.value {
143 Menu {
144 if let pinned = model.isPinned {
145 Button {
146 Task { await model.setPinned(!pinned) }
147 } label: {
148 Label(pinned ? "Unpin" : "Pin",
149 systemImage: pinned ? "pin.slash" : "pin")
150 }
151 }
152 Button {
153 Task {
154 await actionsModel.setBookmarked(!detail.bookmarked)
155 await model.reloadDetail()
156 }
157 } label: {
158 Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
159 systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
160 }
161 Divider()
162 Section {
163 let watchState = WatchState(detail.watch)
164 switch watchState {
165 case .watching:
166 Label("Watching", systemImage: "eye")
167 case .muted:
168 Label("Muted", systemImage: "bell.slash")
169 case .default:
170 EmptyView()
171 }
172 if watchState.available.contains(.watch) {
173 Button {
174 Task {
175 await actionsModel.watch()
176 await model.reloadDetail()
177 }
178 } label: {
179 Label("Watch this repository", systemImage: "eye")
180 }
181 }
182 if watchState.available.contains(.unwatch) {
183 Button {
184 Task {
185 await actionsModel.unwatch()
186 await model.reloadDetail()
187 }
188 } label: {
189 if watchState == .muted {
190 Label("Unmute this repository", systemImage: "bell")
191 } else {
192 Label("Stop watching", systemImage: "eye.slash")
193 }
194 }
195 }
196 if watchState.available.contains(.mute) {
197 Button {
198 Task {
199 await actionsModel.mute()
200 await model.reloadDetail()
201 }
202 } label: {
203 Label("Mute this repository", systemImage: "bell.slash")
204 }
205 }
206 }
207 Divider()
208 Button {
209 forkName = ""
210 confirmingFork = true
211 } label: {
212 Label("Fork", systemImage: "tuningfork")
213 }
214 Divider()
215 Button(role: detail.isArchived ? nil : .destructive) {
216 confirmingArchive = true
217 } label: {
218 Label(detail.isArchived ? "Unarchive" : "Archive",
219 systemImage: "archivebox")
220 }
221 } label: {
222 if model.working || actionsModel.working {
223 ProgressView()
224 } else {
225 Image(systemName: "ellipsis.circle")
226 }
227 }
228 .disabled(model.working || actionsModel.working)
229 .accessibilityIdentifier("repo-actions-menu")
230 }
231 }
232 }
233
234 @ViewBuilder
235 private func header(_ detail: RepoDetail) -> some View {
236 Section {
237 VStack(alignment: .leading, spacing: 8) {
238 HStack(spacing: 6) {
239 Text(detail.path)
240 .font(.gbSans(.headline))
241 if detail.visibility == "private" {
242 Image(systemName: "lock.fill")
243 .font(.gbSans(.caption))
244 .foregroundStyle(.secondary)
245 }
246 if detail.isArchived {
247 GBChip("archived", .secondary)
248 }
249 }
250 if let description = detail.description, !description.isEmpty {
251 Text(description)
252 .font(.gbSans(.subheadline))
253 .foregroundStyle(.secondary)
254 }
255 if let website = detail.website, let url = URL(string: website) {
256 Link(website, destination: url)
257 .font(.gbSans(.caption))
258 .lineLimit(1)
259 }
260 HStack(spacing: 12) {
261 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
262 if let topics = detail.topics, !topics.isEmpty {
263 Text(topics.joined(separator: " · "))
264 .lineLimit(1)
265 }
266 }
267 .font(.gbSans(.caption))
268 .foregroundStyle(.secondary)
269 if let forkOf = detail.forkOf {
270 NavigationLink(value: RepoRoute.repo(forkOf)) {
271 Text("Forked from \(forkOf)")
272 }
273 .font(.gbSans(.caption))
274 .foregroundStyle(.secondary)
275 }
276 }
277 .padding(.vertical, 4)
278 }
279 }
280}