summaryrefslogtreecommitdiff
path: root/Hutch/Views/Pastes
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
commit659c76df9ff3926cb68886c16167808c23bd8f75 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Pastes
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
parent6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff)
downloadhutch-2.tar.gz
hutch-2.tar.bz2
hutch-2.zip
v2.0: Merge branch 'dev'v2
Diffstat (limited to 'Hutch/Views/Pastes')
-rw-r--r--Hutch/Views/Pastes/PasteDetailView.swift288
-rw-r--r--Hutch/Views/Pastes/PasteDetailViewModel.swift114
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift280
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift109
4 files changed, 791 insertions, 0 deletions
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
new file mode 100644
index 0000000..df3a8be
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -0,0 +1,288 @@
+import SwiftUI
+
+struct PasteDetailView: View {
+ let paste: Paste
+ var onUpdated: ((Paste) -> Void)? = nil
+ var onDeleted: ((String) -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: PasteDetailViewModel?
+ @State private var showVisibilitySheet = false
+ @State private var showDeleteConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading paste…")
+ }
+ }
+ .navigationTitle(displayTitle)
+ .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 {
+ Button {
+ showVisibilitySheet = true
+ } label: {
+ Label("Change Visibility", systemImage: "eye")
+ }
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ Label("Delete Paste", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showVisibilitySheet) {
+ if let viewModel, let currentPaste {
+ PasteVisibilitySheet(
+ currentVisibility: currentPaste.visibility,
+ isUpdating: viewModel.isUpdatingVisibility
+ ) { visibility in
+ if let updated = await viewModel.updateVisibility(visibility) {
+ onUpdated?(updated)
+ showVisibilitySheet = false
+ }
+ }
+ }
+ }
+ .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ if await viewModel?.deletePaste() == true {
+ onDeleted?(paste.id)
+ dismiss()
+ }
+ }
+ }
+ } message: {
+ Text("This paste will be permanently removed.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteDetailViewModel(
+ pasteID: paste.id,
+ initialPaste: paste,
+ service: PasteService(client: appState.client)
+ )
+ viewModel = vm
+ await vm.loadPaste()
+ }
+ }
+ }
+
+ private var currentPaste: Paste? {
+ viewModel?.paste ?? paste
+ }
+
+ private var displayTitle: String {
+ if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return "Paste \(paste.id)"
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.paste == nil {
+ SRHTLoadingStateView(message: "Loading paste…")
+ } else if let error = viewModel.error, viewModel.paste == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Paste",
+ message: error,
+ 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)")
+ }
+
+ 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)
+ }
+ }
+ }
+ }
+
+ if let file = viewModel.selectedFile {
+ Section("Current File") {
+ if let filename = file.filename, !filename.isEmpty {
+ LabeledContent("Filename", value: filename)
+ }
+ LabeledContent("Hash", value: file.hash)
+ }
+
+ 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")
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPaste()
+ }
+ }
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}
+
+private struct PasteCodeBlock: View {
+ let text: String
+
+ 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)
+ }
+ .frame(minHeight: 220)
+ }
+}
+
+private struct PasteVisibilitySheet: View {
+ let currentVisibility: Visibility
+ let isUpdating: Bool
+ let onSave: (Visibility) async -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var visibility: Visibility
+
+ init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
+ self.currentVisibility = currentVisibility
+ self.isUpdating = isUpdating
+ self.onSave = onSave
+ _visibility = State(initialValue: currentVisibility)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(visibilityOptions, id: \.self) { option in
+ Button {
+ visibility = option
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title(for: option))
+ .foregroundStyle(.primary)
+ Text(description(for: option))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if visibility == option {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Visibility")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ await onSave(visibility)
+ }
+ }
+ .disabled(isUpdating || visibility == currentVisibility)
+ }
+ }
+ .overlay {
+ if isUpdating {
+ ProgressView()
+ }
+ }
+ }
+ }
+
+ private var visibilityOptions: [Visibility] {
+ [.public, .unlisted, .private]
+ }
+
+ private func title(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Public"
+ case .unlisted:
+ "Unlisted"
+ case .private:
+ "Private"
+ }
+ }
+
+ private func description(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Visible to everyone and listed on your profile."
+ case .unlisted:
+ "Visible to anyone with the URL, but not listed on your profile."
+ case .private:
+ "Visible only to explicitly allowed viewers."
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteDetailViewModel.swift b/Hutch/Views/Pastes/PasteDetailViewModel.swift
new file mode 100644
index 0000000..8c60edd
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailViewModel.swift
@@ -0,0 +1,114 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteDetailViewModel {
+ private(set) var paste: Paste?
+ private(set) var isLoading = false
+ private(set) var isUpdatingVisibility = false
+ private(set) var isDeleting = false
+ private(set) var loadingFileHashes: Set<String> = []
+ var error: String?
+
+ var selectedFileHash: String?
+ private(set) var fileContents: [String: String] = [:]
+
+ private let pasteID: String
+ private let service: PasteService
+
+ init(pasteID: String, initialPaste: Paste? = nil, service: PasteService) {
+ self.pasteID = pasteID
+ self.paste = initialPaste
+ self.service = service
+ self.selectedFileHash = initialPaste?.files.first?.hash
+ }
+
+ var selectedFile: PasteFile? {
+ let hash = selectedFileHash ?? paste?.files.first?.hash
+ return paste?.files.first(where: { $0.hash == hash })
+ }
+
+ var selectedFileContents: String? {
+ guard let selectedFile else { return nil }
+ return fileContents[selectedFile.hash]
+ }
+
+ func loadPaste() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let loaded = try await service.loadPaste(id: pasteID)
+ paste = loaded
+ if selectedFileHash == nil {
+ selectedFileHash = loaded?.files.first?.hash
+ }
+ await loadSelectedFileContentsIfNeeded()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func selectFile(hash: String) {
+ selectedFileHash = hash
+ Task {
+ await loadSelectedFileContentsIfNeeded()
+ }
+ }
+
+ func updateVisibility(_ visibility: Visibility) async -> Paste? {
+ guard !isUpdatingVisibility else { return nil }
+ guard let paste else { return nil }
+ guard paste.visibility != visibility else { return paste }
+
+ isUpdatingVisibility = true
+ error = nil
+ defer { isUpdatingVisibility = false }
+
+ do {
+ let updatedPaste = try await service.updateVisibility(id: paste.id, visibility: visibility)
+ if let updatedPaste {
+ self.paste = updatedPaste
+ if selectedFileHash == nil {
+ selectedFileHash = updatedPaste.files.first?.hash
+ }
+ }
+ return updatedPaste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func deletePaste() async -> Bool {
+ guard !isDeleting else { return false }
+ isDeleting = true
+ error = nil
+ defer { isDeleting = false }
+
+ do {
+ _ = try await service.deletePaste(id: pasteID)
+ return true
+ } catch {
+ self.error = error.localizedDescription
+ return false
+ }
+ }
+
+ func loadSelectedFileContentsIfNeeded() async {
+ guard let file = selectedFile, fileContents[file.hash] == nil else { return }
+ guard let url = file.contents else { return }
+ guard !loadingFileHashes.contains(file.hash) else { return }
+
+ loadingFileHashes.insert(file.hash)
+ defer { loadingFileHashes.remove(file.hash) }
+
+ do {
+ fileContents[file.hash] = try await service.loadContents(from: url)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
new file mode 100644
index 0000000..9d8c0d4
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -0,0 +1,280 @@
+import SwiftUI
+
+struct PasteListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: PasteListViewModel?
+ @State private var showCreatePasteSheet = false
+ @State private var createdPaste: Paste?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ }
+ }
+ .navigationTitle("Pastes")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreatePasteSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreatePasteSheet) {
+ if let viewModel {
+ CreatePasteSheet(viewModel: viewModel) { paste in
+ showCreatePasteSheet = false
+ createdPaste = paste
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdPaste != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdPaste = nil
+ }
+ }
+ )) {
+ if let createdPaste {
+ PasteDetailView(
+ paste: createdPaste,
+ onUpdated: { updated in
+ viewModel?.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel?.removePaste(id: id)
+ }
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteListViewModel(service: PasteService(client: appState.client))
+ viewModel = vm
+ await vm.loadPastes()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.pastes) { paste in
+ NavigationLink(value: paste) {
+ PasteRowView(paste: paste)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: paste)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.pastes.isEmpty {
+ SRHTLoadingStateView(message: "Loading pastes…")
+ } else if let error = viewModel.error, viewModel.pastes.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Pastes",
+ message: error,
+ retryAction: { await viewModel.loadPastes() }
+ )
+ } else if viewModel.pastes.isEmpty {
+ ContentUnavailableView(
+ "No Pastes",
+ systemImage: "doc.on.clipboard",
+ description: Text("Your pastes will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
+ await viewModel.loadPastes()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPastes()
+ }
+ .navigationDestination(for: Paste.self) { paste in
+ PasteDetailView(
+ paste: paste,
+ onUpdated: { updated in
+ viewModel.upsertPaste(updated)
+ },
+ onDeleted: { id in
+ viewModel.removePaste(id: id)
+ }
+ )
+ }
+ }
+}
+
+private struct PasteRowView: View {
+ let paste: Paste
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "doc.text")
+ .foregroundStyle(.secondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(primaryTitle)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(1)
+
+ Text(secondaryLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ VisibilityBadge(visibility: paste.visibility)
+ Text("•")
+ .foregroundStyle(.tertiary)
+ Text(paste.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var primaryTitle: String {
+ if let filename = paste.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
+ }
+
+ private var secondaryLine: String {
+ var parts: [String] = [paste.user.canonicalName]
+ if paste.files.count > 1 {
+ parts.append("\(paste.files.count) files")
+ } else {
+ parts.append("1 file")
+ }
+ if let firstHash = paste.files.first?.hash {
+ parts.append(String(firstHash.prefix(8)))
+ }
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct CreatePasteSheet: View {
+ let viewModel: PasteListViewModel
+ let onCreated: (Paste) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var files = [PasteUploadDraft()]
+ @State private var visibility: Visibility = .unlisted
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Files") {
+ ForEach($files) { $file in
+ VStack(alignment: .leading, spacing: 8) {
+ TextField("Filename (optional)", text: $file.filename)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ ZStack(alignment: .topLeading) {
+ if file.contents.isEmpty {
+ Text("Paste contents")
+ .foregroundStyle(.tertiary)
+ .padding(.top, 8)
+ .padding(.leading, 5)
+ .allowsHitTesting(false)
+ }
+
+ TextEditor(text: $file.contents)
+ .font(.system(.body, design: .monospaced))
+ .frame(minHeight: 180)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .onDelete { offsets in
+ files.remove(atOffsets: offsets)
+ if files.isEmpty {
+ files = [PasteUploadDraft()]
+ }
+ }
+
+ Button {
+ files.append(PasteUploadDraft())
+ } label: {
+ Label("Add File", systemImage: "plus")
+ }
+ }
+
+ Section("Visibility") {
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+
+ Section {
+ Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("New Paste")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
+ onCreated(paste)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingPaste {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Paste")
+ }
+ }
+ .disabled(!hasValidContent || viewModel.isCreatingPaste)
+ }
+ }
+ }
+ }
+
+ private var hasValidContent: Bool {
+ files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ }
+}
diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift
new file mode 100644
index 0000000..ee7d47a
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteListViewModel.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+@Observable
+@MainActor
+final class PasteListViewModel {
+ private(set) var pastes: [Paste] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isCreatingPaste = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let service: PasteService
+
+ init(service: PasteService) {
+ self.service = service
+ }
+
+ func loadPastes() async {
+ if pastes.isEmpty, let cached = service.loadCachedPastes() {
+ pastes = cached.results
+ cursor = cached.cursor
+ hasMore = cached.cursor != nil
+ }
+
+ if pastes.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await service.listPastes(cursor: nil, useCache: true)
+ pastes = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if pastes.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: Paste) async {
+ guard let last = pastes.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+
+ do {
+ let page = try await service.listPastes(cursor: cursor, useCache: false)
+ pastes.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createPaste(files: [PasteUploadDraft], visibility: Visibility) async -> Paste? {
+ guard !isCreatingPaste else { return nil }
+
+ let normalizedFiles = files.filter {
+ !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ guard !normalizedFiles.isEmpty else {
+ error = "Add at least one file with text content."
+ return nil
+ }
+
+ isCreatingPaste = true
+ error = nil
+ defer { isCreatingPaste = false }
+
+ do {
+ let paste = try await service.createPaste(files: normalizedFiles, visibility: visibility)
+ upsertPaste(paste)
+ return paste
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func upsertPaste(_ paste: Paste) {
+ if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
+ pastes[index] = paste
+ } else {
+ pastes.insert(paste, at: 0)
+ }
+ }
+
+ func removePaste(id: String) {
+ pastes.removeAll { $0.id == id }
+ }
+}