summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 09:57:23 -0500
committerChristian Cleberg <[email protected]>2026-04-13 09:57:23 -0500
commit0e461a382257979037e8927e5f6b468635b0a7fe (patch)
tree0aa3a19f6f8f966b0e90f4874ed54359a7898107 /Hutch/Views/Tickets
parentee9f2904aa319231b7047fca3cb069f0c07019cd (diff)
downloadhutch-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.swift84
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift66
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift60
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift30
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")
+ }
+ }
}
}