gitbay/Views/Repos/RepoView.swift
281 lines · 12054 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 .accessibilityIdentifier("repo-settings-link")
82 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
83 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
84 }
85 }
86
87 if let readme = model.readme {
88 Section("README") {
89 ReadmeView(
90 name: model.readmeName ?? "README.md",
91 content: readme,
92 host: host,
93 repoPath: path
94 )
95 .padding(.vertical, 4)
96 }
97 }
98 }
99 }
100 .overlay { LoadStateOverlay(state: model.state) }
101 .navigationTitle(String(path.split(separator: "/").last ?? ""))
102 .navigationBarTitleDisplayMode(.inline)
103 .toolbar { toolbar }
104 .task { await model.load() }
105 .refreshable { await model.load() }
106 .navigationDestination(item: $forkDestination) { route in
107 if case .repo(let forkedPath) = route {
108 RepoView(client: client, path: forkedPath)
109 }
110 }
111 .alert("Fork \(path)", isPresented: $confirmingFork) {
112 TextField("New name (optional)", text: $forkName)
113 .autocorrectionDisabled()
114 .textInputAutocapitalization(.never)
115 Button("Fork") {
116 Task {
117 if let result = await actionsModel.fork(named: forkName) {
118 forkDestination = .repo(result.path)
119 }
120 }
121 }
122 Button("Cancel", role: .cancel) {}
123 }
124 .confirmationDialog(
125 model.state.value?.isArchived == true
126 ? "Unarchive \(path)?"
127 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
128 isPresented: $confirmingArchive
129 ) {
130 Button(
131 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
132 role: model.state.value?.isArchived == true ? nil : .destructive
133 ) {
134 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
135 }
136 Button("Cancel", role: .cancel) {}
137 }
138 }
139
140 @ToolbarContentBuilder
141 private var toolbar: some ToolbarContent {
142 ToolbarItem(placement: .topBarTrailing) {
143 if let detail = model.state.value {
144 Menu {
145 if let pinned = model.isPinned {
146 Button {
147 Task { await model.setPinned(!pinned) }
148 } label: {
149 Label(pinned ? "Unpin" : "Pin",
150 systemImage: pinned ? "pin.slash" : "pin")
151 }
152 }
153 Button {
154 Task {
155 await actionsModel.setBookmarked(!detail.bookmarked)
156 await model.reloadDetail()
157 }
158 } label: {
159 Label(detail.bookmarked ? "Unbookmark" : "Bookmark",
160 systemImage: detail.bookmarked ? "bookmark.slash" : "bookmark")
161 }
162 Divider()
163 Section {
164 let watchState = WatchState(detail.watch)
165 switch watchState {
166 case .watching:
167 Label("Watching", systemImage: "eye")
168 case .muted:
169 Label("Muted", systemImage: "bell.slash")
170 case .default:
171 EmptyView()
172 }
173 if watchState.available.contains(.watch) {
174 Button {
175 Task {
176 await actionsModel.watch()
177 await model.reloadDetail()
178 }
179 } label: {
180 Label("Watch this repository", systemImage: "eye")
181 }
182 }
183 if watchState.available.contains(.unwatch) {
184 Button {
185 Task {
186 await actionsModel.unwatch()
187 await model.reloadDetail()
188 }
189 } label: {
190 if watchState == .muted {
191 Label("Unmute this repository", systemImage: "bell")
192 } else {
193 Label("Stop watching", systemImage: "eye.slash")
194 }
195 }
196 }
197 if watchState.available.contains(.mute) {
198 Button {
199 Task {
200 await actionsModel.mute()
201 await model.reloadDetail()
202 }
203 } label: {
204 Label("Mute this repository", systemImage: "bell.slash")
205 }
206 }
207 }
208 Divider()
209 Button {
210 forkName = ""
211 confirmingFork = true
212 } label: {
213 Label("Fork", systemImage: "tuningfork")
214 }
215 Divider()
216 Button(role: detail.isArchived ? nil : .destructive) {
217 confirmingArchive = true
218 } label: {
219 Label(detail.isArchived ? "Unarchive" : "Archive",
220 systemImage: "archivebox")
221 }
222 } label: {
223 if model.working || actionsModel.working {
224 ProgressView()
225 } else {
226 Image(systemName: "ellipsis.circle")
227 }
228 }
229 .disabled(model.working || actionsModel.working)
230 .accessibilityIdentifier("repo-actions-menu")
231 }
232 }
233 }
234
235 @ViewBuilder
236 private func header(_ detail: RepoDetail) -> some View {
237 Section {
238 VStack(alignment: .leading, spacing: 8) {
239 HStack(spacing: 6) {
240 Text(detail.path)
241 .font(.gbSans(.headline))
242 if detail.visibility == "private" {
243 Image(systemName: "lock.fill")
244 .font(.gbSans(.caption))
245 .foregroundStyle(.secondary)
246 }
247 if detail.isArchived {
248 GBChip("archived", .secondary)
249 }
250 }
251 if let description = detail.description, !description.isEmpty {
252 Text(description)
253 .font(.gbSans(.subheadline))
254 .foregroundStyle(.secondary)
255 }
256 if let website = detail.website, let url = URL(string: website) {
257 Link(website, destination: url)
258 .font(.gbSans(.caption))
259 .lineLimit(1)
260 }
261 HStack(spacing: 12) {
262 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
263 if let topics = detail.topics, !topics.isEmpty {
264 Text(topics.joined(separator: " · "))
265 .lineLimit(1)
266 }
267 }
268 .font(.gbSans(.caption))
269 .foregroundStyle(.secondary)
270 if let forkOf = detail.forkOf {
271 NavigationLink(value: RepoRoute.repo(forkOf)) {
272 Text("Forked from \(forkOf)")
273 }
274 .font(.gbSans(.caption))
275 .foregroundStyle(.secondary)
276 }
277 }
278 .padding(.vertical, 4)
279 }
280 }
281}