gitbay/Views/Repos/RepoListView.swift

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

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