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