Commit e8db7f85bc

e8db7f85bc9a393b2e24e329350a54537fedb878

parent: 561c44687f

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 23:02 UTC

feat: redesign paste detail view as full-screen file viewer

Replaces the list-based layout with a full-screen viewer that matches
the repository file tree pattern. File contents are now the primary
focus using CodeFileTextView (line numbers, wrap toggle). A compact
sticky header shows the filename, visibility badge, owner, date, and
abbreviated hash. Paste metadata moves to a Details sheet accessible
via the bottom toolbar. Duplicate share buttons are consolidated: link
sharing lives in the ··· menu and content sharing is in the toolbar.

Fixes: https://todo.sr.ht/~ccleberg/hutch/13

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +2 −2
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 66; 635 CURRENT_PROJECT_VERSION = 67;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 66; 664 CURRENT_PROJECT_VERSION = 67;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
Hutch/App/AppStorageKeys.swift +1
@@ -4,6 +4,7 @@ enum AppStorageKeys {
4 static let contributionGraphsEnabled = "contributionGraphsEnabled" 4 static let contributionGraphsEnabled = "contributionGraphsEnabled"
5 static let activeAccountID = "activeAccountID" 5 static let activeAccountID = "activeAccountID"
6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines" 6 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
7 static let wrapPasteFileLines = "wrapPasteFileLines"
7 static let lookupHistory = "lookupHistory" 8 static let lookupHistory = "lookupHistory"
8 static let recentActivity = "recentActivity" 9 static let recentActivity = "recentActivity"
9 static let scopedSearchHistory = "scopedSearchHistory" 10 static let scopedSearchHistory = "scopedSearchHistory"
Hutch/Views/Pastes/PasteDetailView.swift +235 −65
@@ -10,6 +10,12 @@ struct PasteDetailView: View {
10 @State private var viewModel: PasteDetailViewModel? 10 @State private var viewModel: PasteDetailViewModel?
11 @State private var showVisibilitySheet = false 11 @State private var showVisibilitySheet = false
12 @State private var showDeleteConfirmation = false 12 @State private var showDeleteConfirmation = false
13 @State private var showInfoSheet = false
14 @State private var showFileShareSheet = false
15 @State private var showShareUnavailableAlert = false
16 @State private var didCopyContents = false
17 @State private var copyResetTask: Task<Void, Never>?
18 @AppStorage(AppStorageKeys.wrapPasteFileLines) private var wrapLines = false
13 19
14 var body: some View { 20 var body: some View {
15 Group { 21 Group {
@@ -23,15 +29,14 @@ struct PasteDetailView: View {
23 .navigationBarTitleDisplayMode(.inline) 29 .navigationBarTitleDisplayMode(.inline)
24 .toolbar { 30 .toolbar {
25 ToolbarItemGroup(placement: .topBarTrailing) { 31 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 { 32 if viewModel != nil {
34 Menu { 33 Menu {
34 if let url = currentPaste.flatMap({ SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }) {
35 ShareLink(item: url) {
36 Label("Share Link", systemImage: "link")
37 }
38 }
39
35 Button { 40 Button {
36 showVisibilitySheet = true 41 showVisibilitySheet = true
37 } label: { 42 } label: {
@@ -114,87 +119,252 @@ struct PasteDetailView: View {
114 retryAction: { await viewModel.loadPaste() } 119 retryAction: { await viewModel.loadPaste() }
115 ) 120 )
116 } else if let paste = viewModel.paste { 121 } else if let paste = viewModel.paste {
117 List { 122 VStack(spacing: 0) {
118 Section("Details") { 123 fileHeaderBar(paste: paste, viewModel: viewModel)
119 LabeledContent("ID", value: paste.id) 124 Divider()
120 LabeledContent("Owner", value: paste.user.canonicalName) 125 fileContentArea(viewModel: viewModel)
121 LabeledContent("Created", value: paste.created.relativeDescription) 126 }
122 LabeledContent("Visibility", value: visibilityLabel(paste.visibility)) 127 .safeAreaInset(edge: .bottom, spacing: 0) {
123 LabeledContent("Files", value: "\(paste.files.count)") 128 actionToolbar(viewModel: viewModel)
129 }
130 .srhtErrorBanner(error: $vm.error)
131 .sheet(isPresented: $showInfoSheet) {
132 PasteInfoSheet(paste: paste, selectedFile: viewModel.selectedFile)
133 }
134 .sheet(isPresented: $showFileShareSheet) {
135 if let contents = viewModel.selectedFileContents {
136 FileContentShareSheet(activityItems: [contents])
137 }
138 }
139 .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
140 Button("OK", role: .cancel) {
141 // Alert dismissal is implicit; no additional action required.
124 } 142 }
143 } message: {
144 Text(SRHTShareTarget.file.fallbackMessage)
145 }
146 }
147 }
125 148
126 if paste.files.count > 1 { 149 // MARK: - File Header Bar
127 Section("Files") { 150
128 Picker("Selected File", selection: Binding( 151 private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View {
129 get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, 152 VStack(alignment: .leading, spacing: 6) {
130 set: { viewModel.selectFile(hash: $0) } 153 HStack(alignment: .firstTextBaseline, spacing: 8) {
131 )) { 154 let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } }
132 ForEach(paste.files) { file in 155 Text(filename ?? "Untitled")
133 Text(file.filename ?? String(file.hash.prefix(8))) 156 .font(.subheadline.monospaced())
134 .tag(file.hash) 157 .foregroundStyle(.primary)
135 } 158 .lineLimit(1)
136 } 159
137 } 160 Spacer(minLength: 4)
161
162 VisibilityBadge(visibility: paste.visibility)
163 }
164
165 HStack(spacing: 4) {
166 Text(paste.user.canonicalName)
167 .font(.caption)
168 .foregroundStyle(.secondary)
169
170 Text("·")
171 .font(.caption)
172 .foregroundStyle(.tertiary)
173
174 Text(paste.created.relativeDescription)
175 .font(.caption)
176 .foregroundStyle(.secondary)
177
178 if let hash = viewModel.selectedFile?.hash {
179 Text("·")
180 .font(.caption)
181 .foregroundStyle(.tertiary)
182
183 Text(hash.prefix(8))
184 .font(.caption.monospaced())
185 .foregroundStyle(.tertiary)
138 } 186 }
187 }
139 188
140 if let file = viewModel.selectedFile { 189 if paste.files.count > 1 {
141 Section("Current File") { 190 Picker(
142 if let filename = file.filename, !filename.isEmpty { 191 "File",
143 LabeledContent("Filename", value: filename) 192 selection: Binding(
144 } 193 get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
145 LabeledContent("Hash", value: file.hash) 194 set: { viewModel.selectFile(hash: $0) }
195 )
196 ) {
197 ForEach(paste.files) { file in
198 Text(file.filename ?? String(file.hash.prefix(8)))
199 .tag(file.hash)
146 } 200 }
201 }
202 .pickerStyle(.segmented)
203 }
204 }
205 .padding(.horizontal)
206 .padding(.vertical, 10)
207 .background(.bar)
208 }
147 209
148 Section { 210 // MARK: - File Content Area
149 if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { 211
150 SRHTLoadingStateView(message: "Loading paste contents…") 212 @ViewBuilder
151 .frame(minHeight: 180) 213 private func fileContentArea(viewModel: PasteDetailViewModel) -> some View {
152 } else if let contents = viewModel.selectedFileContents { 214 if let file = viewModel.selectedFile {
153 PasteCodeBlock(text: contents) 215 if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
154 } else { 216 SRHTLoadingStateView(message: "Loading contents…")
155 Text("This file’s contents are unavailable.") 217 } else if let contents = viewModel.selectedFileContents {
156 .foregroundStyle(.secondary) 218 CodeFileTextView(
157 } 219 text: contents,
158 } header: { 220 fileName: file.filename ?? "",
159 Text("Contents") 221 wrapLines: wrapLines
222 )
223 } else {
224 ContentUnavailableView(
225 "Contents Unavailable",
226 systemImage: "doc.questionmark",
227 description: Text("This file's contents could not be loaded.")
228 )
229 }
230 } else {
231 ContentUnavailableView(
232 "No File Selected",
233 systemImage: "doc",
234 description: Text("Select a file to view its contents.")
235 )
236 }
237 }
238
239 // MARK: - Action Toolbar
240
241 private func actionToolbar(viewModel: PasteDetailViewModel) -> some View {
242 HStack(spacing: 0) {
243 toolbarButton(
244 title: didCopyContents ? "Copied" : "Copy All",
245 systemImage: didCopyContents ? "checkmark" : "doc.on.doc"
246 ) {
247 if let contents = viewModel.selectedFileContents {
248 UIPasteboard.general.string = contents
249 didCopyContents = true
250 copyResetTask?.cancel()
251 copyResetTask = Task {
252 try? await Task.sleep(for: .seconds(2))
253 guard !Task.isCancelled else { return }
254 await MainActor.run { didCopyContents = false }
160 } 255 }
161 } 256 }
162 } 257 }
163 .listStyle(.insetGrouped) 258 .disabled(viewModel.selectedFileContents == nil)
164 .srhtErrorBanner(error: $vm.error) 259
165 .refreshable { 260 toolbarButton(
166 await viewModel.loadPaste() 261 title: "Share",
262 systemImage: "square.and.arrow.up"
263 ) {
264 if let contents = viewModel.selectedFileContents, !contents.isEmpty {
265 showFileShareSheet = true
266 } else {
267 showShareUnavailableAlert = true
268 }
269 }
270 .disabled(viewModel.selectedFileContents == nil)
271
272 toolbarButton(
273 title: wrapLines ? "Wrap On" : "Wrap Off",
274 systemImage: "text.word.spacing"
275 ) {
276 wrapLines.toggle()
277 }
278
279 toolbarButton(
280 title: "Details",
281 systemImage: "info.circle"
282 ) {
283 showInfoSheet = true
167 } 284 }
168 } 285 }
286 .padding(.horizontal, 8)
287 .padding(.top, 10)
288 .padding(.bottom, 8)
289 .background(.bar)
290 .overlay(alignment: .top) {
291 Divider()
292 }
169 } 293 }
170 294
171 private func visibilityLabel(_ visibility: Visibility) -> String { 295 private func toolbarButton(
172 switch visibility { 296 title: String,
173 case .public: 297 systemImage: String,
174 return "Public" 298 action: @escaping () -> Void
175 case .unlisted: 299 ) -> some View {
176 return "Unlisted" 300 Button(action: action) {
177 case .private: 301 VStack(spacing: 4) {
178 return "Private" 302 Image(systemName: systemImage)
303 .font(.system(size: 17, weight: .semibold))
304 Text(title)
305 .font(.caption2)
306 .lineLimit(1)
307 }
308 .frame(maxWidth: .infinity)
309 .contentShape(Rectangle())
179 } 310 }
311 .buttonStyle(.plain)
312 .foregroundStyle(.primary)
180 } 313 }
181} 314}
182 315
183private struct PasteCodeBlock: View { 316// MARK: - Paste Info Sheet
184 let text: String 317
318private struct PasteInfoSheet: View {
319 let paste: Paste
320 let selectedFile: PasteFile?
321
322 @Environment(\.dismiss) private var dismiss
185 323
186 var body: some View { 324 var body: some View {
187 ScrollView([.horizontal, .vertical], showsIndicators: true) { 325 NavigationStack {
188 Text(text.isEmpty ? " " : text) 326 List {
189 .font(.system(.body, design: .monospaced)) 327 Section("Paste") {
190 .textSelection(.enabled) 328 LabeledContent("ID", value: paste.id)
191 .frame(maxWidth: .infinity, alignment: .leading) 329 LabeledContent("Owner", value: paste.user.canonicalName)
192 .padding(.vertical, 4) 330 LabeledContent("Created", value: paste.created.relativeDescription)
331 LabeledContent("Visibility") {
332 VisibilityBadge(visibility: paste.visibility)
333 }
334 if paste.files.count > 1 {
335 LabeledContent("Files", value: "\(paste.files.count)")
336 }
337 }
338
339 if let file = selectedFile {
340 Section("File") {
341 if let filename = file.filename, !filename.isEmpty {
342 LabeledContent("Filename", value: filename)
343 }
344 LabeledContent("Hash") {
345 Text(file.hash)
346 .font(.caption.monospaced())
347 .foregroundStyle(.secondary)
348 .textSelection(.enabled)
349 }
350 }
351 }
352 }
353 .listStyle(.insetGrouped)
354 .themedList()
355 .navigationTitle("Details")
356 .navigationBarTitleDisplayMode(.inline)
357 .toolbar {
358 ToolbarItem(placement: .confirmationAction) {
359 Button("Done") { dismiss() }
360 }
361 }
193 } 362 }
194 .frame(minHeight: 220)
195 } 363 }
196} 364}
197 365
366// MARK: - Visibility Sheet
367
198private struct PasteVisibilitySheet: View { 368private struct PasteVisibilitySheet: View {
199 let currentVisibility: Visibility 369 let currentVisibility: Visibility
200 let isUpdating: Bool 370 let isUpdating: Bool