summaryrefslogtreecommitdiff
path: root/Hutch/Views
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views')
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift6
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift114
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift61
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift22
-rw-r--r--Hutch/Views/Settings/SettingsView.swift7
-rw-r--r--Hutch/Views/Shared/StaleCacheStatusRow.swift32
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift6
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift120
8 files changed, 284 insertions, 84 deletions
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 334dff7..51d0c88 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -166,6 +166,12 @@ struct BuildDetailView: View {
)
} else if let job = viewModel.job {
List {
+ if let cacheMetadata = viewModel.cacheMetadata {
+ Section {
+ StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
+ }
+ }
+
Section("Details") {
HStack {
Text("Status")
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index dcf70a0..74e8701 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -28,7 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
@MainActor
final class BuildDetailViewModel {
private static let autoRefreshInterval: Duration = .seconds(5)
- private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" }
+ private static func cacheKey(for jobId: Int) -> String { APICacheKeys.buildDetail(jobId: jobId) }
let jobId: Int
private let client: SRHTClient
@@ -46,6 +46,8 @@ final class BuildDetailViewModel {
private(set) var isRebuilding = false
private(set) var isSubmittingEditedBuild = false
private(set) var rawJobResponse: String?
+ private(set) var cacheMetadata: CacheEntryMetadata?
+ private(set) var isRefreshingCachedData = false
var error: String?
/// Transient error shown for action failures (cancel, rebuild, submit).
/// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -128,22 +130,21 @@ final class BuildDetailViewModel {
rawJobResponse = nil
do {
- let result = try await client.execute(
+ let result = try await client.executeCached(
service: .builds,
query: Self.detailQuery,
variables: ["id": jobId],
- responseType: JobDetailResponse.self
+ responseType: JobDetailResponse.self,
+ cacheKey: Self.cacheKey(for: jobId),
+ resourceType: .buildDetail,
+ ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
+ policy: .cacheFirstThenRefresh
)
- var loadedJob = result.job
- loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
- task.withOrdinal(index)
- }
- if job != loadedJob {
- job = loadedJob
- }
-
- if loadedJob.status.isTerminal {
- stopAutoRefresh()
+ apply(result.value, metadata: result.metadata)
+ if result.isFromCache {
+ isLoading = false
+ await refreshJobInBackground()
+ return
}
} catch {
self.error = error.userFacingMessage
@@ -159,26 +160,19 @@ final class BuildDetailViewModel {
do {
let cacheKey = Self.cacheKey(for: jobId)
- let result = try await client.executeAndCache(
+ let result = try await client.executeCached(
service: .builds,
query: Self.detailQuery,
variables: ["id": jobId],
responseType: JobDetailResponse.self,
- cacheKey: cacheKey
+ cacheKey: cacheKey,
+ resourceType: .buildDetail,
+ ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
+ policy: .refreshIgnoringCache
)
- rawJobResponse = client.responseCache.get(forKey: cacheKey)
+ rawJobResponse = await client.cachedPayload(forKey: cacheKey)
.flatMap { String(data: $0, encoding: .utf8) }
- var loadedJob = result.job
- loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
- task.withOrdinal(index)
- }
- if job != loadedJob {
- job = loadedJob
- }
-
- if loadedJob.status.isTerminal {
- stopAutoRefresh()
- }
+ apply(result.value, metadata: result.metadata)
} catch {
self.error = error.userFacingMessage
}
@@ -201,7 +195,14 @@ final class BuildDetailViewModel {
loadingTaskLogs.insert(cacheKey)
do {
- taskLogs[cacheKey] = try await client.fetchText(url: logURL)
+ let logCacheKey = APICacheKeys.buildLog(url: logURL, jobId: jobId, task: cacheKey)
+ let result = try await client.fetchCachedText(
+ url: logURL,
+ cacheKey: logCacheKey,
+ ttl: APICacheTTLs.completedBuildLog,
+ policy: .cacheFirstThenRefresh
+ )
+ taskLogs[cacheKey] = result.value
failedTaskLogs.remove(cacheKey)
} catch {
failedTaskLogs.insert(cacheKey)
@@ -222,7 +223,13 @@ final class BuildDetailViewModel {
isLoadingBuildLog = true
do {
- buildLogText = try await client.fetchText(url: logURL)
+ let result = try await client.fetchCachedText(
+ url: logURL,
+ cacheKey: APICacheKeys.buildLog(url: logURL, jobId: jobId),
+ ttl: jobIsTerminal ? APICacheTTLs.completedBuildLog : APICacheTTLs.activeBuild,
+ policy: jobIsTerminal ? .cacheFirstThenRefresh : .refreshIgnoringCache
+ )
+ buildLogText = result.value
} catch {
self.error = error.userFacingMessage
}
@@ -287,6 +294,7 @@ final class BuildDetailViewModel {
variables: ["id": jobId],
responseType: CancelResponse.self
)
+ await invalidateAfterMutation()
await reloadJobPreservingDebugState()
} catch {
// Revert optimistic update on failure.
@@ -329,6 +337,7 @@ final class BuildDetailViewModel {
variables: variables,
responseType: SubmitJobResponse.self
)
+ await invalidateAfterMutation()
return result.submit.id
} catch {
setActionError("Couldn't rebuild. \(error.userFacingMessage)")
@@ -377,6 +386,7 @@ final class BuildDetailViewModel {
variables: variables,
responseType: SubmitJobResponse.self
)
+ await invalidateAfterMutation()
return result.submit.id
} catch {
setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
@@ -417,6 +427,52 @@ final class BuildDetailViewModel {
}
}
+ private func refreshJobInBackground() async {
+ guard !isRefreshingCachedData else { return }
+ isRefreshingCachedData = true
+ defer { isRefreshingCachedData = false }
+
+ do {
+ let result = try await client.executeCached(
+ service: .builds,
+ query: Self.detailQuery,
+ variables: ["id": jobId],
+ responseType: JobDetailResponse.self,
+ cacheKey: Self.cacheKey(for: jobId),
+ resourceType: .buildDetail,
+ ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
+ policy: .refreshIgnoringCache
+ )
+ apply(result.value, metadata: result.metadata)
+ } catch {
+ if job == nil {
+ self.error = error.userFacingMessage
+ }
+ }
+ }
+
+ private func apply(_ response: JobDetailResponse, metadata: CacheEntryMetadata?) {
+ cacheMetadata = metadata
+ var loadedJob = response.job
+ loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
+ task.withOrdinal(index)
+ }
+ if job != loadedJob {
+ job = loadedJob
+ }
+
+ if loadedJob.status.isTerminal {
+ stopAutoRefresh()
+ }
+ }
+
+ private func invalidateAfterMutation() async {
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "log"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+ }
+
private var shouldAutoRefresh: Bool {
guard let job else { return true }
return !job.status.isTerminal
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
index 9ccd74b..11f9e7f 100644
--- a/Hutch/Views/Repositories/FileTreeViewModel.swift
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -90,6 +90,7 @@ final class FileTreeViewModel {
private(set) var viewingObject: GitObject?
private(set) var isLoading = false
+ private(set) var cacheMetadata: CacheEntryMetadata?
var error: String?
// Available references for the branch/tag picker
@@ -285,12 +286,18 @@ final class FileTreeViewModel {
do {
let result: RevparseResponse
do {
- result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.rootTreeQuery,
variables: variables,
- responseType: RevparseResponse.self
+ responseType: RevparseResponse.self,
+ cacheKey: APICacheKeys.treeRoot(service: service, rid: repositoryRid, ref: revspec),
+ resourceType: .repositoryTree,
+ ttl: APICacheTTLs.movingRefFileContent,
+ policy: .cacheFirstThenRefresh
)
+ result = cached.value
+ cacheMetadata = cached.metadata
} catch {
if isMissingGitReferenceError(error) {
navStack = [FileNavEntry(name: "root", treeId: "")]
@@ -375,12 +382,18 @@ final class FileTreeViewModel {
]
do {
- let result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.subtreeQuery,
variables: variables,
- responseType: SubtreeResponse.self
+ responseType: SubtreeResponse.self,
+ cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId),
+ resourceType: .repositoryTree,
+ ttl: APICacheTTLs.immutableFileContent,
+ policy: .cacheFirstThenRefresh
)
+ let result = cached.value
+ cacheMetadata = cached.metadata
navStack.append(FileNavEntry(name: name, treeId: treeId))
var allEntries = result.repository?.object?.entries?.results ?? []
var cursor = result.repository?.object?.entries?.cursor
@@ -407,12 +420,18 @@ final class FileTreeViewModel {
]
do {
- let result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.blobQuery,
variables: variables,
- responseType: BlobResponse.self
+ responseType: BlobResponse.self,
+ cacheKey: APICacheKeys.blob(service: service, rid: repositoryRid, blobId: blobId),
+ resourceType: .repositoryFile,
+ ttl: APICacheTTLs.immutableFileContent,
+ policy: .cacheFirstThenRefresh
)
+ let result = cached.value
+ cacheMetadata = cached.metadata
viewingEntry = entry
viewingObject = result.repository?.object ?? .unknown
} catch {
@@ -453,12 +472,18 @@ final class FileTreeViewModel {
]
do {
- let result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.subtreeQuery,
variables: variables,
- responseType: SubtreeResponse.self
+ responseType: SubtreeResponse.self,
+ cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: targetEntry.treeId),
+ resourceType: .repositoryTree,
+ ttl: APICacheTTLs.immutableFileContent,
+ policy: .cacheFirstThenRefresh
)
+ let result = cached.value
+ cacheMetadata = cached.metadata
var allEntries = result.repository?.object?.entries?.results ?? []
var cursor = result.repository?.object?.entries?.cursor
while let nextCursor = cursor {
@@ -480,13 +505,17 @@ final class FileTreeViewModel {
"treeId": treeId,
"cursor": cursor
]
- let result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.treeEntriesPageQuery,
variables: variables,
- responseType: SubtreeResponse.self
+ responseType: SubtreeResponse.self,
+ cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId, cursor: cursor),
+ resourceType: .repositoryTree,
+ ttl: APICacheTTLs.immutableFileContent,
+ policy: .cacheFirstThenRefresh
)
- return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
+ return cached.value.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
}
// MARK: - Linked File (used by README link interception)
@@ -517,13 +546,17 @@ final class FileTreeViewModel {
"revspec": revspec,
"path": path
]
- let result = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.linkedFileQuery,
variables: variables,
- responseType: LinkedFileResponse.self
+ responseType: LinkedFileResponse.self,
+ cacheKey: APICacheKeys.path(service: service, rid: repositoryRid, ref: revspec, path: path),
+ resourceType: .repositoryFile,
+ ttl: revspec == "HEAD" ? APICacheTTLs.movingRefFileContent : APICacheTTLs.immutableFileContent,
+ policy: .cacheFirstThenRefresh
)
- return result.repository?.path
+ return cached.value.repository?.path
}
/// Dismiss the file view and go back to the directory listing.
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 9aff1a4..4839437 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -138,6 +138,7 @@ final class RepositoryDetailViewModel {
private(set) var readmePath: String?
private(set) var isLoadingReadme = false
private(set) var readmeLoaded = false
+ private(set) var readmeCacheMetadata: CacheEntryMetadata?
// MARK: - Artifacts state
@@ -379,13 +380,18 @@ final class RepositoryDetailViewModel {
do {
// Step 1: Check the custom HTML readme set via the web UI
- let result = try await client.execute(
+ let result = try await client.executeCached(
service: service,
query: Self.readmeQuery,
variables: ["rid": repository.rid],
- responseType: ReadmeResponse.self
+ responseType: ReadmeResponse.self,
+ cacheKey: APICacheKeys.readme(service: service, rid: repository.rid),
+ resourceType: .repositoryReadme,
+ ttl: APICacheTTLs.movingRefFileContent,
+ policy: .cacheFirstThenRefresh
)
- if let html = result.repository?.readme, !html.isEmpty {
+ readmeCacheMetadata = result.metadata
+ if let html = result.value.repository?.readme, !html.isEmpty {
readmePath = nil
readmeContent = .html(html)
readmeLoaded = true
@@ -396,12 +402,18 @@ final class RepositoryDetailViewModel {
for filename in Self.readmeFilenames {
let pathResult: PathResponse
do {
- pathResult = try await client.execute(
+ let cached = try await client.executeCached(
service: service,
query: Self.readmeFileQuery(filename: filename),
variables: ["rid": repository.rid],
- responseType: PathResponse.self
+ responseType: PathResponse.self,
+ cacheKey: APICacheKeys.readme(service: service, rid: repository.rid, path: filename),
+ resourceType: .repositoryReadme,
+ ttl: APICacheTTLs.movingRefFileContent,
+ policy: .cacheFirstThenRefresh
)
+ pathResult = cached.value
+ readmeCacheMetadata = cached.metadata
} catch {
if isEmptyRepositoryError(error) {
readmeContent = nil
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index ce980e2..f307429 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -133,6 +133,13 @@ struct SettingsView: View {
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
.themedRow()
+ Button {
+ Task { await appState.client.clearPersistentCache() }
+ } label: {
+ Label("Clear Cache", systemImage: "externaldrive.badge.xmark")
+ }
+ .themedRow()
+
Button("Reset App Data", role: .destructive) {
pendingDestructiveAction = .resetAppData
}
diff --git a/Hutch/Views/Shared/StaleCacheStatusRow.swift b/Hutch/Views/Shared/StaleCacheStatusRow.swift
new file mode 100644
index 0000000..166fec4
--- /dev/null
+++ b/Hutch/Views/Shared/StaleCacheStatusRow.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+
+struct StaleCacheStatusRow: View {
+ let metadata: CacheEntryMetadata
+ let isRefreshing: Bool
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: metadata.isExpired() ? "clock.badge.exclamationmark" : "clock")
+ .foregroundStyle(.secondary)
+ Text(statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ if isRefreshing {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+ .themedRow()
+ }
+
+ private var statusText: String {
+ if isRefreshing {
+ return "Showing cached data. Refreshing…"
+ }
+ if metadata.isExpired() {
+ return "Showing cached data. Last updated \(metadata.fetchedAt.relativeDescription)."
+ }
+ return "Last updated \(metadata.fetchedAt.relativeDescription)"
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 0028083..92263f6 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -169,6 +169,12 @@ struct TicketDetailView: View {
} else if let ticket = viewModel.ticket {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
+ if let cacheMetadata = viewModel.cacheMetadata {
+ StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+
// Header
ticketHeader(ticket, viewModel: viewModel)
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 483f749..df2fd46 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -98,7 +98,7 @@ private struct LabelsPage: Decodable, Sendable {
@MainActor
final class TicketDetailViewModel {
private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
- "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)"
+ APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
}
let ownerUsername: String
@@ -114,6 +114,8 @@ final class TicketDetailViewModel {
private(set) var isPerformingAction = false
private(set) var trackerLabels: [TicketLabel] = []
private(set) var rawTicketResponse: String?
+ private(set) var cacheMetadata: CacheEntryMetadata?
+ private(set) var isRefreshingCachedData = false
var commentText = ""
var error: String?
@@ -291,30 +293,25 @@ final class TicketDetailViewModel {
rawTicketResponse = nil
do {
- let result = try await client.execute(
+ let result = try await client.executeCached(
service: .todo,
query: Self.detailQuery,
variables: [
"rid": trackerRid,
"ticketId": ticketId
],
- responseType: TicketDetailResponse.self
- )
- 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
+ responseType: TicketDetailResponse.self,
+ cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
+ resourceType: .ticketDetail,
+ ttl: APICacheTTLs.ticketDetail,
+ policy: .cacheFirstThenRefresh
)
- events = payload.events.results.sorted(by: Self.timelineOrder)
+ apply(result.value, metadata: result.metadata)
+ if result.isFromCache {
+ isLoading = false
+ await refreshTicketInBackground()
+ return
+ }
} catch {
self.error = error.userFacingMessage
}
@@ -329,7 +326,7 @@ final class TicketDetailViewModel {
do {
let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
- let result = try await client.executeAndCache(
+ let result = try await client.executeCached(
service: .todo,
query: Self.detailQuery,
variables: [
@@ -337,25 +334,14 @@ final class TicketDetailViewModel {
"ticketId": ticketId
],
responseType: TicketDetailResponse.self,
- cacheKey: cacheKey
+ cacheKey: cacheKey,
+ resourceType: .ticketDetail,
+ ttl: APICacheTTLs.ticketDetail,
+ policy: .refreshIgnoringCache
)
- rawTicketResponse = client.responseCache.get(forKey: cacheKey)
+ rawTicketResponse = await client.cachedPayload(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)
+ apply(result.value, metadata: result.metadata)
} catch {
self.error = error.userFacingMessage
}
@@ -391,6 +377,7 @@ final class TicketDetailViewModel {
events.append(event)
events.sort(by: Self.timelineOrder)
commentText = ""
+ await invalidateAfterMutation()
} catch {
self.error = error.userFacingMessage
}
@@ -423,6 +410,7 @@ final class TicketDetailViewModel {
],
responseType: UpdateStatusResponse.self
)
+ await invalidateAfterMutation()
// Re-fetch the ticket to get updated status/resolution
await reloadTicketPreservingDebugState()
} catch {
@@ -457,6 +445,7 @@ final class TicketDetailViewModel {
],
responseType: AssignUserResponse.self
)
+ await invalidateAfterMutation()
// Reload to reflect the change
await reloadTicketPreservingDebugState()
} catch {
@@ -503,6 +492,7 @@ final class TicketDetailViewModel {
],
responseType: AssignUserResponse.self
)
+ await invalidateAfterMutation()
await reloadTicketPreservingDebugState()
} catch {
ticket = TicketDetail(
@@ -550,6 +540,7 @@ final class TicketDetailViewModel {
],
responseType: UnassignUserResponse.self
)
+ await invalidateAfterMutation()
// Reload to reflect the change
await reloadTicketPreservingDebugState()
} catch {
@@ -575,6 +566,7 @@ final class TicketDetailViewModel {
],
responseType: LabelTicketResponse.self
)
+ await invalidateAfterMutation()
await reloadTicketPreservingDebugState()
} catch {
self.error = error.userFacingMessage
@@ -599,6 +591,7 @@ final class TicketDetailViewModel {
],
responseType: UnlabelTicketResponse.self
)
+ await invalidateAfterMutation()
await reloadTicketPreservingDebugState()
} catch {
self.error = error.userFacingMessage
@@ -654,6 +647,61 @@ final class TicketDetailViewModel {
}
}
+ private func refreshTicketInBackground() async {
+ guard !isRefreshingCachedData else { return }
+ isRefreshingCachedData = true
+ defer { isRefreshingCachedData = false }
+
+ do {
+ let result = try await client.executeCached(
+ service: .todo,
+ query: Self.detailQuery,
+ variables: [
+ "rid": trackerRid,
+ "ticketId": ticketId
+ ],
+ responseType: TicketDetailResponse.self,
+ cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
+ resourceType: .ticketDetail,
+ ttl: APICacheTTLs.ticketDetail,
+ policy: .refreshIgnoringCache
+ )
+ apply(result.value, metadata: result.metadata)
+ } catch {
+ if ticket == nil {
+ self.error = error.userFacingMessage
+ }
+ }
+ }
+
+ private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
+ cacheMetadata = metadata
+ let payload = response.tracker.ticket
+ let updatedTicket = 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
+ )
+ ticket = updatedTicket
+ let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
+ events = updatedEvents
+ }
+
+ private func invalidateAfterMutation() async {
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
+ }
+
static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
let userCanonical = normalizedCanonicalName(user.canonicalName)