gitbay/Views/Repos/RepoListView.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Views/Repos/RepoListView.swift history · blame · raw

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}