krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.3.1: 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}