summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 18:02:58 -0500
committerChristian Cleberg <[email protected]>2026-04-13 18:02:58 -0500
commit02a44d115b3784231cad640e992d9c18a7f67b40 (patch)
tree2a0c4498288889bb4dbf3de9c43a7aa1434f1459
parentb423f8a1cf3141d0b15ee899c1c5fe535ff982e9 (diff)
downloadhutch-02a44d115b3784231cad640e992d9c18a7f67b40.tar.gz
hutch-02a44d115b3784231cad640e992d9c18a7f67b40.tar.bz2
hutch-02a44d115b3784231cad640e992d9c18a7f67b40.zip
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
-rw-r--r--Hutch.xcodeproj/project.pbxproj4
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Views/Pastes/PasteDetailView.swift300
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