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