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