krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.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 Repository")
221 }
222 }
223 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
224 }
225 }
226 }
227 }
228}