krz/hutch

an ios client for sourcehut

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

02a44d115b3784231cad640e992d9c18a7f67b40

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T23:02:58Z

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
 Hutch.xcodeproj/project.pbxproj          |   4 +-
 Hutch/App/AppStorageKeys.swift           |   1 +
 Hutch/Views/Pastes/PasteDetailView.swift | 300 ++++++++++++++++++++++++-------
 3 files changed, 238 insertions(+), 67 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index d3d664e..dda781d 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 66;
+				CURRENT_PROJECT_VERSION = 67;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 66;
+				CURRENT_PROJECT_VERSION = 67;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 94e1310..d348f41 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -4,6 +4,7 @@ enum AppStorageKeys {
     static let contributionGraphsEnabled = "contributionGraphsEnabled"
     static let activeAccountID = "activeAccountID"
     static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
+    static let wrapPasteFileLines = "wrapPasteFileLines"
     static let lookupHistory = "lookupHistory"
     static let recentActivity = "recentActivity"
     static let scopedSearchHistory = "scopedSearchHistory"
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
index a73e784..7eb1853 100644
--- a/Hutch/Views/Pastes/PasteDetailView.swift
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -10,6 +10,12 @@ struct PasteDetailView: View {
     @State private var viewModel: PasteDetailViewModel?
     @State private var showVisibilitySheet = false
     @State private var showDeleteConfirmation = false
+    @State private var showInfoSheet = false
+    @State private var showFileShareSheet = false
+    @State private var showShareUnavailableAlert = false
+    @State private var didCopyContents = false
+    @State private var copyResetTask: Task<Void, Never>?
+    @AppStorage(AppStorageKeys.wrapPasteFileLines) private var wrapLines = false
 
     var body: some View {
         Group {
@@ -23,15 +29,14 @@ struct PasteDetailView: View {
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
             ToolbarItemGroup(placement: .topBarTrailing) {
-                SRHTShareButton(
-                    url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
-                    target: .paste
-                ) {
-                    Image(systemName: "square.and.arrow.up")
-                }
-
                 if viewModel != nil {
                     Menu {
+                        if let url = currentPaste.flatMap({ SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }) {
+                            ShareLink(item: url) {
+                                Label("Share Link", systemImage: "link")
+                            }
+                        }
+
                         Button {
                             showVisibilitySheet = true
                         } label: {
@@ -114,87 +119,252 @@ struct PasteDetailView: View {
                 retryAction: { await viewModel.loadPaste() }
             )
         } else if let paste = viewModel.paste {
-            List {
-                Section("Details") {
-                    LabeledContent("ID", value: paste.id)
-                    LabeledContent("Owner", value: paste.user.canonicalName)
-                    LabeledContent("Created", value: paste.created.relativeDescription)
-                    LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
-                    LabeledContent("Files", value: "\(paste.files.count)")
+            VStack(spacing: 0) {
+                fileHeaderBar(paste: paste, viewModel: viewModel)
+                Divider()
+                fileContentArea(viewModel: viewModel)
+            }
+            .safeAreaInset(edge: .bottom, spacing: 0) {
+                actionToolbar(viewModel: viewModel)
+            }
+            .srhtErrorBanner(error: $vm.error)
+            .sheet(isPresented: $showInfoSheet) {
+                PasteInfoSheet(paste: paste, selectedFile: viewModel.selectedFile)
+            }
+            .sheet(isPresented: $showFileShareSheet) {
+                if let contents = viewModel.selectedFileContents {
+                    FileContentShareSheet(activityItems: [contents])
+                }
+            }
+            .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) {
+                Button("OK", role: .cancel) {
+                    // Alert dismissal is implicit; no additional action required.
                 }
+            } message: {
+                Text(SRHTShareTarget.file.fallbackMessage)
+            }
+        }
+    }
 
-                if paste.files.count > 1 {
-                    Section("Files") {
-                        Picker("Selected File", selection: Binding(
-                            get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
-                            set: { viewModel.selectFile(hash: $0) }
-                        )) {
-                            ForEach(paste.files) { file in
-                                Text(file.filename ?? String(file.hash.prefix(8)))
-                                    .tag(file.hash)
-                            }
-                        }
-                    }
+    // MARK: - File Header Bar
+
+    private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .firstTextBaseline, spacing: 8) {
+                let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } }
+                Text(filename ?? "Untitled")
+                    .font(.subheadline.monospaced())
+                    .foregroundStyle(.primary)
+                    .lineLimit(1)
+
+                Spacer(minLength: 4)
+
+                VisibilityBadge(visibility: paste.visibility)
+            }
+
+            HStack(spacing: 4) {
+                Text(paste.user.canonicalName)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+
+                Text("·")
+                    .font(.caption)
+                    .foregroundStyle(.tertiary)
+
+                Text(paste.created.relativeDescription)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+
+                if let hash = viewModel.selectedFile?.hash {
+                    Text("·")
+                        .font(.caption)
+                        .foregroundStyle(.tertiary)
+
+                    Text(hash.prefix(8))
+                        .font(.caption.monospaced())
+                        .foregroundStyle(.tertiary)
                 }
+            }
 
-                if let file = viewModel.selectedFile {
-                    Section("Current File") {
-                        if let filename = file.filename, !filename.isEmpty {
-                            LabeledContent("Filename", value: filename)
-                        }
-                        LabeledContent("Hash", value: file.hash)
+            if paste.files.count > 1 {
+                Picker(
+                    "File",
+                    selection: Binding(
+                        get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
+                        set: { viewModel.selectFile(hash: $0) }
+                    )
+                ) {
+                    ForEach(paste.files) { file in
+                        Text(file.filename ?? String(file.hash.prefix(8)))
+                            .tag(file.hash)
                     }
+                }
+                .pickerStyle(.segmented)
+            }
+        }
+        .padding(.horizontal)
+        .padding(.vertical, 10)
+        .background(.bar)
+    }
 
-                    Section {
-                        if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
-                            SRHTLoadingStateView(message: "Loading paste contents…")
-                                .frame(minHeight: 180)
-                        } else if let contents = viewModel.selectedFileContents {
-                            PasteCodeBlock(text: contents)
-                        } else {
-                            Text("This file’s contents are unavailable.")
-                                .foregroundStyle(.secondary)
-                        }
-                    } header: {
-                        Text("Contents")
+    // MARK: - File Content Area
+
+    @ViewBuilder
+    private func fileContentArea(viewModel: PasteDetailViewModel) -> some View {
+        if let file = viewModel.selectedFile {
+            if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
+                SRHTLoadingStateView(message: "Loading contents…")
+            } else if let contents = viewModel.selectedFileContents {
+                CodeFileTextView(
+                    text: contents,
+                    fileName: file.filename ?? "",
+                    wrapLines: wrapLines
+                )
+            } else {
+                ContentUnavailableView(
+                    "Contents Unavailable",
+                    systemImage: "doc.questionmark",
+                    description: Text("This file's contents could not be loaded.")
+                )
+            }
+        } else {
+            ContentUnavailableView(
+                "No File Selected",
+                systemImage: "doc",
+                description: Text("Select a file to view its contents.")
+            )
+        }
+    }
+
+    // MARK: - Action Toolbar
+
+    private func actionToolbar(viewModel: PasteDetailViewModel) -> some View {
+        HStack(spacing: 0) {
+            toolbarButton(
+                title: didCopyContents ? "Copied" : "Copy All",
+                systemImage: didCopyContents ? "checkmark" : "doc.on.doc"
+            ) {
+                if let contents = viewModel.selectedFileContents {
+                    UIPasteboard.general.string = contents
+                    didCopyContents = true
+                    copyResetTask?.cancel()
+                    copyResetTask = Task {
+                        try? await Task.sleep(for: .seconds(2))
+                        guard !Task.isCancelled else { return }
+                        await MainActor.run { didCopyContents = false }
                     }
                 }
             }
-            .listStyle(.insetGrouped)
-            .srhtErrorBanner(error: $vm.error)
-            .refreshable {
-                await viewModel.loadPaste()
+            .disabled(viewModel.selectedFileContents == nil)
+
+            toolbarButton(
+                title: "Share",
+                systemImage: "square.and.arrow.up"
+            ) {
+                if let contents = viewModel.selectedFileContents, !contents.isEmpty {
+                    showFileShareSheet = true
+                } else {
+                    showShareUnavailableAlert = true
+                }
+            }
+            .disabled(viewModel.selectedFileContents == nil)
+
+            toolbarButton(
+                title: wrapLines ? "Wrap On" : "Wrap Off",
+                systemImage: "text.word.spacing"
+            ) {
+                wrapLines.toggle()
+            }
+
+            toolbarButton(
+                title: "Details",
+                systemImage: "info.circle"
+            ) {
+                showInfoSheet = true
             }
         }
+        .padding(.horizontal, 8)
+        .padding(.top, 10)
+        .padding(.bottom, 8)
+        .background(.bar)
+        .overlay(alignment: .top) {
+            Divider()
+        }
     }
 
-    private func visibilityLabel(_ visibility: Visibility) -> String {
-        switch visibility {
-        case .public:
-            return "Public"
-        case .unlisted:
-            return "Unlisted"
-        case .private:
-            return "Private"
+    private func toolbarButton(
+        title: String,
+        systemImage: String,
+        action: @escaping () -> Void
+    ) -> some View {
+        Button(action: action) {
+            VStack(spacing: 4) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 17, weight: .semibold))
+                Text(title)
+                    .font(.caption2)
+                    .lineLimit(1)
+            }
+            .frame(maxWidth: .infinity)
+            .contentShape(Rectangle())
         }
+        .buttonStyle(.plain)
+        .foregroundStyle(.primary)
     }
 }
 
-private struct PasteCodeBlock: View {
-    let text: String
+// MARK: - Paste Info Sheet
+
+private struct PasteInfoSheet: View {
+    let paste: Paste
+    let selectedFile: PasteFile?
+
+    @Environment(\.dismiss) private var dismiss
 
     var body: some View {
-        ScrollView([.horizontal, .vertical], showsIndicators: true) {
-            Text(text.isEmpty ? " " : text)
-                .font(.system(.body, design: .monospaced))
-                .textSelection(.enabled)
-                .frame(maxWidth: .infinity, alignment: .leading)
-                .padding(.vertical, 4)
+        NavigationStack {
+            List {
+                Section("Paste") {
+                    LabeledContent("ID", value: paste.id)
+                    LabeledContent("Owner", value: paste.user.canonicalName)
+                    LabeledContent("Created", value: paste.created.relativeDescription)
+                    LabeledContent("Visibility") {
+                        VisibilityBadge(visibility: paste.visibility)
+                    }
+                    if paste.files.count > 1 {
+                        LabeledContent("Files", value: "\(paste.files.count)")
+                    }
+                }
+
+                if let file = selectedFile {
+                    Section("File") {
+                        if let filename = file.filename, !filename.isEmpty {
+                            LabeledContent("Filename", value: filename)
+                        }
+                        LabeledContent("Hash") {
+                            Text(file.hash)
+                                .font(.caption.monospaced())
+                                .foregroundStyle(.secondary)
+                                .textSelection(.enabled)
+                        }
+                    }
+                }
+            }
+            .listStyle(.insetGrouped)
+            .themedList()
+            .navigationTitle("Details")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") { dismiss() }
+                }
+            }
         }
-        .frame(minHeight: 220)
     }
 }
 
+// MARK: - Visibility Sheet
+
 private struct PasteVisibilitySheet: View {
     let currentVisibility: Visibility
     let isUpdating: Bool