krz/hutch

an ios client for sourcehut

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

v2.9.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 showCreateRepositorySheet = false
  8    @State private var createdRepository: RepositorySummary?
  9
 10    var body: some View {
 11        Group {
 12            if let viewModel {
 13                listContent(viewModel)
 14            } else {
 15                SRHTLoadingStateView(message: "Loading repositories…")
 16            }
 17        }
 18        .navigationTitle("Repositories")
 19        .toolbar {
 20            if viewModel != nil {
 21                ToolbarItem(placement: .topBarTrailing) {
 22                    Button {
 23                        showCreateRepositorySheet = true
 24                    } label: {
 25                        Image(systemName: "plus")
 26                    }
 27                    .accessibilityLabel("Create repository")
 28                }
 29            }
 30        }
 31        .sheet(isPresented: $showCreateRepositorySheet) {
 32            if let viewModel {
 33                CreateRepositorySheet(viewModel: viewModel) { repository in
 34                    showCreateRepositorySheet = false
 35                    createdRepository = repository
 36                }
 37            }
 38        }
 39        .navigationDestination(isPresented: Binding(
 40            get: { createdRepository != nil },
 41            set: { isPresented in
 42                if !isPresented {
 43                    createdRepository = nil
 44                }
 45            }
 46        )) {
 47            if let createdRepository {
 48                RepositoryDetailView(repository: createdRepository) {
 49                    viewModel?.removeRepository(id: createdRepository.id)
 50                }
 51            }
 52        }
 53        .task {
 54            if viewModel == nil {
 55                viewModel = RepositoryListViewModel(client: appState.client)
 56            }
 57        }
 58    }
 59
 60    @ViewBuilder
 61    private func listContent(_ viewModel: RepositoryListViewModel) -> some View {
 62        @Bindable var vm = viewModel
 63
 64        List {
 65            ForEach(viewModel.repositories) { repo in
 66                NavigationLink(value: repo) {
 67                    RepositoryRowView(
 68                        repository: repo,
 69                        buildStatus: viewModel.latestBuildStatus(for: repo)
 70                    )
 71                }
 72                .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
 73                .task {
 74                    await viewModel.loadMoreIfNeeded(currentItem: repo)
 75                }
 76            }
 77
 78            if viewModel.isLoadingMore {
 79                HStack {
 80                    Spacer()
 81                    ProgressView()
 82                    Spacer()
 83                }
 84                .listRowSeparator(.hidden)
 85            }
 86        }
 87        .listStyle(.plain)
 88        .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
 89        .overlay {
 90            if viewModel.isLoading, viewModel.repositories.isEmpty {
 91                SRHTLoadingStateView(message: "Loading repositories…")
 92            } else if let error = viewModel.error, viewModel.repositories.isEmpty {
 93                SRHTErrorStateView(
 94                    title: "Couldn't Load Repositories",
 95                    message: error,
 96                    retryAction: { await viewModel.loadRepositories() }
 97                )
 98            } else if viewModel.repositories.isEmpty, viewModel.error == nil {
 99                if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
100                    ContentUnavailableView(
101                        "No Repositories",
102                        systemImage: "book.closed",
103                        description: Text("You don't have any repositories yet.")
104                    )
105                } else {
106                    ContentUnavailableView.search
107                }
108            }
109        }
110        .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
111            await viewModel.loadRepositories()
112        }
113        .srhtErrorBanner(error: $vm.error)
114        .refreshable {
115            await viewModel.loadRepositories()
116        }
117        .task {
118            await viewModel.loadRepositories()
119        }
120        .onChange(of: viewModel.searchText) { oldValue, newValue in
121            // Cancel previous search task
122            searchTask?.cancel()
123            
124            // Clear results immediately when search text is cleared
125            if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
126                viewModel.resetSearch()
127                Task {
128                    await viewModel.loadRepositories()
129                }
130                return
131            }
132            
133            // Debounce search to avoid excessive API calls
134            searchTask = Task {
135                try? await Task.sleep(for: .milliseconds(350))
136                guard !Task.isCancelled else { return }
137                await viewModel.loadRepositories(search: newValue)
138            }
139        }
140        .navigationDestination(for: RepositorySummary.self) { repo in
141            RepositoryDetailView(repository: repo) {
142                viewModel.removeRepository(id: repo.id)
143            }
144        }
145    }
146}
147
148private struct CreateRepositorySheet: View {
149    let viewModel: RepositoryListViewModel
150    let onCreated: (RepositorySummary) -> Void
151
152    @Environment(\.dismiss) private var dismiss
153    @State private var name = ""
154    @State private var description = ""
155    @State private var cloneURL = ""
156    @State private var visibility: Visibility = .public
157    @State private var service: RepositoryCreationService = .git
158
159    var body: some View {
160        NavigationStack {
161            Form {
162                Section("Repository Details") {
163                    Picker("Version Control", selection: $service) {
164                        ForEach(RepositoryCreationService.allCases) { service in
165                            Text(service.displayName).tag(service)
166                        }
167                    }
168                    TextField("Repository name", text: $name)
169                        .textInputAutocapitalization(.never)
170                        .autocorrectionDisabled()
171                    TextField("Short description (optional)", text: $description, axis: .vertical)
172                        .lineLimit(2...4)
173                    Picker("Visibility", selection: $visibility) {
174                        Text("Public").tag(Visibility.public)
175                        Text("Unlisted").tag(Visibility.unlisted)
176                        Text("Private").tag(Visibility.private)
177                    }
178                }
179
180                Section("Import Existing Repository") {
181                    if service == .git {
182                        TextField("Remote URL (optional)", text: $cloneURL)
183                            .textInputAutocapitalization(.never)
184                            .autocorrectionDisabled()
185                            .keyboardType(.URL)
186                        Text("Import an existing Git repository from a remote URL.")
187                            .font(.footnote)
188                            .foregroundStyle(.secondary)
189                    } else {
190                        Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
191                            .font(.footnote)
192                            .foregroundStyle(.secondary)
193                    }
194                }
195            }
196            .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
197            .navigationBarTitleDisplayMode(.inline)
198            .toolbar {
199                ToolbarItem(placement: .cancellationAction) {
200                    Button("Cancel") { dismiss() }
201                }
202                ToolbarItem(placement: .confirmationAction) {
203                    Button {
204                        Task {
205                            if let repository = await viewModel.createRepository(
206                                service: service,
207                                name: name,
208                                description: description,
209                                visibility: visibility,
210                                cloneURL: cloneURL
211                            ) {
212                                onCreated(repository)
213                            }
214                        }
215                    } label: {
216                        if viewModel.isCreatingRepository {
217                            ProgressView()
218                                .controlSize(.small)
219                        } else {
220                            Text("Create")
221                        }
222                    }
223                    .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
224                }
225            }
226        }
227    }
228}