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