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}