Hutch/Views/Repositories/RepositoryListView.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Repositories/RepositoryListView.swift history · blame · raw

224 lines · 8818 bytes

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