gitbay/Views/Repos/RepoListView.swift
189 lines · 6646 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
10 init(client: GitbayClient) {
11 _model = State(initialValue: RepoListViewModel(client: client))
12 _createModel = State(initialValue: RepoCreateViewModel(client: client))
13 }
14
15 var body: some View {
16 List {
17 ForEach(model.visibleRepos) { repo in
18 NavigationLink(value: RepoRoute.repo(repo.path)) {
19 RepoRow(repo: repo)
20 }
21 }
22 if model.searchText.isEmpty {
23 PageFooter(list: model.list)
24 }
25 }
26 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
27 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
28 .navigationTitle("Repositories")
29 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 Button {
32 composing = true
33 } label: {
34 Image(systemName: "plus")
35 }
36 .accessibilityIdentifier("repo-create-button")
37 }
38 }
39 .sheet(isPresented: $composing) {
40 RepoCreateSheet(
41 model: createModel,
42 ownerPrefix: session.current?.username ?? ""
43 ) {
44 composing = false
45 Task { await model.load() }
46 }
47 }
48 .task { await model.load() }
49 .refreshable { await model.load() }
50 }
51}
52
53/// `repo create <owner/name> [--private]` — the name carries the owner,
54/// so org repos are created by typing org/name.
55struct RepoCreateSheet: View {
56
57 let model: RepoCreateViewModel
58 let ownerPrefix: String
59 let onCreated: () -> Void
60
61 @Environment(\.dismiss) private var dismiss
62 @State private var path = ""
63 @State private var isPrivate = false
64 @State private var seeded = false
65
66 var body: some View {
67 NavigationStack {
68 Form {
69 Section {
70 TextField("owner/name", text: $path)
71 .autocorrectionDisabled()
72 .textInputAutocapitalization(.never)
73 .accessibilityIdentifier("repo-create-path")
74 } footer: {
75 Text("Use org/name to create under an organization you can write to.")
76 }
77 Section {
78 Toggle("Private", isOn: $isPrivate)
79 }
80 if let error = model.errorMessage {
81 Section {
82 GBNotice(error)
83 }
84 }
85 }
86 .navigationTitle("New Repository")
87 .navigationBarTitleDisplayMode(.inline)
88 .toolbar {
89 ToolbarItem(placement: .cancellationAction) {
90 Button("Cancel") { dismiss() }
91 }
92 ToolbarItem(placement: .confirmationAction) {
93 if model.working {
94 ProgressView()
95 } else {
96 Button("Create") {
97 Task {
98 if await model.create(path: path, isPrivate: isPrivate) != nil {
99 onCreated()
100 }
101 }
102 }
103 .disabled(!path.contains("/")
104 || path.hasSuffix("/")
105 || path.hasPrefix("/"))
106 .accessibilityIdentifier("repo-create-submit")
107 }
108 }
109 }
110 .interactiveDismissDisabled(model.working)
111 .onAppear {
112 if !seeded {
113 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
114 seeded = true
115 }
116 }
117 }
118 }
119}
120
121private struct RepoRow: View {
122 let repo: RepoSummary
123
124 var body: some View {
125 VStack(alignment: .leading, spacing: 3) {
126 HStack(spacing: 6) {
127 Text(repo.path)
128 .font(.gbSans(.body).weight(.medium))
129 .lineLimit(1)
130 if repo.isPrivate {
131 Image(systemName: "lock.fill")
132 .font(.gbSans(.caption2))
133 .foregroundStyle(.secondary)
134 }
135 if repo.isArchived {
136 GBChip("archived", .secondary)
137 }
138 }
139 if let description = repo.description, !description.isEmpty {
140 Text(description)
141 .font(.gbSans(.subheadline))
142 .foregroundStyle(.secondary)
143 .lineLimit(2)
144 }
145 }
146 .padding(.vertical, 2)
147 }
148}
149
150/// The account switcher and sign-out. It lives on the My Profile tab
151/// only: the other tabs are about the instance, not about who you are.
152struct AccountMenu: ToolbarContent {
153
154 @Environment(SessionStore.self) private var session
155
156 var body: some ToolbarContent {
157 ToolbarItem(placement: .topBarTrailing) {
158 Menu {
159 if let current = session.current {
160 Section(current.label) {
161 NavigationLink(value: RepoRoute.account) {
162 Label("Keys & Email", systemImage: "key")
163 }
164 }
165 }
166 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
167 Section("Switch account") {
168 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
169 Button(account.label) {
170 session.activate(account)
171 }
172 }
173 }
174 }
175 Section {
176 NavigationLink("Add Account", value: RepoRoute.addAccount)
177 if let current = session.current {
178 Button("Sign Out", role: .destructive) {
179 session.remove(current)
180 }
181 }
182 }
183 } label: {
184 Image(systemName: "person.crop.circle")
185 }
186 .accessibilityIdentifier("account-menu")
187 }
188 }
189}