gitbay/Views/Repos/RepoListView.swift
309 lines · 11621 bytes
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 onImported()
205 }
206 }
207 }
208 .disabled(!pathLooksRight || from.trimmingCharacters(in: .whitespaces).isEmpty)
209 .accessibilityIdentifier("repo-import-submit")
210 }
211 }
212 }
213 .interactiveDismissDisabled(model.working)
214 .onAppear {
215 if !seeded {
216 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
217 seeded = true
218 }
219 }
220 }
221 }
222}
223
224struct RepoRow: View {
225 let repo: RepoSummary
226
227 var body: some View {
228 VStack(alignment: .leading, spacing: 3) {
229 HStack(spacing: 6) {
230 Text(repo.path)
231 .font(.gbSans(.body).weight(.medium))
232 .lineLimit(1)
233 if repo.isPrivate {
234 Image(systemName: "lock.fill")
235 .font(.gbSans(.caption2))
236 .foregroundStyle(.secondary)
237 }
238 if repo.isArchived {
239 GBChip("archived", .secondary)
240 }
241 }
242 if let description = repo.description, !description.isEmpty {
243 Text(description)
244 .font(.gbSans(.subheadline))
245 .foregroundStyle(.secondary)
246 .lineLimit(2)
247 }
248 }
249 .padding(.vertical, 2)
250 }
251}
252
253/// The account switcher and sign-out. It lives on the My Profile tab
254/// only: the other tabs are about the instance, not about who you are.
255struct AccountMenu: ToolbarContent {
256
257 @Environment(SessionStore.self) private var session
258
259 /// Whether the signed-in account is an instance admin, read from
260 /// `whoami` on the My Profile tab and passed in.
261 let isAdmin: Bool
262
263 var body: some ToolbarContent {
264 ToolbarItem(placement: .topBarTrailing) {
265 Menu {
266 if let current = session.current {
267 Section(current.label) {
268 NavigationLink(value: RepoRoute.account) {
269 Label("Keys & Email", systemImage: "key")
270 }
271 NavigationLink(value: RepoRoute.tokens) {
272 Label("API Tokens", systemImage: "key.horizontal")
273 }
274 NavigationLink(value: SnippetRoute.list(owner: nil)) {
275 Label("Snippets", systemImage: "scissors")
276 }
277 if isAdmin {
278 NavigationLink(value: RepoRoute.admin) {
279 Label("Admin", systemImage: "gearshape.2")
280 }
281 .accessibilityIdentifier("account-menu-admin")
282 }
283 }
284 }
285 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
286 Section("Switch account") {
287 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
288 Button(account.label) {
289 session.activate(account)
290 }
291 }
292 }
293 }
294 Section {
295 NavigationLink("Add Account", value: RepoRoute.addAccount)
296 if let current = session.current {
297 Button("Sign Out", role: .destructive) {
298 Task { await session.remove(current) }
299 }
300 }
301 }
302 } label: {
303 Image(systemName: "person.crop.circle")
304 }
305 .accessibilityLabel("Account")
306 .accessibilityIdentifier("account-menu")
307 }
308 }
309}