gitbay/Views/Repos/RepoListView.swift
183 lines · 6344 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.
56private struct 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 Label(error, systemImage: "exclamationmark.triangle")
84 .foregroundStyle(.red)
85 .font(.subheadline)
86 }
87 }
88 }
89 .navigationTitle("New Repository")
90 .navigationBarTitleDisplayMode(.inline)
91 .toolbar {
92 ToolbarItem(placement: .cancellationAction) {
93 Button("Cancel") { dismiss() }
94 }
95 ToolbarItem(placement: .confirmationAction) {
96 if model.working {
97 ProgressView()
98 } else {
99 Button("Create") {
100 Task {
101 if await model.create(path: path, isPrivate: isPrivate) != nil {
102 onCreated()
103 }
104 }
105 }
106 .disabled(!path.contains("/")
107 || path.hasSuffix("/")
108 || path.hasPrefix("/"))
109 .accessibilityIdentifier("repo-create-submit")
110 }
111 }
112 }
113 .interactiveDismissDisabled(model.working)
114 .onAppear {
115 if !seeded {
116 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
117 seeded = true
118 }
119 }
120 }
121 }
122}
123
124private struct RepoRow: View {
125 let repo: RepoSummary
126
127 var body: some View {
128 VStack(alignment: .leading, spacing: 3) {
129 HStack(spacing: 6) {
130 Text(repo.path)
131 .font(.body.weight(.medium))
132 .lineLimit(1)
133 if repo.isPrivate {
134 Image(systemName: "lock.fill")
135 .font(.caption2)
136 .foregroundStyle(.secondary)
137 }
138 if repo.isArchived {
139 Text("archived")
140 .font(.caption2)
141 .padding(.horizontal, 5)
142 .padding(.vertical, 1)
143 .background(.quaternary, in: Capsule())
144 }
145 }
146 if let description = repo.description, !description.isEmpty {
147 Text(description)
148 .font(.subheadline)
149 .foregroundStyle(.secondary)
150 .lineLimit(2)
151 }
152 }
153 .padding(.vertical, 2)
154 }
155}
156
157/// The account switcher and sign-out, on every top-level screen.
158struct AccountMenu: ToolbarContent {
159
160 @Environment(SessionStore.self) private var session
161
162 var body: some ToolbarContent {
163 ToolbarItem(placement: .topBarTrailing) {
164 Menu {
165 if let current = session.current {
166 Section(current.label) {
167 Button("Sign Out", role: .destructive) {
168 session.remove(current)
169 }
170 }
171 }
172 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
173 Button(account.label) {
174 session.activate(account)
175 }
176 }
177 NavigationLink("Add Account", value: RepoRoute.addAccount)
178 } label: {
179 Image(systemName: "person.crop.circle")
180 }
181 }
182 }
183}