import SwiftUI struct RepoListView: View { @Environment(SessionStore.self) private var session @State private var model: RepoListViewModel @State private var createModel: RepoCreateViewModel @State private var composing = false @State private var importing = false init(client: GitbayClient) { _model = State(initialValue: RepoListViewModel(client: client)) _createModel = State(initialValue: RepoCreateViewModel(client: client)) } var body: some View { RoutingList { ForEach(model.visibleRepos) { repo in NavigationLink(value: RepoRoute.repo(repo.path)) { RepoRow(repo: repo) } } if model.searchText.isEmpty { PageFooter(list: model.list) } } .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") .navigationTitle("Repositories") .toolbar { ToolbarItem(placement: .topBarTrailing) { RouteLink(value: RepoRoute.bookmarks) { Image(systemName: "bookmark") } .accessibilityLabel("Bookmarks") .accessibilityIdentifier("repo-bookmarks-button") } ToolbarItem(placement: .topBarTrailing) { Menu { Button("New Repository", systemImage: "plus") { composing = true } Button("Import from a Remote", systemImage: "square.and.arrow.down") { importing = true } } label: { Image(systemName: "plus") } .accessibilityLabel("Add Repository") .accessibilityIdentifier("repo-create-button") } } .sheet(isPresented: $composing) { RepoCreateSheet( model: createModel, ownerPrefix: session.current?.username ?? "" ) { composing = false Task { await model.load() } } } .sheet(isPresented: $importing) { RepoImportSheet( model: createModel, ownerPrefix: session.current?.username ?? "" ) { importing = false Task { await model.load() } } } .task { await model.load() } .refreshable { await model.load() } } } /// `repo create [--private]` — the name carries the owner, /// so org repos are created by typing org/name. struct RepoCreateSheet: View { let model: RepoCreateViewModel let ownerPrefix: String let onCreated: () -> Void @Environment(\.dismiss) private var dismiss @State private var path = "" @State private var isPrivate = false @State private var seeded = false var body: some View { NavigationStack { Form { Section { TextField("owner/name", text: $path) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("repo-create-path") } footer: { Text("Use org/name to create under an organization you can write to.") } Section { Toggle("Private", isOn: $isPrivate) } if let error = model.errorMessage { Section { GBNotice(error) } } } .navigationTitle("New Repository") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Create") { Task { if await model.create(path: path, isPrivate: isPrivate) != nil { onCreated() } } } .disabled(!path.contains("/") || path.hasSuffix("/") || path.hasPrefix("/")) .accessibilityIdentifier("repo-create-submit") } } } .interactiveDismissDisabled(model.working) .onAppear { if !seeded { path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/" seeded = true } } } } } /// `repo import --from [--private] [--token-stdin]`. /// A token for a private source goes over stdin; the command refuses one /// embedded in the URL. struct RepoImportSheet: View { let model: RepoCreateViewModel let ownerPrefix: String let onImported: () -> Void @Environment(\.dismiss) private var dismiss @State private var path = "" @State private var from = "" @State private var isPrivate = false @State private var token = "" @State private var seeded = false private var pathLooksRight: Bool { path.contains("/") && !path.hasSuffix("/") && !path.hasPrefix("/") } var body: some View { NavigationStack { Form { Section { TextField("owner/name", text: $path) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("repo-import-path") TextField("https://…/repo.git", text: $from) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) .accessibilityIdentifier("repo-import-from") } footer: { Text("The source is cloned once; it is not kept as a mirror.") } Section { Toggle("Private", isOn: $isPrivate) SecureField("Token (private source)", text: $token) .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Text("A token is sent once to clone and is not stored.") } if let error = model.errorMessage { Section { GBNotice(error) } } } .navigationTitle("Import Repository") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Import") { Task { if await model.importRepo( path: path, from: from, isPrivate: isPrivate, token: token) != nil { onImported() } } } .disabled(!pathLooksRight || from.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("repo-import-submit") } } } .interactiveDismissDisabled(model.working) .onAppear { if !seeded { path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/" seeded = true } } } } } struct RepoRow: View { let repo: RepoSummary var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(repo.path) .font(.gbSans(.body).weight(.medium)) .lineLimit(1) if repo.isPrivate { Image(systemName: "lock.fill") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } if repo.isArchived { GBChip("archived", .secondary) } } if let description = repo.description, !description.isEmpty { Text(description) .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) .lineLimit(2) } } .padding(.vertical, 2) } } /// The account switcher and sign-out. It lives on the My Profile tab /// only: the other tabs are about the instance, not about who you are. struct AccountMenu: ToolbarContent { @Environment(SessionStore.self) private var session /// Whether the signed-in account is an instance admin, read from /// `whoami` on the My Profile tab and passed in. let isAdmin: Bool var body: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Menu { if let current = session.current { Section(current.label) { NavigationLink(value: RepoRoute.account) { Label("Keys & Email", systemImage: "key") } NavigationLink(value: RepoRoute.tokens) { Label("API Tokens", systemImage: "key.horizontal") } NavigationLink(value: RepoRoute.webSessions) { Label("Browser Sessions", systemImage: "macwindow") } NavigationLink(value: SnippetRoute.list(owner: nil)) { Label("Snippets", systemImage: "scissors") } if isAdmin { NavigationLink(value: RepoRoute.admin) { Label("Admin", systemImage: "gearshape.2") } .accessibilityIdentifier("account-menu-admin") } } } if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { Section("Switch account") { ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in Button(account.label) { session.activate(account) } } } } Section { NavigationLink("Add Account", value: RepoRoute.addAccount) if let current = session.current { Button("Sign Out", role: .destructive) { Task { await session.remove(current) } } } } } label: { Image(systemName: "person.crop.circle") } .accessibilityLabel("Account") .accessibilityIdentifier("account-menu") } } }