diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
| commit | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Pastes | |
| parent | 1e3c748119c6e9eec27f02146f17ea0302ff648a (diff) | |
| download | hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2 hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip | |
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch/Views/Pastes')
| -rw-r--r-- | Hutch/Views/Pastes/PasteDetailView.swift | 288 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteDetailViewModel.swift | 114 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 280 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListViewModel.swift | 109 |
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 } + } +} |
