krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.5: Hutch/Views/Repositories/RepositoryListView.swift · raw
1import SwiftUI
2
3struct RepositoryListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: RepositoryListViewModel?
6 @State private var searchTask: Task<Void, Never>?
7 @State private var immediateSearchTask: Task<Void, Never>?
8 @State private var showCreateRepositorySheet = false
9 @State private var createdRepository: RepositorySummary?
10
11 var body: some View {
12 Group {
13 if let viewModel {
14 listContent(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading repositories…")
17 }
18 }
19 .navigationTitle("Repositories")
20 .toolbar {
21 if viewModel != nil {
22 ToolbarItem(placement: .topBarTrailing) {
23 Button {
24 showCreateRepositorySheet = true
25 } label: {
26 Image(systemName: "plus")
27 }
28 .accessibilityLabel("Create repository")
29 }
30 }
31 }
32 .sheet(isPresented: $showCreateRepositorySheet) {
33 if let viewModel {
34 CreateRepositorySheet(viewModel: viewModel) { repository in
35 showCreateRepositorySheet = false
36 createdRepository = repository
37 }
38 }
39 }
40 .navigationDestination(isPresented: Binding(
41 get: { createdRepository != nil },
42 set: { isPresented in
43 if !isPresented {
44 createdRepository = nil
45 }
46 }
47 )) {
48 if let createdRepository {
49 RepositoryDetailView(
50 repository: createdRepository,
51 onRepositoryUpdated: { updatedRepository in
52 self.createdRepository = updatedRepository
53 viewModel?.updateRepository(updatedRepository)
54 }
55 ) {
56 viewModel?.removeRepository(id: createdRepository.id)
57 }
58 }
59 }
60 .task {
61 if viewModel == nil {
62 viewModel = RepositoryListViewModel(client: appState.client, defaults: appState.accountDefaults)
63 }
64 }
65 }
66
67 @ViewBuilder
68 private func listContent(_ viewModel: RepositoryListViewModel) -> some View {
69 @Bindable var vm = viewModel
70
71 List {
72 if viewModel.isSearching {
73 HStack(spacing: 12) {
74 ProgressView()
75 .controlSize(.small)
76 Text("Searching repositories…")
77 .font(.footnote)
78 .foregroundStyle(.secondary)
79 }
80 .listRowSeparator(.hidden)
81 .themedRow()
82 }
83
84 ForEach(viewModel.repositories) { repo in
85 NavigationLink(value: repo) {
86 RepositoryRowView(
87 repository: repo,
88 buildStatus: viewModel.latestBuildStatus(for: repo)
89 )
90 }
91 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
92 }
93 .themedRow()
94
95 if viewModel.isLoadingMore {
96 HStack {
97 Spacer()
98 ProgressView()
99 Spacer()
100 }
101 .listRowSeparator(.hidden)
102 .themedRow()
103 }
104 }
105 .themedList()
106 .listStyle(.plain)
107 .searchable(
108 text: $vm.searchText,
109 placement: .navigationBarDrawer(displayMode: .always),
110 prompt: "Search repositories by name, owner, or description"
111 )
112 .searchSuggestions {
113 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
114 RecentSearchSuggestions(
115 title: "Recent Repository Searches",
116 entries: viewModel.recentSearches
117 ) { query in
118 searchTask?.cancel()
119 immediateSearchTask?.cancel()
120 vm.searchText = query
121 immediateSearchTask = Task {
122 await viewModel.loadRepositories(search: query)
123 }
124 } onClear: {
125 viewModel.clearRecentSearches()
126 }
127 }
128 }
129 .overlay {
130 if viewModel.isLoading, viewModel.repositories.isEmpty {
131 SRHTLoadingStateView(message: "Loading repositories…")
132 } else if let error = viewModel.error, viewModel.repositories.isEmpty {
133 SRHTErrorStateView(
134 title: "Couldn't Load Repositories",
135 message: error,
136 retryAction: { await viewModel.loadRepositories() }
137 )
138 } else if viewModel.repositories.isEmpty, viewModel.error == nil {
139 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
140 ContentUnavailableView(
141 "No Repositories",
142 systemImage: "book.closed",
143 description: Text("You don't have any repositories yet.")
144 )
145 } else {
146 ContentUnavailableView(
147 "No Repository Matches",
148 systemImage: "magnifyingglass",
149 description: Text("No repositories matched “\(viewModel.searchText)”.")
150 )
151 }
152 }
153 }
154 .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
155 await viewModel.loadRepositories()
156 }
157 .srhtErrorBanner(error: $vm.error)
158 .refreshable {
159 await viewModel.loadRepositories(forceRefresh: true)
160 }
161 .task {
162 await viewModel.loadRepositories()
163 }
164 .onSubmit(of: .search) {
165 let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
166 guard !query.isEmpty else { return }
167
168 searchTask?.cancel()
169 immediateSearchTask?.cancel()
170 viewModel.recordRecentSearch(query)
171 immediateSearchTask = Task {
172 await viewModel.loadRepositories(search: query)
173 }
174 }
175 .onChange(of: viewModel.searchText) { oldValue, newValue in
176 // Cancel previous search task
177 searchTask?.cancel()
178 immediateSearchTask?.cancel()
179
180 // Clear results immediately when search text is cleared
181 if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
182 viewModel.resetSearch()
183 Task {
184 await viewModel.loadRepositories()
185 }
186 return
187 }
188
189 // Debounce search to avoid excessive API calls
190 searchTask = Task {
191 try? await Task.sleep(for: .milliseconds(350))
192 guard !Task.isCancelled else { return }
193 await viewModel.loadRepositories(search: newValue)
194 }
195 }
196 .navigationDestination(for: RepositorySummary.self) { repo in
197 RepositoryDetailView(
198 repository: repo,
199 onRepositoryUpdated: { updatedRepository in
200 viewModel.updateRepository(updatedRepository)
201 }
202 ) {
203 viewModel.removeRepository(id: repo.id)
204 }
205 }
206 }
207}
208
209private struct CreateRepositorySheet: View {
210 let viewModel: RepositoryListViewModel
211 let onCreated: (RepositorySummary) -> Void
212
213 @Environment(\.dismiss) private var dismiss
214 @State private var name = ""
215 @State private var description = ""
216 @State private var cloneURL = ""
217 @State private var visibility: Visibility = .publicVisibility
218 @State private var service: RepositoryCreationService = .git
219
220 var body: some View {
221 NavigationStack {
222 Form {
223 Section("Repository Details") {
224 Picker("Version Control", selection: $service) {
225 ForEach(RepositoryCreationService.allCases) { service in
226 Text(service.displayName).tag(service)
227 }
228 }
229 .themedRow()
230 TextField("Repository name", text: $name)
231 .textInputAutocapitalization(.never)
232 .autocorrectionDisabled()
233 .themedRow()
234 TextField("Short description (optional)", text: $description, axis: .vertical)
235 .lineLimit(2...4)
236 .themedRow()
237 Picker("Visibility", selection: $visibility) {
238 Text("Public").tag(Visibility.publicVisibility)
239 Text("Unlisted").tag(Visibility.unlisted)
240 Text("Private").tag(Visibility.privateVisibility)
241 }
242 .themedRow()
243 }
244
245 Section("Import Existing Repository") {
246 if service == .git {
247 TextField("Remote URL (optional)", text: $cloneURL)
248 .textInputAutocapitalization(.never)
249 .autocorrectionDisabled()
250 .keyboardType(.URL)
251 .themedRow()
252 Text("Import an existing Git repository from a remote URL.")
253 .font(.footnote)
254 .foregroundStyle(.secondary)
255 .themedRow()
256 } else {
257 Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
258 .font(.footnote)
259 .foregroundStyle(.secondary)
260 .themedRow()
261 }
262 }
263 }
264 .themedList()
265 .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
266 .navigationBarTitleDisplayMode(.inline)
267 .toolbar {
268 ToolbarItem(placement: .cancellationAction) {
269 Button("Cancel") { dismiss() }
270 }
271 ToolbarItem(placement: .confirmationAction) {
272 Button {
273 Task {
274 if let repository = await viewModel.createRepository(
275 service: service,
276 name: name,
277 description: description,
278 visibility: visibility,
279 cloneURL: cloneURL
280 ) {
281 onCreated(repository)
282 }
283 }
284 } label: {
285 if viewModel.isCreatingRepository {
286 ProgressView()
287 .controlSize(.small)
288 } else {
289 Text("Create")
290 }
291 }
292 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
293 }
294 }
295 }
296 }
297}