gitbay/Views/Repos/RepoListView.swift
192 lines · 6790 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 AccountMenu()
39 }
40 .sheet(isPresented: $composing) {
41 RepoCreateSheet(
42 model: createModel,
43 ownerPrefix: session.current?.username ?? ""
44 ) {
45 composing = false
46 Task { await model.load() }
47 }
48 }
49 .task { await model.load() }
50 .refreshable { await model.load() }
51 }
52}
53
54/// `repo create <owner/name> [--private]` — the name carries the owner,
55/// so org repos are created by typing org/name.
56struct RepoCreateSheet: View {
57
58 let model: RepoCreateViewModel
59 let ownerPrefix: String
60 let onCreated: () -> Void
61
62 @Environment(\.dismiss) private var dismiss
63 @State private var path = ""
64 @State private var isPrivate = false
65 @State private var seeded = false
66
67 var body: some View {
68 NavigationStack {
69 Form {
70 Section {
71 TextField("owner/name", text: $path)
72 .autocorrectionDisabled()
73 .textInputAutocapitalization(.never)
74 .accessibilityIdentifier("repo-create-path")
75 } footer: {
76 Text("Use org/name to create under an organization you can write to.")
77 }
78 Section {
79 Toggle("Private", isOn: $isPrivate)
80 }
81 if let error = model.errorMessage {
82 Section {
83 GBNotice(error)
84 }
85 }
86 }
87 .navigationTitle("New Repository")
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 ToolbarItem(placement: .cancellationAction) {
91 Button("Cancel") { dismiss() }
92 }
93 ToolbarItem(placement: .confirmationAction) {
94 if model.working {
95 ProgressView()
96 } else {
97 Button("Create") {
98 Task {
99 if await model.create(path: path, isPrivate: isPrivate) != nil {
100 onCreated()
101 }
102 }
103 }
104 .disabled(!path.contains("/")
105 || path.hasSuffix("/")
106 || path.hasPrefix("/"))
107 .accessibilityIdentifier("repo-create-submit")
108 }
109 }
110 }
111 .interactiveDismissDisabled(model.working)
112 .onAppear {
113 if !seeded {
114 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
115 seeded = true
116 }
117 }
118 }
119 }
120}
121
122private struct RepoRow: View {
123 let repo: RepoSummary
124
125 var body: some View {
126 VStack(alignment: .leading, spacing: 3) {
127 HStack(spacing: 6) {
128 Text(repo.path)
129 .font(.gbSans(.body).weight(.medium))
130 .lineLimit(1)
131 if repo.isPrivate {
132 Image(systemName: "lock.fill")
133 .font(.gbSans(.caption2))
134 .foregroundStyle(.secondary)
135 }
136 if repo.isArchived {
137 GBChip("archived", .secondary)
138 }
139 }
140 if let description = repo.description, !description.isEmpty {
141 Text(description)
142 .font(.gbSans(.subheadline))
143 .foregroundStyle(.secondary)
144 .lineLimit(2)
145 }
146 }
147 .padding(.vertical, 2)
148 }
149}
150
151/// The account switcher and sign-out, on every top-level screen.
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.profile(current.username)) {
162 Label("My Profile", systemImage: "person.crop.circle")
163 }
164 NavigationLink(value: RepoRoute.account) {
165 Label("Keys & Email", systemImage: "key")
166 }
167 }
168 }
169 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
170 Section("Switch account") {
171 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
172 Button(account.label) {
173 session.activate(account)
174 }
175 }
176 }
177 }
178 Section {
179 NavigationLink("Add Account", value: RepoRoute.addAccount)
180 if let current = session.current {
181 Button("Sign Out", role: .destructive) {
182 session.remove(current)
183 }
184 }
185 }
186 } label: {
187 Image(systemName: "person.crop.circle")
188 }
189 .accessibilityIdentifier("account-menu")
190 }
191 }
192}