krz/hutch

an ios client for sourcehut

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

v3.0.0: 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                .task {
 92                    await viewModel.loadMoreIfNeeded(currentItem: repo)
 93                }
 94            }
 95
 96            if viewModel.isLoadingMore {
 97                HStack {
 98                    Spacer()
 99                    ProgressView()
100                    Spacer()
101                }
102                .listRowSeparator(.hidden)
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()
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 = .public
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                    TextField("Repository name", text: $name)
230                        .textInputAutocapitalization(.never)
231                        .autocorrectionDisabled()
232                    TextField("Short description (optional)", text: $description, axis: .vertical)
233                        .lineLimit(2...4)
234                    Picker("Visibility", selection: $visibility) {
235                        Text("Public").tag(Visibility.public)
236                        Text("Unlisted").tag(Visibility.unlisted)
237                        Text("Private").tag(Visibility.private)
238                    }
239                }
240
241                Section("Import Existing Repository") {
242                    if service == .git {
243                        TextField("Remote URL (optional)", text: $cloneURL)
244                            .textInputAutocapitalization(.never)
245                            .autocorrectionDisabled()
246                            .keyboardType(.URL)
247                        Text("Import an existing Git repository from a remote URL.")
248                            .font(.footnote)
249                            .foregroundStyle(.secondary)
250                    } else {
251                        Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
252                            .font(.footnote)
253                            .foregroundStyle(.secondary)
254                    }
255                }
256            }
257            .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
258            .navigationBarTitleDisplayMode(.inline)
259            .toolbar {
260                ToolbarItem(placement: .cancellationAction) {
261                    Button("Cancel") { dismiss() }
262                }
263                ToolbarItem(placement: .confirmationAction) {
264                    Button {
265                        Task {
266                            if let repository = await viewModel.createRepository(
267                                service: service,
268                                name: name,
269                                description: description,
270                                visibility: visibility,
271                                cloneURL: cloneURL
272                            ) {
273                                onCreated(repository)
274                            }
275                        }
276                    } label: {
277                        if viewModel.isCreatingRepository {
278                            ProgressView()
279                                .controlSize(.small)
280                        } else {
281                            Text("Create")
282                        }
283                    }
284                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
285                }
286            }
287        }
288    }
289}