gitbay/Views/Repos/RepoListView.swift
316 lines · 12075 bytes
5 symbols in this file
1import SwiftUI
2
3struct RepoListView: View {
4
5 @Environment(SessionStore.self) private var session
6 @State private var model: RepoListViewModel
7 @State private var createModel: RepoCreateViewModel
8 @State private var composing = false
9 @State private var importing = false
10
11 init(client: GitbayClient) {
12 _model = State(initialValue: RepoListViewModel(client: client))
13 _createModel = State(initialValue: RepoCreateViewModel(client: client))
14 }
15
16 var body: some View {
17 RoutingList {
18 ForEach(model.visibleRepos) { repo in
19 NavigationLink(value: RepoRoute.repo(repo.path)) {
20 RepoRow(repo: repo)
21 }
22 }
23 if model.searchText.isEmpty {
24 PageFooter(list: model.list)
25 }
26 }
27 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
28 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
29 .navigationTitle("Repositories")
30 .toolbar {
31 ToolbarItem(placement: .topBarTrailing) {
32 RouteLink(value: RepoRoute.bookmarks) {
33 Image(systemName: "bookmark")
34 }
35 .accessibilityLabel("Bookmarks")
36 .accessibilityIdentifier("repo-bookmarks-button")
37 }
38 ToolbarItem(placement: .topBarTrailing) {
39 Menu {
40 Button("New Repository", systemImage: "plus") { composing = true }
41 Button("Import from a Remote", systemImage: "square.and.arrow.down") { importing = true }
42 } label: {
43 Image(systemName: "plus")
44 }
45 .accessibilityLabel("Add Repository")
46 .accessibilityIdentifier("repo-create-button")
47 }
48 }
49 .sheet(isPresented: $composing) {
50 RepoCreateSheet(
51 model: createModel,
52 ownerPrefix: session.current?.username ?? ""
53 ) {
54 composing = false
55 Task { await model.load() }
56 }
57 }
58 .sheet(isPresented: $importing) {
59 RepoImportSheet(
60 model: createModel,
61 ownerPrefix: session.current?.username ?? ""
62 ) {
63 importing = false
64 Task { await model.load() }
65 }
66 }
67 .task { await model.load() }
68 .refreshable { await model.load() }
69 }
70}
71
72/// `repo create <owner/name> [--private]` — the name carries the owner,
73/// so org repos are created by typing org/name.
74struct RepoCreateSheet: View {
75
76 let model: RepoCreateViewModel
77 let ownerPrefix: String
78 let onCreated: () -> Void
79
80 @Environment(\.dismiss) private var dismiss
81 @State private var path = ""
82 @State private var isPrivate = false
83 @State private var seeded = false
84
85 var body: some View {
86 NavigationStack {
87 Form {
88 Section {
89 TextField("owner/name", text: $path)
90 .autocorrectionDisabled()
91 .textInputAutocapitalization(.never)
92 .accessibilityIdentifier("repo-create-path")
93 } footer: {
94 Text("Use org/name to create under an organization you can write to.")
95 }
96 Section {
97 Toggle("Private", isOn: $isPrivate)
98 }
99 if let error = model.errorMessage {
100 Section {
101 GBNotice(error)
102 }
103 }
104 }
105 .navigationTitle("New Repository")
106 .navigationBarTitleDisplayMode(.inline)
107 .toolbar {
108 ToolbarItem(placement: .cancellationAction) {
109 Button("Cancel") { dismiss() }
110 }
111 ToolbarItem(placement: .confirmationAction) {
112 if model.working {
113 ProgressView()
114 } else {
115 Button("Create") {
116 Task {
117 if await model.create(path: path, isPrivate: isPrivate) != nil {
118 onCreated()
119 }
120 }
121 }
122 .disabled(!path.contains("/")
123 || path.hasSuffix("/")
124 || path.hasPrefix("/"))
125 .accessibilityIdentifier("repo-create-submit")
126 }
127 }
128 }
129 .interactiveDismissDisabled(model.working)
130 .onAppear {
131 if !seeded {
132 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
133 seeded = true
134 }
135 }
136 }
137 }
138}
139
140/// `repo import <owner/name> --from <url> [--private] [--token-stdin]`.
141/// A token for a private source goes over stdin; the command refuses one
142/// embedded in the URL.
143struct RepoImportSheet: View {
144
145 let model: RepoCreateViewModel
146 let ownerPrefix: String
147 let onImported: () -> Void
148
149 @Environment(\.dismiss) private var dismiss
150 @State private var path = ""
151 @State private var from = ""
152 @State private var isPrivate = false
153 @State private var token = ""
154 @State private var seeded = false
155
156 private var pathLooksRight: Bool {
157 path.contains("/") && !path.hasSuffix("/") && !path.hasPrefix("/")
158 }
159
160 var body: some View {
161 NavigationStack {
162 Form {
163 Section {
164 TextField("owner/name", text: $path)
165 .autocorrectionDisabled()
166 .textInputAutocapitalization(.never)
167 .accessibilityIdentifier("repo-import-path")
168 TextField("https://…/repo.git", text: $from)
169 .autocorrectionDisabled()
170 .textInputAutocapitalization(.never)
171 .keyboardType(.URL)
172 .accessibilityIdentifier("repo-import-from")
173 } footer: {
174 Text("The source is cloned once; it is not kept as a mirror.")
175 }
176 Section {
177 Toggle("Private", isOn: $isPrivate)
178 SecureField("Token (private source)", text: $token)
179 .autocorrectionDisabled()
180 .textInputAutocapitalization(.never)
181 } footer: {
182 Text("A token is sent once to clone and is not stored.")
183 }
184 if let error = model.errorMessage {
185 Section {
186 GBNotice(error)
187 }
188 }
189 }
190 .navigationTitle("Import Repository")
191 .navigationBarTitleDisplayMode(.inline)
192 .toolbar {
193 ToolbarItem(placement: .cancellationAction) {
194 Button("Cancel") { dismiss() }
195 }
196 ToolbarItem(placement: .confirmationAction) {
197 if model.working {
198 ProgressView()
199 } else {
200 Button("Import") {
201 Task {
202 if await model.importRepo(
203 path: path, from: from, isPrivate: isPrivate, token: token) != nil {
204 // A secure field that still holds text as the sheet
205 // goes makes iOS offer to save it as a password.
206 token = ""
207 await Task.yield()
208 onImported()
209 }
210 }
211 }
212 .disabled(!pathLooksRight || from.trimmingCharacters(in: .whitespaces).isEmpty)
213 .accessibilityIdentifier("repo-import-submit")
214 }
215 }
216 }
217 .interactiveDismissDisabled(model.working)
218 .onAppear {
219 if !seeded {
220 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
221 seeded = true
222 }
223 }
224 }
225 }
226}
227
228struct RepoRow: View {
229 let repo: RepoSummary
230
231 var body: some View {
232 VStack(alignment: .leading, spacing: 3) {
233 HStack(spacing: 6) {
234 Text(repo.path)
235 .font(.gbSans(.body).weight(.medium))
236 .lineLimit(1)
237 if repo.isPrivate {
238 Image(systemName: "lock.fill")
239 .font(.gbSans(.caption2))
240 .foregroundStyle(.secondary)
241 }
242 if repo.isArchived {
243 GBChip("archived", .secondary)
244 }
245 }
246 if let description = repo.description, !description.isEmpty {
247 Text(description)
248 .font(.gbSans(.subheadline))
249 .foregroundStyle(.secondary)
250 .lineLimit(2)
251 }
252 }
253 .padding(.vertical, 2)
254 }
255}
256
257/// The account switcher and sign-out. It lives on the My Profile tab
258/// only: the other tabs are about the instance, not about who you are.
259struct AccountMenu: ToolbarContent {
260
261 @Environment(SessionStore.self) private var session
262
263 /// Whether the signed-in account is an instance admin, read from
264 /// `whoami` on the My Profile tab and passed in.
265 let isAdmin: Bool
266
267 var body: some ToolbarContent {
268 ToolbarItem(placement: .topBarTrailing) {
269 Menu {
270 if let current = session.current {
271 Section(current.label) {
272 NavigationLink(value: RepoRoute.account) {
273 Label("Keys & Email", systemImage: "key")
274 }
275 NavigationLink(value: RepoRoute.tokens) {
276 Label("API Tokens", systemImage: "key.horizontal")
277 }
278 NavigationLink(value: RepoRoute.webSessions) {
279 Label("Browser Sessions", systemImage: "macwindow")
280 }
281 NavigationLink(value: SnippetRoute.list(owner: nil)) {
282 Label("Snippets", systemImage: "scissors")
283 }
284 if isAdmin {
285 NavigationLink(value: RepoRoute.admin) {
286 Label("Admin", systemImage: "gearshape.2")
287 }
288 .accessibilityIdentifier("account-menu-admin")
289 }
290 }
291 }
292 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
293 Section("Switch account") {
294 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
295 Button(account.label) {
296 session.activate(account)
297 }
298 }
299 }
300 }
301 Section {
302 NavigationLink("Add Account", value: RepoRoute.addAccount)
303 if let current = session.current {
304 Button("Sign Out", role: .destructive) {
305 Task { await session.remove(current) }
306 }
307 }
308 }
309 } label: {
310 Image(systemName: "person.crop.circle")
311 }
312 .accessibilityLabel("Account")
313 .accessibilityIdentifier("account-menu")
314 }
315 }
316}