gitbay/Views/Repos/RepoListView.swift
290 lines · 10652 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 var body: some ToolbarContent {
258 ToolbarItem(placement: .topBarTrailing) {
259 Menu {
260 if let current = session.current {
261 Section(current.label) {
262 NavigationLink(value: RepoRoute.account) {
263 Label("Keys & Email", systemImage: "key")
264 }
265 }
266 }
267 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
268 Section("Switch account") {
269 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
270 Button(account.label) {
271 session.activate(account)
272 }
273 }
274 }
275 }
276 Section {
277 NavigationLink("Add Account", value: RepoRoute.addAccount)
278 if let current = session.current {
279 Button("Sign Out", role: .destructive) {
280 session.remove(current)
281 }
282 }
283 }
284 } label: {
285 Image(systemName: "person.crop.circle")
286 }
287 .accessibilityIdentifier("account-menu")
288 }
289 }
290}