krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.1: 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 Button("Delete", role: .destructive) {
68 Task {
69 if await viewModel?.deletePaste() == true {
70 onDeleted?(paste.id)
71 dismiss()
72 }
73 }
74 }
75 } message: {
76 Text("This paste will be permanently removed.")
77 }
78 .task {
79 if viewModel == nil {
80 let vm = PasteDetailViewModel(
81 pasteID: paste.id,
82 initialPaste: paste,
83 service: PasteService(client: appState.client)
84 )
85 viewModel = vm
86 await vm.loadPaste()
87 }
88 }
89 }
90
91 private var currentPaste: Paste? {
92 viewModel?.paste ?? paste
93 }
94
95 private var displayTitle: String {
96 if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
97 return filename
98 }
99 return "Paste \(paste.id)"
100 }
101
102 @ViewBuilder
103 private func content(_ viewModel: PasteDetailViewModel) -> some View {
104 @Bindable var vm = viewModel
105
106 if viewModel.isLoading, viewModel.paste == nil {
107 SRHTLoadingStateView(message: "Loading paste…")
108 } else if let error = viewModel.error, viewModel.paste == nil {
109 SRHTErrorStateView(
110 title: "Couldn't Load Paste",
111 message: error,
112 retryAction: { await viewModel.loadPaste() }
113 )
114 } else if let paste = viewModel.paste {
115 List {
116 Section("Details") {
117 LabeledContent("ID", value: paste.id)
118 LabeledContent("Owner", value: paste.user.canonicalName)
119 LabeledContent("Created", value: paste.created.relativeDescription)
120 LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
121 LabeledContent("Files", value: "\(paste.files.count)")
122 }
123
124 if paste.files.count > 1 {
125 Section("Files") {
126 Picker("Selected File", selection: Binding(
127 get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
128 set: { viewModel.selectFile(hash: $0) }
129 )) {
130 ForEach(paste.files) { file in
131 Text(file.filename ?? String(file.hash.prefix(8)))
132 .tag(file.hash)
133 }
134 }
135 }
136 }
137
138 if let file = viewModel.selectedFile {
139 Section("Current File") {
140 if let filename = file.filename, !filename.isEmpty {
141 LabeledContent("Filename", value: filename)
142 }
143 LabeledContent("Hash", value: file.hash)
144 }
145
146 Section {
147 if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
148 SRHTLoadingStateView(message: "Loading paste contents…")
149 .frame(minHeight: 180)
150 } else if let contents = viewModel.selectedFileContents {
151 PasteCodeBlock(text: contents)
152 } else {
153 Text("This file’s contents are unavailable.")
154 .foregroundStyle(.secondary)
155 }
156 } header: {
157 Text("Contents")
158 }
159 }
160 }
161 .listStyle(.insetGrouped)
162 .srhtErrorBanner(error: $vm.error)
163 .refreshable {
164 await viewModel.loadPaste()
165 }
166 }
167 }
168
169 private func visibilityLabel(_ visibility: Visibility) -> String {
170 switch visibility {
171 case .public:
172 return "Public"
173 case .unlisted:
174 return "Unlisted"
175 case .private:
176 return "Private"
177 }
178 }
179}
180
181private struct PasteCodeBlock: View {
182 let text: String
183
184 var body: some View {
185 ScrollView([.horizontal, .vertical], showsIndicators: true) {
186 Text(text.isEmpty ? " " : text)
187 .font(.system(.body, design: .monospaced))
188 .textSelection(.enabled)
189 .frame(maxWidth: .infinity, alignment: .leading)
190 .padding(.vertical, 4)
191 }
192 .frame(minHeight: 220)
193 }
194}
195
196private struct PasteVisibilitySheet: View {
197 let currentVisibility: Visibility
198 let isUpdating: Bool
199 let onSave: (Visibility) async -> Void
200
201 @Environment(\.dismiss) private var dismiss
202 @State private var visibility: Visibility
203
204 init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
205 self.currentVisibility = currentVisibility
206 self.isUpdating = isUpdating
207 self.onSave = onSave
208 _visibility = State(initialValue: currentVisibility)
209 }
210
211 var body: some View {
212 NavigationStack {
213 List {
214 ForEach(visibilityOptions, id: \.self) { option in
215 Button {
216 visibility = option
217 } label: {
218 HStack {
219 VStack(alignment: .leading, spacing: 2) {
220 Text(title(for: option))
221 .foregroundStyle(.primary)
222 Text(description(for: option))
223 .font(.caption)
224 .foregroundStyle(.secondary)
225 }
226
227 Spacer()
228
229 if visibility == option {
230 Image(systemName: "checkmark")
231 .foregroundStyle(.tint)
232 }
233 }
234 .contentShape(Rectangle())
235 }
236 .buttonStyle(.plain)
237 }
238 }
239 .listStyle(.insetGrouped)
240 .navigationTitle("Visibility")
241 .navigationBarTitleDisplayMode(.inline)
242 .toolbar {
243 ToolbarItem(placement: .cancellationAction) {
244 Button("Cancel") { dismiss() }
245 }
246 ToolbarItem(placement: .confirmationAction) {
247 Button("Save") {
248 Task {
249 await onSave(visibility)
250 }
251 }
252 .disabled(isUpdating || visibility == currentVisibility)
253 }
254 }
255 .overlay {
256 if isUpdating {
257 ProgressView()
258 }
259 }
260 }
261 }
262
263 private var visibilityOptions: [Visibility] {
264 [.public, .unlisted, .private]
265 }
266
267 private func title(for visibility: Visibility) -> String {
268 switch visibility {
269 case .public:
270 "Public"
271 case .unlisted:
272 "Unlisted"
273 case .private:
274 "Private"
275 }
276 }
277
278 private func description(for visibility: Visibility) -> String {
279 switch visibility {
280 case .public:
281 "Visible to everyone and listed on your profile."
282 case .unlisted:
283 "Visible to anyone with the URL, but not listed on your profile."
284 case .private:
285 "Visible only to explicitly allowed viewers."
286 }
287 }
288}