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 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 66;
635 CURRENT_PROJECT_VERSION = 67;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 66;
664 CURRENT_PROJECT_VERSION = 67;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
Hutch/App/AppStorageKeys.swift +1
@@ -4,6 +4,7 @@ enum AppStorageKeys {
44 static let contributionGraphsEnabled = "contributionGraphsEnabled"
55 static let activeAccountID = "activeAccountID"
66 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
7 static let wrapPasteFileLines = "wrapPasteFileLines"
78 static let lookupHistory = "lookupHistory"
89 static let recentActivity = "recentActivity"
910 static let scopedSearchHistory = "scopedSearchHistory"
Hutch/Views/Pastes/PasteDetailView.swift +235 −65
@@ -10,6 +10,12 @@ struct PasteDetailView: View {
1010 @State private var viewModel: PasteDetailViewModel?
1111 @State private var showVisibilitySheet = false
1212 @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
1319
1420 var body: some View {
1521 Group {
@@ -23,15 +29,14 @@ struct PasteDetailView: View {
2329 .navigationBarTitleDisplayMode(.inline)
2430 .toolbar {
2531 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
3332 if viewModel != nil {
3433 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
3540 Button {
3641 showVisibilitySheet = true
3742 } label: {
@@ -114,87 +119,252 @@ struct PasteDetailView: View {
114119 retryAction: { await viewModel.loadPaste() }
115120 )
116121 } 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)")
122 VStack(spacing: 0) {
123 fileHeaderBar(paste: paste, viewModel: viewModel)
124 Divider()
125 fileContentArea(viewModel: viewModel)
126 }
127 .safeAreaInset(edge: .bottom, spacing: 0) {
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.
124142 }
143 } message: {
144 Text(SRHTShareTarget.file.fallbackMessage)
145 }
146 }
147 }
125148
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 }
149 // MARK: - File Header Bar
150
151 private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View {
152 VStack(alignment: .leading, spacing: 6) {
153 HStack(alignment: .firstTextBaseline, spacing: 8) {
154 let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } }
155 Text(filename ?? "Untitled")
156 .font(.subheadline.monospaced())
157 .foregroundStyle(.primary)
158 .lineLimit(1)
159
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)
138186 }
187 }
139188
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)
189 if paste.files.count > 1 {
190 Picker(
191 "File",
192 selection: Binding(
193 get: { viewModel.selectedFileHash ?? paste.files.first?.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)
146200 }
201 }
202 .pickerStyle(.segmented)
203 }
204 }
205 .padding(.horizontal)
206 .padding(.vertical, 10)
207 .background(.bar)
208 }
147209
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")
210 // MARK: - File Content Area
211
212 @ViewBuilder
213 private func fileContentArea(viewModel: PasteDetailViewModel) -> some View {
214 if let file = viewModel.selectedFile {
215 if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
216 SRHTLoadingStateView(message: "Loading contents…")
217 } else if let contents = viewModel.selectedFileContents {
218 CodeFileTextView(
219 text: contents,
220 fileName: file.filename ?? "",
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 }
160255 }
161256 }
162257 }
163 .listStyle(.insetGrouped)
164 .srhtErrorBanner(error: $vm.error)
165 .refreshable {
166 await viewModel.loadPaste()
258 .disabled(viewModel.selectedFileContents == nil)
259
260 toolbarButton(
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
167284 }
168285 }
286 .padding(.horizontal, 8)
287 .padding(.top, 10)
288 .padding(.bottom, 8)
289 .background(.bar)
290 .overlay(alignment: .top) {
291 Divider()
292 }
169293 }
170294
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"
295 private func toolbarButton(
296 title: String,
297 systemImage: String,
298 action: @escaping () -> Void
299 ) -> some View {
300 Button(action: action) {
301 VStack(spacing: 4) {
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())
179310 }
311 .buttonStyle(.plain)
312 .foregroundStyle(.primary)
180313 }
181314}
182315
183private struct PasteCodeBlock: View {
184 let text: String
316// MARK: - Paste Info Sheet
317
318private struct PasteInfoSheet: View {
319 let paste: Paste
320 let selectedFile: PasteFile?
321
322 @Environment(\.dismiss) private var dismiss
185323
186324 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)
325 NavigationStack {
326 List {
327 Section("Paste") {
328 LabeledContent("ID", value: paste.id)
329 LabeledContent("Owner", value: paste.user.canonicalName)
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 }
193362 }
194 .frame(minHeight: 220)
195363 }
196364}
197365
366// MARK: - Visibility Sheet
367
198368private struct PasteVisibilitySheet: View {
199369 let currentVisibility: Visibility
200370 let isUpdating: Bool