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