krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.12.1: 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}