gitbay/Views/Repos/RepoListView.swift

v1.7.0
gitbay-ios/gitbay/Views/Repos/RepoListView.swift history · blame · raw

306 lines · 11450 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: SnippetRoute.list(owner: nil)) {
272                            Label("Snippets", systemImage: "scissors")
273                        }
274                        if isAdmin {
275                            NavigationLink(value: RepoRoute.admin) {
276                                Label("Admin", systemImage: "gearshape.2")
277                            }
278                            .accessibilityIdentifier("account-menu-admin")
279                        }
280                    }
281                }
282                if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
283                    Section("Switch account") {
284                        ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
285                            Button(account.label) {
286                                session.activate(account)
287                            }
288                        }
289                    }
290                }
291                Section {
292                    NavigationLink("Add Account", value: RepoRoute.addAccount)
293                    if let current = session.current {
294                        Button("Sign Out", role: .destructive) {
295                            Task { await session.remove(current) }
296                        }
297                    }
298                }
299            } label: {
300                Image(systemName: "person.crop.circle")
301            }
302            .accessibilityLabel("Account")
303            .accessibilityIdentifier("account-menu")
304        }
305    }
306}