krz/hutch

an ios client for sourcehut

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

v2.11.0: Hutch/Views/Pastes/PasteDetailView.swift · raw

  1import SwiftUI
  2
  3struct PasteDetailView: View {
  4    let paste: Paste
  5    var onUpdated: ((Paste) -> Void)? = nil
  6    var onDeleted: ((String) -> Void)? = nil
  7
  8    @Environment(AppState.self) private var appState
  9    @Environment(\.dismiss) private var dismiss
 10    @State private var viewModel: PasteDetailViewModel?
 11    @State private var showVisibilitySheet = false
 12    @State private var showDeleteConfirmation = false
 13
 14    var body: some View {
 15        Group {
 16            if let viewModel {
 17                content(viewModel)
 18            } else {
 19                SRHTLoadingStateView(message: "Loading paste…")
 20            }
 21        }
 22        .navigationTitle(displayTitle)
 23        .navigationBarTitleDisplayMode(.inline)
 24        .toolbar {
 25            ToolbarItemGroup(placement: .topBarTrailing) {
 26                SRHTShareButton(
 27                    url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
 28                    target: .paste
 29                ) {
 30                    Image(systemName: "square.and.arrow.up")
 31                }
 32
 33                if viewModel != nil {
 34                    Menu {
 35                        Button {
 36                            showVisibilitySheet = true
 37                        } label: {
 38                            Label("Change Visibility", systemImage: "eye")
 39                        }
 40
 41                        Button(role: .destructive) {
 42                            showDeleteConfirmation = true
 43                        } label: {
 44                            Label("Delete Paste", systemImage: "trash")
 45                        }
 46                    } label: {
 47                        Image(systemName: "ellipsis.circle")
 48                    }
 49                }
 50            }
 51        }
 52        .sheet(isPresented: $showVisibilitySheet) {
 53            if let viewModel, let currentPaste {
 54                PasteVisibilitySheet(
 55                    currentVisibility: currentPaste.visibility,
 56                    isUpdating: viewModel.isUpdatingVisibility
 57                ) { visibility in
 58                    if let updated = await viewModel.updateVisibility(visibility) {
 59                        onUpdated?(updated)
 60                        showVisibilitySheet = false
 61                    }
 62                }
 63            }
 64        }
 65        .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
 66            Button("Cancel", role: .cancel) {
 67                // Alert dismissal is implicit; no additional action required.
 68            }
 69            Button("Delete", role: .destructive) {
 70                Task {
 71                    if await viewModel?.deletePaste() == true {
 72                        onDeleted?(paste.id)
 73                        dismiss()
 74                    }
 75                }
 76            }
 77        } message: {
 78            Text("This paste will be permanently removed.")
 79        }
 80        .task {
 81            if viewModel == nil {
 82                let vm = PasteDetailViewModel(
 83                    pasteID: paste.id,
 84                    initialPaste: paste,
 85                    service: PasteService(client: appState.client)
 86                )
 87                viewModel = vm
 88                await vm.loadPaste()
 89            }
 90        }
 91    }
 92
 93    private var currentPaste: Paste? {
 94        viewModel?.paste ?? paste
 95    }
 96
 97    private var displayTitle: String {
 98        if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
 99            return filename
100        }
101        return "Paste \(paste.id)"
102    }
103
104    @ViewBuilder
105    private func content(_ viewModel: PasteDetailViewModel) -> some View {
106        @Bindable var vm = viewModel
107
108        if viewModel.isLoading, viewModel.paste == nil {
109            SRHTLoadingStateView(message: "Loading paste…")
110        } else if let error = viewModel.error, viewModel.paste == nil {
111            SRHTErrorStateView(
112                title: "Couldn't Load Paste",
113                message: error,
114                retryAction: { await viewModel.loadPaste() }
115            )
116        } else if let paste = viewModel.paste {
117            List {
118                Section("Details") {
119                    LabeledContent("ID", value: paste.id)
120                    LabeledContent("Owner", value: paste.user.canonicalName)
121                    LabeledContent("Created", value: paste.created.relativeDescription)
122                    LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
123                    LabeledContent("Files", value: "\(paste.files.count)")
124                }
125
126                if paste.files.count > 1 {
127                    Section("Files") {
128                        Picker("Selected File", selection: Binding(
129                            get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
130                            set: { viewModel.selectFile(hash: $0) }
131                        )) {
132                            ForEach(paste.files) { file in
133                                Text(file.filename ?? String(file.hash.prefix(8)))
134                                    .tag(file.hash)
135                            }
136                        }
137                    }
138                }
139
140                if let file = viewModel.selectedFile {
141                    Section("Current File") {
142                        if let filename = file.filename, !filename.isEmpty {
143                            LabeledContent("Filename", value: filename)
144                        }
145                        LabeledContent("Hash", value: file.hash)
146                    }
147
148                    Section {
149                        if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
150                            SRHTLoadingStateView(message: "Loading paste contents…")
151                                .frame(minHeight: 180)
152                        } else if let contents = viewModel.selectedFileContents {
153                            PasteCodeBlock(text: contents)
154                        } else {
155                            Text("This file’s contents are unavailable.")
156                                .foregroundStyle(.secondary)
157                        }
158                    } header: {
159                        Text("Contents")
160                    }
161                }
162            }
163            .listStyle(.insetGrouped)
164            .srhtErrorBanner(error: $vm.error)
165            .refreshable {
166                await viewModel.loadPaste()
167            }
168        }
169    }
170
171    private func visibilityLabel(_ visibility: Visibility) -> String {
172        switch visibility {
173        case .public:
174            return "Public"
175        case .unlisted:
176            return "Unlisted"
177        case .private:
178            return "Private"
179        }
180    }
181}
182
183private struct PasteCodeBlock: View {
184    let text: String
185
186    var body: some View {
187        ScrollView([.horizontal, .vertical], showsIndicators: true) {
188            Text(text.isEmpty ? " " : text)
189                .font(.system(.body, design: .monospaced))
190                .textSelection(.enabled)
191                .frame(maxWidth: .infinity, alignment: .leading)
192                .padding(.vertical, 4)
193        }
194        .frame(minHeight: 220)
195    }
196}
197
198private struct PasteVisibilitySheet: View {
199    let currentVisibility: Visibility
200    let isUpdating: Bool
201    let onSave: (Visibility) async -> Void
202
203    @Environment(\.dismiss) private var dismiss
204    @State private var visibility: Visibility
205
206    init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
207        self.currentVisibility = currentVisibility
208        self.isUpdating = isUpdating
209        self.onSave = onSave
210        _visibility = State(initialValue: currentVisibility)
211    }
212
213    var body: some View {
214        NavigationStack {
215            List {
216                ForEach(visibilityOptions, id: \.self) { option in
217                    Button {
218                        visibility = option
219                    } label: {
220                        HStack {
221                            VStack(alignment: .leading, spacing: 2) {
222                                Text(title(for: option))
223                                    .foregroundStyle(.primary)
224                                Text(description(for: option))
225                                    .font(.caption)
226                                    .foregroundStyle(.secondary)
227                            }
228
229                            Spacer()
230
231                            if visibility == option {
232                                Image(systemName: "checkmark")
233                                    .foregroundStyle(.tint)
234                            }
235                        }
236                        .contentShape(Rectangle())
237                    }
238                    .buttonStyle(.plain)
239                }
240            }
241            .listStyle(.insetGrouped)
242            .navigationTitle("Visibility")
243            .navigationBarTitleDisplayMode(.inline)
244            .toolbar {
245                ToolbarItem(placement: .cancellationAction) {
246                    Button("Cancel") { dismiss() }
247                }
248                ToolbarItem(placement: .confirmationAction) {
249                    Button("Save") {
250                        Task {
251                            await onSave(visibility)
252                        }
253                    }
254                    .disabled(isUpdating || visibility == currentVisibility)
255                }
256            }
257            .overlay {
258                if isUpdating {
259                    ProgressView()
260                }
261            }
262        }
263    }
264
265    private var visibilityOptions: [Visibility] {
266        [.public, .unlisted, .private]
267    }
268
269    private func title(for visibility: Visibility) -> String {
270        switch visibility {
271        case .public:
272            "Public"
273        case .unlisted:
274            "Unlisted"
275        case .private:
276            "Private"
277        }
278    }
279
280    private func description(for visibility: Visibility) -> String {
281        switch visibility {
282        case .public:
283            "Visible to everyone and listed on your profile."
284        case .unlisted:
285            "Visible to anyone with the URL, but not listed on your profile."
286        case .private:
287            "Visible only to explicitly allowed viewers."
288        }
289    }
290}