krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: Hutch/Views/Pastes/PasteListView.swift · raw
1import SwiftUI
2
3struct PasteListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
5 @Environment(AppState.self) private var appState
6 @State private var viewModel: PasteListViewModel?
7 @State private var showCreatePasteSheet = false
8 @State private var createdPaste: Paste?
9 @State private var pasteToDelete: Paste?
10
11 var body: some View {
12 Group {
13 if let viewModel {
14 content(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading pastes…")
17 }
18 }
19 .navigationTitle("Pastes")
20 .toolbar {
21 if viewModel != nil {
22 ToolbarItem(placement: .topBarTrailing) {
23 Button {
24 showCreatePasteSheet = true
25 } label: {
26 Image(systemName: "plus")
27 }
28 .accessibilityLabel("Create paste")
29 }
30 }
31 }
32 .sheet(isPresented: $showCreatePasteSheet) {
33 if let viewModel {
34 CreatePasteSheet(viewModel: viewModel) { paste in
35 showCreatePasteSheet = false
36 createdPaste = paste
37 }
38 }
39 }
40 .navigationDestination(isPresented: Binding(
41 get: { createdPaste != nil },
42 set: { isPresented in
43 if !isPresented {
44 createdPaste = nil
45 }
46 }
47 )) {
48 if let createdPaste {
49 PasteDetailView(
50 paste: createdPaste,
51 onUpdated: { updated in
52 viewModel?.upsertPaste(updated)
53 },
54 onDeleted: { id in
55 viewModel?.removePaste(id: id)
56 }
57 )
58 }
59 }
60 .task {
61 if viewModel == nil {
62 let vm = PasteListViewModel(service: PasteService(client: appState.client))
63 viewModel = vm
64 await vm.loadPastes()
65 }
66 }
67 }
68
69 @ViewBuilder
70 private func content(_ viewModel: PasteListViewModel) -> some View {
71 @Bindable var vm = viewModel
72
73 List {
74 ForEach(viewModel.filteredPastes) { paste in
75 NavigationLink(value: paste) {
76 PasteRowView(paste: paste)
77 .equatable()
78 }
79 .swipeActions(edge: .leading, allowsFullSwipe: true) {
80 if swipeActionsEnabled {
81 Button {
82 Task { await viewModel.cycleVisibility(for: paste) }
83 } label: {
84 Label(
85 nextVisibilityLabel(for: paste.visibility),
86 systemImage: nextVisibilityIcon(for: paste.visibility)
87 )
88 }
89 .tint(nextVisibilityColor(for: paste.visibility))
90 }
91 }
92 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
93 if swipeActionsEnabled {
94 Button {
95 pasteToDelete = paste
96 } label: {
97 Label("Delete", systemImage: "trash")
98 }
99 .tint(.red)
100 }
101 }
102 .task {
103 await viewModel.loadMoreIfNeeded(currentItem: paste)
104 }
105 }
106 .themedRow()
107
108 if viewModel.isLoadingMore {
109 HStack {
110 Spacer()
111 ProgressView()
112 Spacer()
113 }
114 .listRowSeparator(.hidden)
115 .themedRow()
116 }
117 }
118 .themedList()
119 .listStyle(.plain)
120 .searchable(
121 text: $vm.searchText,
122 placement: .navigationBarDrawer(displayMode: .always),
123 prompt: "Search pastes"
124 )
125 .overlay {
126 if viewModel.isLoading, viewModel.pastes.isEmpty {
127 SRHTLoadingStateView(message: "Loading pastes…")
128 } else if let error = viewModel.error, viewModel.pastes.isEmpty {
129 SRHTErrorStateView(
130 title: "Couldn't Load Pastes",
131 message: error,
132 retryAction: { await viewModel.loadPastes() }
133 )
134 } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
135 ContentUnavailableView.search(text: viewModel.searchText)
136 } else if viewModel.pastes.isEmpty {
137 ContentUnavailableView(
138 "No Pastes",
139 systemImage: "doc.on.clipboard",
140 description: Text("Your pastes will appear here.")
141 )
142 }
143 }
144 .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
145 await viewModel.loadPastes()
146 }
147 .srhtErrorBanner(error: $vm.error)
148 .alert("Delete Paste?", isPresented: Binding(
149 get: { pasteToDelete != nil },
150 set: { if !$0 { pasteToDelete = nil } }
151 )) {
152 Button("Cancel", role: .cancel) {
153 pasteToDelete = nil
154 }
155 Button("Delete", role: .destructive) {
156 if let paste = pasteToDelete {
157 pasteToDelete = nil
158 Task {
159 await viewModel.deletePaste(paste)
160 }
161 }
162 }
163 } message: {
164 Text("This paste will be permanently deleted from SourceHut.")
165 }
166 .refreshable {
167 await viewModel.loadPastes()
168 }
169 .navigationDestination(for: Paste.self) { paste in
170 PasteDetailView(
171 paste: paste,
172 onUpdated: { updated in
173 viewModel.upsertPaste(updated)
174 },
175 onDeleted: { id in
176 viewModel.removePaste(id: id)
177 }
178 )
179 }
180 }
181
182 private func nextVisibilityLabel(for visibility: Visibility) -> String {
183 switch visibility {
184 case .publicVisibility:
185 return "Make Unlisted"
186 case .unlisted:
187 return "Make Private"
188 case .privateVisibility:
189 return "Make Public"
190 }
191 }
192
193 private func nextVisibilityIcon(for visibility: Visibility) -> String {
194 switch visibility {
195 case .publicVisibility:
196 return "eye.slash"
197 case .unlisted:
198 return "lock"
199 case .privateVisibility:
200 return "globe"
201 }
202 }
203
204 private func nextVisibilityColor(for visibility: Visibility) -> Color {
205 switch visibility {
206 case .publicVisibility:
207 return .orange
208 case .unlisted:
209 return .red
210 case .privateVisibility:
211 return .green
212 }
213 }
214}
215
216private struct PasteRowView: View, Equatable {
217 let paste: Paste
218
219 var body: some View {
220 HStack(alignment: .top, spacing: 12) {
221 Image(systemName: "doc.text")
222 .foregroundStyle(.secondary)
223 .frame(width: 20)
224
225 VStack(alignment: .leading, spacing: 4) {
226 Text(primaryTitle)
227 .font(.subheadline.weight(.medium))
228 .lineLimit(1)
229
230 Text(secondaryLine)
231 .font(.caption)
232 .foregroundStyle(.secondary)
233 .lineLimit(2)
234
235 HStack(spacing: 8) {
236 VisibilityBadge(visibility: paste.visibility)
237 Text("•")
238 .foregroundStyle(.tertiary)
239 Text(paste.created.relativeDescription)
240 .foregroundStyle(.tertiary)
241 }
242 .font(.caption2)
243 }
244 }
245 .padding(.vertical, 2)
246 }
247
248 private var primaryTitle: String {
249 if let filename = paste.files.first?.filename, !filename.isEmpty {
250 return filename
251 }
252 return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
253 }
254
255 private var secondaryLine: String {
256 var parts: [String] = [paste.user.canonicalName]
257 if paste.files.count > 1 {
258 parts.append("\(paste.files.count) files")
259 } else {
260 parts.append("1 file")
261 }
262 if let firstHash = paste.files.first?.hash {
263 parts.append(String(firstHash.prefix(8)))
264 }
265 return parts.joined(separator: " • ")
266 }
267}
268
269private struct CreatePasteSheet: View {
270 let viewModel: PasteListViewModel
271 let onCreated: (Paste) -> Void
272
273 @Environment(\.dismiss) private var dismiss
274 @State private var files = [PasteUploadDraft()]
275 @State private var visibility: Visibility = .unlisted
276
277 var body: some View {
278 NavigationStack {
279 Form {
280 Section("Files") {
281 ForEach($files) { fileBinding in
282 VStack(alignment: .leading, spacing: 8) {
283 TextField("Filename (optional)", text: fileBinding.filename)
284 .autocorrectionDisabled()
285 .textInputAutocapitalization(.never)
286
287 ZStack(alignment: .topLeading) {
288 if fileBinding.wrappedValue.contents.isEmpty {
289 Text("Paste contents")
290 .foregroundStyle(.tertiary)
291 .padding(.top, 8)
292 .padding(.leading, 5)
293 .allowsHitTesting(false)
294 }
295
296 TextEditor(text: fileBinding.contents)
297 .font(.system(.body, design: .monospaced))
298 .frame(minHeight: 180)
299 }
300 }
301 .padding(.vertical, 4)
302 }
303 .onDelete { offsets in
304 files.remove(atOffsets: offsets)
305 if files.isEmpty {
306 files = [PasteUploadDraft()]
307 }
308 }
309 .themedRow()
310
311 Button {
312 files.append(PasteUploadDraft())
313 } label: {
314 Label("Add File", systemImage: "plus")
315 }
316 .themedRow()
317 }
318
319 Section("Visibility") {
320 Picker("Visibility", selection: $visibility) {
321 Text("Public").tag(Visibility.publicVisibility)
322 Text("Unlisted").tag(Visibility.unlisted)
323 Text("Private").tag(Visibility.privateVisibility)
324 }
325 .themedRow()
326 }
327
328 Section {
329 Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
330 .font(.footnote)
331 .foregroundStyle(.secondary)
332 .themedRow()
333 }
334
335 if let error = viewModel.error {
336 Section {
337 Label(error, systemImage: "exclamationmark.triangle.fill")
338 .foregroundStyle(.red)
339 .themedRow()
340 }
341 }
342 }
343 .themedList()
344 .navigationTitle("New Paste")
345 .navigationBarTitleDisplayMode(.inline)
346 .toolbar {
347 ToolbarItem(placement: .cancellationAction) {
348 Button("Cancel") { dismiss() }
349 }
350 ToolbarItem(placement: .confirmationAction) {
351 Button {
352 Task {
353 if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
354 onCreated(paste)
355 }
356 }
357 } label: {
358 if viewModel.isCreatingPaste {
359 ProgressView()
360 .controlSize(.small)
361 } else {
362 Text("Create Paste")
363 }
364 }
365 .disabled(!hasValidContent || viewModel.isCreatingPaste)
366 }
367 }
368 }
369 }
370
371 private var hasValidContent: Bool {
372 files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
373 }
374}