krz/hutch

an ios client for sourcehut

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

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