Hutch/Views/Repositories/RepositoryListView.swift
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}