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}