diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 09:57:23 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 09:57:23 -0500 |
| commit | 0e461a382257979037e8927e5f6b468635b0a7fe (patch) | |
| tree | 0aa3a19f6f8f966b0e90f4874ed54359a7898107 /Hutch/Views/Tickets | |
| parent | ee9f2904aa319231b7047fca3cb069f0c07019cd (diff) | |
| download | hutch-0e461a382257979037e8927e5f6b468635b0a7fe.tar.gz hutch-0e461a382257979037e8927e5f6b468635b0a7fe.tar.bz2 hutch-0e461a382257979037e8927e5f6b468635b0a7fe.zip | |
feat: add power user actions
Implements: https://todo.sr.ht/~ccleberg/hutch/52
Implements: https://todo.sr.ht/~ccleberg/hutch/53
Implements: https://todo.sr.ht/~ccleberg/hutch/54
Diffstat (limited to 'Hutch/Views/Tickets')
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 84 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 66 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 60 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListView.swift | 30 |
4 files changed, 226 insertions, 14 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 4bea3ae..f85a0c1 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -10,6 +10,7 @@ struct TicketDetailView: View { @Environment(AppState.self) private var appState @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL @State private var viewModel: TicketDetailViewModel? // Sheet state @@ -47,7 +48,7 @@ struct TicketDetailView: View { Image(systemName: "square.and.arrow.up") } - if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser { + if let viewModel, viewModel.ticket != nil { actionsMenu(viewModel) } } @@ -63,7 +64,7 @@ struct TicketDetailView: View { client: appState.client ) viewModel = vm - await vm.loadTicket() + await reloadDetail(vm) } } } @@ -73,6 +74,35 @@ struct TicketDetailView: View { @ViewBuilder private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { Menu { + if let ticketURL = SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId) { + Button { + openURL(ticketURL) + } label: { + SwiftUI.Label("Open in Browser", systemImage: "safari") + } + + Button { + appState.copyToPasteboard(ticketURL.absoluteString, label: "ticket URL") + } label: { + SwiftUI.Label("Copy URL", systemImage: "doc.on.doc") + } + } + + Button { + appState.copyToPasteboard(String(ticketId), label: "ticket ID") + } label: { + SwiftUI.Label("Copy Ticket ID", systemImage: "number") + } + + Button { + appState.copyToPasteboard(trackerRid, label: "tracker RID") + } label: { + SwiftUI.Label("Copy Tracker RID", systemImage: "number") + } + + if isOwnedByCurrentUser { + Divider() + if let ticket = viewModel.ticket { if ticket.status == .resolved { Button { @@ -103,6 +133,7 @@ struct TicketDetailView: View { } label: { SwiftUI.Label("Manage Labels", systemImage: "tag") } + } } label: { Image(systemName: "ellipsis.circle") } @@ -133,7 +164,7 @@ struct TicketDetailView: View { SRHTErrorStateView( title: "Couldn't Load Ticket", message: error, - retryAction: { await viewModel.loadTicket() } + retryAction: { await reloadDetail(viewModel) } ) } else if let ticket = viewModel.ticket { ScrollView { @@ -179,13 +210,19 @@ struct TicketDetailView: View { .padding(.vertical, 12) } - // Comment input + if appState.isDebugModeEnabled { + Divider() + .padding(.vertical, 12) + + debugSection(viewModel: viewModel, ticket: ticket) + } + commentInput(viewModel) } } .srhtErrorBanner(error: $vm.error) .refreshable { - await viewModel.loadTicket() + await reloadDetail(viewModel) } } } @@ -363,6 +400,35 @@ struct TicketDetailView: View { .padding() } + @ViewBuilder + private func debugSection(viewModel: TicketDetailViewModel, ticket: TicketDetail) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Debug") + .font(.headline) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + DebugTextBlock( + title: "Diagnostics", + content: """ + ticketId: \(ticket.id) + trackerId: \(trackerId) + trackerRid: \(trackerRid) + status: \(ticket.status.rawValue) + events: \(viewModel.events.count) + url: \(SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticket.id)?.absoluteString ?? "unavailable") + """ + ) + + if let rawTicketResponse = viewModel.rawTicketResponse { + DebugTextBlock(title: "Raw Response", content: rawTicketResponse) + } + } + .padding(.horizontal) + .padding(.bottom, 16) + } + } + private func normalizedUsername(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed @@ -381,6 +447,14 @@ struct TicketDetailView: View { } } } + + private func reloadDetail(_ viewModel: TicketDetailViewModel) async { + if appState.isDebugModeEnabled { + await viewModel.loadTicketWithDebugCapture() + } else { + await viewModel.loadTicket() + } + } } // MARK: - Self-Sizing Markdown Web View diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 999374b..483f749 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -97,6 +97,9 @@ private struct LabelsPage: Decodable, Sendable { @Observable @MainActor final class TicketDetailViewModel { + private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String { + "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)" + } let ownerUsername: String let trackerName: String @@ -110,6 +113,7 @@ final class TicketDetailViewModel { private(set) var isSubmitting = false private(set) var isPerformingAction = false private(set) var trackerLabels: [TicketLabel] = [] + private(set) var rawTicketResponse: String? var commentText = "" var error: String? @@ -284,6 +288,7 @@ final class TicketDetailViewModel { guard !isLoading else { return } isLoading = true error = nil + rawTicketResponse = nil do { let result = try await client.execute( @@ -317,6 +322,47 @@ final class TicketDetailViewModel { isLoading = false } + func loadTicketWithDebugCapture() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId) + let result = try await client.executeAndCache( + service: .todo, + query: Self.detailQuery, + variables: [ + "rid": trackerRid, + "ticketId": ticketId + ], + responseType: TicketDetailResponse.self, + cacheKey: cacheKey + ) + rawTicketResponse = client.responseCache.get(forKey: cacheKey) + .flatMap { String(data: $0, encoding: .utf8) } + let payload = result.tracker.ticket + ticket = TicketDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + title: payload.title, + description: payload.description, + status: payload.status, + resolution: payload.resolution, + authenticity: payload.authenticity, + submitter: payload.submitter, + assignees: payload.assignees, + labels: payload.labels + ) + events = payload.events.results.sorted(by: Self.timelineOrder) + } catch { + self.error = error.userFacingMessage + } + + isLoading = false + } + func submitComment() async { let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !isSubmitting else { return } @@ -378,7 +424,7 @@ final class TicketDetailViewModel { responseType: UpdateStatusResponse.self ) // Re-fetch the ticket to get updated status/resolution - await loadTicket() + await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage } @@ -412,7 +458,7 @@ final class TicketDetailViewModel { responseType: AssignUserResponse.self ) // Reload to reflect the change - await loadTicket() + await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage } @@ -457,7 +503,7 @@ final class TicketDetailViewModel { ], responseType: AssignUserResponse.self ) - await loadTicket() + await reloadTicketPreservingDebugState() } catch { ticket = TicketDetail( id: currentTicket.id, @@ -505,7 +551,7 @@ final class TicketDetailViewModel { responseType: UnassignUserResponse.self ) // Reload to reflect the change - await loadTicket() + await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage } @@ -529,7 +575,7 @@ final class TicketDetailViewModel { ], responseType: LabelTicketResponse.self ) - await loadTicket() + await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage } @@ -553,7 +599,7 @@ final class TicketDetailViewModel { ], responseType: UnlabelTicketResponse.self ) - await loadTicket() + await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage } @@ -600,6 +646,14 @@ final class TicketDetailViewModel { isPerformingAction = false } + private func reloadTicketPreservingDebugState() async { + if rawTicketResponse != nil { + await loadTicketWithDebugCapture() + } else { + await loadTicket() + } + } + static func matchesAssignee(_ entity: Entity, user: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let userCanonical = normalizedCanonicalName(user.canonicalName) diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index ccd06fe..f7529fb 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -7,6 +7,7 @@ struct TicketListView: View { @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @State private var tracker: TrackerSummary @State private var viewModel: TicketListViewModel? @State private var trackerManagementViewModel: TrackerManagementViewModel? @@ -86,9 +87,7 @@ struct TicketListView: View { viewModel?.setSelectionMode(true) } - if isOwnedByCurrentUser { - trackerActionsMenu - } + trackerActionsMenu } } } @@ -296,6 +295,27 @@ struct TicketListView: View { } label: { TicketRowView(ticket: ticket) } + .contextMenu { + if let url = SRHTWebURL.ticket(ownerUsername: ownerUsername(for: tracker), trackerName: tracker.name, ticketId: ticket.id) { + Button { + openURL(url) + } label: { + Label("Open in Browser", systemImage: "safari") + } + + Button { + appState.copyToPasteboard(url.absoluteString, label: "ticket URL") + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + Button { + appState.copyToPasteboard(String(ticket.id), label: "ticket ID") + } label: { + Label("Copy Ticket ID", systemImage: "number") + } + } .swipeActions(edge: .leading, allowsFullSwipe: true) { if swipeActionsEnabled { ticketAssignSwipeAction(ticket, viewModel: viewModel) @@ -409,6 +429,35 @@ struct TicketListView: View { private var trackerActionsMenu: some View { Menu { + if let trackerURL = SRHTWebURL.tracker(tracker) { + Button { + openURL(trackerURL) + } label: { + Label("Open in Browser", systemImage: "safari") + } + + Button { + appState.copyToPasteboard(trackerURL.absoluteString, label: "tracker URL") + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + Button { + appState.copyToPasteboard(String(tracker.id), label: "tracker ID") + } label: { + Label("Copy Tracker ID", systemImage: "number") + } + + Button { + appState.copyToPasteboard(tracker.rid, label: "tracker RID") + } label: { + Label("Copy RID", systemImage: "number") + } + + if isOwnedByCurrentUser { + Divider() + Button { showTrackerEditor = true } label: { @@ -432,12 +481,17 @@ struct TicketListView: View { } label: { Label("Delete Tracker", systemImage: "trash") } + } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel("Tracker actions") } + private func ownerUsername(for tracker: TrackerSummary) -> String { + tracker.owner.canonicalName.srhtUsername + } + @ViewBuilder private func ticketAssignSwipeAction( _ ticket: TicketSummary, diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index 50604b8..db0fdf2 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -224,6 +224,9 @@ struct TrackerListView: View { // MARK: - Tracker Row private struct TrackerRowView: View { + @Environment(AppState.self) private var appState + @Environment(\.openURL) private var openURL + let tracker: TrackerSummary var body: some View { @@ -255,5 +258,32 @@ private struct TrackerRowView: View { .foregroundStyle(.tertiary) } .padding(.vertical, 2) + .contextMenu { + if let url = SRHTWebURL.tracker(tracker) { + Button { + openURL(url) + } label: { + Label("Open in Browser", systemImage: "safari") + } + + Button { + appState.copyToPasteboard(url.absoluteString, label: "tracker URL") + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + Button { + appState.copyToPasteboard(String(tracker.id), label: "tracker ID") + } label: { + Label("Copy Tracker ID", systemImage: "number") + } + + Button { + appState.copyToPasteboard(tracker.rid, label: "tracker RID") + } label: { + Label("Copy RID", systemImage: "number") + } + } } } |
