krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.2: 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}