krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.4: 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 }
82
83 ForEach(viewModel.repositories) { repo in
84 NavigationLink(value: repo) {
85 RepositoryRowView(
86 repository: repo,
87 buildStatus: viewModel.latestBuildStatus(for: repo)
88 )
89 }
90 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
91 }
92
93 if viewModel.isLoadingMore {
94 HStack {
95 Spacer()
96 ProgressView()
97 Spacer()
98 }
99 .listRowSeparator(.hidden)
100 }
101 }
102 .themedList()
103 .listStyle(.plain)
104 .searchable(
105 text: $vm.searchText,
106 placement: .navigationBarDrawer(displayMode: .always),
107 prompt: "Search repositories by name, owner, or description"
108 )
109 .searchSuggestions {
110 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
111 RecentSearchSuggestions(
112 title: "Recent Repository Searches",
113 entries: viewModel.recentSearches
114 ) { query in
115 searchTask?.cancel()
116 immediateSearchTask?.cancel()
117 vm.searchText = query
118 immediateSearchTask = Task {
119 await viewModel.loadRepositories(search: query)
120 }
121 } onClear: {
122 viewModel.clearRecentSearches()
123 }
124 }
125 }
126 .overlay {
127 if viewModel.isLoading, viewModel.repositories.isEmpty {
128 SRHTLoadingStateView(message: "Loading repositories…")
129 } else if let error = viewModel.error, viewModel.repositories.isEmpty {
130 SRHTErrorStateView(
131 title: "Couldn't Load Repositories",
132 message: error,
133 retryAction: { await viewModel.loadRepositories() }
134 )
135 } else if viewModel.repositories.isEmpty, viewModel.error == nil {
136 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
137 ContentUnavailableView(
138 "No Repositories",
139 systemImage: "book.closed",
140 description: Text("You don't have any repositories yet.")
141 )
142 } else {
143 ContentUnavailableView(
144 "No Repository Matches",
145 systemImage: "magnifyingglass",
146 description: Text("No repositories matched “\(viewModel.searchText)”.")
147 )
148 }
149 }
150 }
151 .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
152 await viewModel.loadRepositories()
153 }
154 .srhtErrorBanner(error: $vm.error)
155 .refreshable {
156 await viewModel.loadRepositories(forceRefresh: true)
157 }
158 .task {
159 await viewModel.loadRepositories()
160 }
161 .onSubmit(of: .search) {
162 let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
163 guard !query.isEmpty else { return }
164
165 searchTask?.cancel()
166 immediateSearchTask?.cancel()
167 viewModel.recordRecentSearch(query)
168 immediateSearchTask = Task {
169 await viewModel.loadRepositories(search: query)
170 }
171 }
172 .onChange(of: viewModel.searchText) { oldValue, newValue in
173 // Cancel previous search task
174 searchTask?.cancel()
175 immediateSearchTask?.cancel()
176
177 // Clear results immediately when search text is cleared
178 if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
179 viewModel.resetSearch()
180 Task {
181 await viewModel.loadRepositories()
182 }
183 return
184 }
185
186 // Debounce search to avoid excessive API calls
187 searchTask = Task {
188 try? await Task.sleep(for: .milliseconds(350))
189 guard !Task.isCancelled else { return }
190 await viewModel.loadRepositories(search: newValue)
191 }
192 }
193 .navigationDestination(for: RepositorySummary.self) { repo in
194 RepositoryDetailView(
195 repository: repo,
196 onRepositoryUpdated: { updatedRepository in
197 viewModel.updateRepository(updatedRepository)
198 }
199 ) {
200 viewModel.removeRepository(id: repo.id)
201 }
202 }
203 }
204}
205
206private struct CreateRepositorySheet: View {
207 let viewModel: RepositoryListViewModel
208 let onCreated: (RepositorySummary) -> Void
209
210 @Environment(\.dismiss) private var dismiss
211 @State private var name = ""
212 @State private var description = ""
213 @State private var cloneURL = ""
214 @State private var visibility: Visibility = .public
215 @State private var service: RepositoryCreationService = .git
216
217 var body: some View {
218 NavigationStack {
219 Form {
220 Section("Repository Details") {
221 Picker("Version Control", selection: $service) {
222 ForEach(RepositoryCreationService.allCases) { service in
223 Text(service.displayName).tag(service)
224 }
225 }
226 TextField("Repository name", text: $name)
227 .textInputAutocapitalization(.never)
228 .autocorrectionDisabled()
229 TextField("Short description (optional)", text: $description, axis: .vertical)
230 .lineLimit(2...4)
231 Picker("Visibility", selection: $visibility) {
232 Text("Public").tag(Visibility.public)
233 Text("Unlisted").tag(Visibility.unlisted)
234 Text("Private").tag(Visibility.private)
235 }
236 }
237
238 Section("Import Existing Repository") {
239 if service == .git {
240 TextField("Remote URL (optional)", text: $cloneURL)
241 .textInputAutocapitalization(.never)
242 .autocorrectionDisabled()
243 .keyboardType(.URL)
244 Text("Import an existing Git repository from a remote URL.")
245 .font(.footnote)
246 .foregroundStyle(.secondary)
247 } else {
248 Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
249 .font(.footnote)
250 .foregroundStyle(.secondary)
251 }
252 }
253 }
254 .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
255 .navigationBarTitleDisplayMode(.inline)
256 .toolbar {
257 ToolbarItem(placement: .cancellationAction) {
258 Button("Cancel") { dismiss() }
259 }
260 ToolbarItem(placement: .confirmationAction) {
261 Button {
262 Task {
263 if let repository = await viewModel.createRepository(
264 service: service,
265 name: name,
266 description: description,
267 visibility: visibility,
268 cloneURL: cloneURL
269 ) {
270 onCreated(repository)
271 }
272 }
273 } label: {
274 if viewModel.isCreatingRepository {
275 ProgressView()
276 .controlSize(.small)
277 } else {
278 Text("Create")
279 }
280 }
281 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
282 }
283 }
284 }
285 }
286}