summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppState.swift2
-rw-r--r--Hutch/Views/Home/HomeView.swift33
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift51
-rw-r--r--Hutch/Views/Home/RecentActivityStore.swift10
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift4
-rw-r--r--HutchTests/RecentActivityStoreTests.swift38
7 files changed, 135 insertions, 19 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 702c075..b7ddbe9 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -8,8 +8,8 @@
/* Begin PBXBuildFile section */
8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
- 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
- 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
+ 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.nfo */; };
+ 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.txt */; };
8B2FA9CD2F7E211800E609E2 /* meta.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C82F7E211800E609E2 /* meta.json */; };
8B2FA9CE2F7E211800E609E2 /* hg.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C62F7E211800E609E2 /* hg.json */; };
8B2FA9CF2F7E211800E609E2 /* builds.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2FA9C42F7E211800E609E2 /* builds.json */; };
@@ -64,8 +64,8 @@
/* Begin PBXFileReference section */
8B01C8012F955ACC00FCFB9A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = "<group>"; };
8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
- 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
- 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
+ 8B2F89642F69DEB900FC0253 /* README.nfo */ = {isa = PBXFileReference; lastKnownFileType = text; path = README.nfo; sourceTree = "<group>"; };
+ 8B2F89652F69DEB900FC0253 /* SECURITY.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = SECURITY.txt; sourceTree = "<group>"; };
8B2FA9C42F7E211800E609E2 /* builds.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = builds.json; sourceTree = "<group>"; };
8B2FA9C52F7E211800E609E2 /* git.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = git.json; sourceTree = "<group>"; };
8B2FA9C62F7E211800E609E2 /* hg.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = hg.json; sourceTree = "<group>"; };
@@ -205,8 +205,8 @@
8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */,
8B8182C22F6B742B000AE049 /* HutchTests */,
8B2F89632F69DEB900FC0253 /* LICENSE */,
- 8B2F89642F69DEB900FC0253 /* README.md */,
- 8B2F89652F69DEB900FC0253 /* SECURITY.md */,
+ 8B2F89642F69DEB900FC0253 /* README.nfo */,
+ 8B2F89652F69DEB900FC0253 /* SECURITY.txt */,
8B4B28D32F6704280045FA19 /* Hutch */,
8BE081F42F80000100000001 /* Shared */,
8BE081F52F80000100000001 /* HutchWidgetExtension */,
@@ -378,8 +378,8 @@
buildActionMask = 2147483647;
files = (
8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
- 8B2F89672F69DEB900FC0253 /* README.md in Resources */,
- 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
+ 8B2F89672F69DEB900FC0253 /* README.nfo in Resources */,
+ 8B2F89682F69DEB900FC0253 /* SECURITY.txt in Resources */,
8B2FA9CD2F7E211800E609E2 /* meta.json in Resources */,
8B2FA9CE2F7E211800E609E2 /* hg.json in Resources */,
8B2FA9CF2F7E211800E609E2 /* builds.json in Resources */,
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index ad31c5c..a526afd 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -623,7 +623,7 @@ final class AppState {
defaults: accountDefaults,
accountID: activeAccountID
)
- await viewModel.loadDashboard()
+ await viewModel.loadDashboard(awaitInboxUnread: true)
}
private func clearWebData() async {
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index f0ec9ff..0e3e214 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -5,6 +5,8 @@ struct HomeView: View {
@Environment(\.scenePhase) private var scenePhase
@AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard)
+ private var swipeActionsEnabled = true
@State private var viewModel: HomeViewModel?
@State private var recentItems: [RecentActivityEntry] = []
@State private var isOpeningRecentItem = false
@@ -118,7 +120,7 @@ struct HomeView: View {
@ViewBuilder
private var recentSection: some View {
if !recentItems.isEmpty {
- Section("Recent") {
+ Section {
ForEach(recentItems.prefix(3)) { item in
Button {
openRecentItem(item)
@@ -128,12 +130,41 @@ struct HomeView: View {
.buttonStyle(.plain)
.disabled(isOpeningRecentItem)
.listRowSeparator(.hidden)
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if swipeActionsEnabled {
+ Button(role: .destructive) {
+ removeRecentItem(item)
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
}
.themedRow()
+ } header: {
+ HStack {
+ Text("Recent")
+ Spacer()
+ Button("Clear") {
+ clearRecentActivity()
+ }
+ .font(.caption)
+ .textCase(nil)
+ }
}
}
}
+ private func removeRecentItem(_ item: RecentActivityEntry) {
+ RecentActivityStore.remove(id: item.id, defaults: appState.accountDefaults)
+ loadRecentActivity()
+ }
+
+ private func clearRecentActivity() {
+ RecentActivityStore.clear(defaults: appState.accountDefaults)
+ loadRecentActivity()
+ }
+
private func buildsSection(_ viewModel: HomeViewModel) -> some View {
Section("Builds") {
Button {
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 7779bea..23b2d80 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -199,6 +199,7 @@ final class HomeViewModel {
private let projectService: ProjectService
private let ticketFetchConcurrencyLimit = 6
private let inboxUnreadConcurrencyLimit = 4
+ private var inboxRefreshTask: Task<Void, Never>?
private var currentUserKey: String {
currentUser.canonicalName
@@ -348,7 +349,19 @@ final class HomeViewModel {
/// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
/// whatever is already cached and only schedules a background fetch, so new
/// mail cannot show up on the first pull.
- func loadDashboard(forceRefresh: Bool = false) async {
+ ///
+ /// The inbox unread count is deliberately kept off this method's critical
+ /// path: it paginates every thread of every subscribed list and costs
+ /// ~25s, which — because `.refreshable` binds the spinner to this method
+ /// returning — would hold the pull-to-refresh spinner for the whole time.
+ /// Instead it refreshes in a detached background task and updates the badge
+ /// when it lands, so the spinner returns as soon as the fast branches settle.
+ ///
+ /// Callers that build a persisted snapshot from a throwaway view model —
+ /// the widget / App Intents needs-attention refresh — cannot let the inbox
+ /// fetch outlive them, so they pass `awaitInboxUnread: true` to fold it back
+ /// onto the critical path and get an accurate count before this returns.
+ func loadDashboard(forceRefresh: Bool = false, awaitInboxUnread: Bool = false) async {
isLoadingProjects = true
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
@@ -362,7 +375,6 @@ final class HomeViewModel {
async let projectsTask = loadProjects(forceRefresh: forceRefresh)
async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
- async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
// Resolve system status first so the Home title-bar status badge can
@@ -413,13 +425,40 @@ final class HomeViewModel {
}
isLoadingAssignedTickets = false
- let inboxUnreadSnapshot = await inboxUnreadTask
- unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
- unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
- hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
lastRefreshed = Date()
persistNeedsAttentionSnapshot()
persistSystemStatusWidgetSnapshot()
+
+ if awaitInboxUnread {
+ inboxRefreshTask?.cancel()
+ inboxRefreshTask = nil
+ applyInboxUnreadSnapshot(await loadInboxUnreadSnapshot(forceRefresh: forceRefresh))
+ } else {
+ refreshInboxUnreadInBackground(forceRefresh: forceRefresh)
+ }
+ }
+
+ /// Refreshes the inbox unread badge without blocking `loadDashboard`.
+ ///
+ /// Any in-flight refresh is cancelled so overlapping pulls don't stack
+ /// ~25s fetches. On cancellation the snapshot loader returns `nil`; the
+ /// early `Task.isCancelled` check then leaves the existing badge untouched
+ /// rather than wiping it, so a superseding refresh owns the final value.
+ private func refreshInboxUnreadInBackground(forceRefresh: Bool) {
+ inboxRefreshTask?.cancel()
+ inboxRefreshTask = Task { [weak self] in
+ guard let self else { return }
+ let snapshot = await self.loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
+ guard !Task.isCancelled else { return }
+ self.applyInboxUnreadSnapshot(snapshot)
+ }
+ }
+
+ private func applyInboxUnreadSnapshot(_ snapshot: HomeInboxUnreadSnapshot?) {
+ unreadInboxThreadCount = snapshot?.unreadCount
+ unreadInboxThreads = snapshot?.threads ?? []
+ hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+ persistNeedsAttentionSnapshot()
}
/// Returns true if sufficient time has elapsed since the last dashboard refresh.
diff --git a/Hutch/Views/Home/RecentActivityStore.swift b/Hutch/Views/Home/RecentActivityStore.swift
index 464a2c1..6bc5720 100644
--- a/Hutch/Views/Home/RecentActivityStore.swift
+++ b/Hutch/Views/Home/RecentActivityStore.swift
@@ -129,6 +129,16 @@ enum RecentActivityStore {
)
}
+ static func remove(id: String, defaults: UserDefaults) {
+ var entries = load(defaults: defaults)
+ entries.removeAll { $0.id == id }
+ save(entries, defaults: defaults)
+ }
+
+ static func clear(defaults: UserDefaults) {
+ defaults.removeObject(forKey: AppStorageKeys.recentActivity)
+ }
+
private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) {
var entries = load(defaults: defaults)
entries.removeAll { $0.id == entry.id }
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 69d105b..b8b9622 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -1809,9 +1809,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
vertical-align: middle;
}
.task-list-item {
- display: inline-flex;
- align-items: center;
- gap: 0.1rem;
+ display: inline;
}
a { color: \(linkColor); }
table { border-collapse: collapse; width: 100%; }
diff --git a/HutchTests/RecentActivityStoreTests.swift b/HutchTests/RecentActivityStoreTests.swift
new file mode 100644
index 0000000..e6fcc04
--- /dev/null
+++ b/HutchTests/RecentActivityStoreTests.swift
@@ -0,0 +1,38 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct RecentActivityStoreTests {
+ @Test
+ func removeDropsMatchingEntryKeepingOthers() {
+ let defaults = UserDefaults(suiteName: #function)!
+ defaults.removePersistentDomain(forName: #function)
+
+ RecentActivityStore.recordBuild(jobId: 1, title: "Build 1", defaults: defaults)
+ RecentActivityStore.recordBuild(jobId: 2, title: "Build 2", defaults: defaults)
+
+ RecentActivityStore.remove(id: "build:1", defaults: defaults)
+
+ let remaining = RecentActivityStore.load(defaults: defaults)
+ #expect(remaining.map(\.id) == ["build:2"])
+ }
+
+ @Test
+ func clearRemovesAllEntries() {
+ let defaults = UserDefaults(suiteName: #function)!
+ defaults.removePersistentDomain(forName: #function)
+
+ RecentActivityStore.recordBuild(jobId: 1, title: "Build 1", defaults: defaults)
+ RecentActivityStore.recordTicket(
+ ownerUsername: "~alice",
+ trackerName: "hutch",
+ ticketId: 42,
+ title: "A ticket",
+ defaults: defaults
+ )
+
+ RecentActivityStore.clear(defaults: defaults)
+
+ #expect(RecentActivityStore.load(defaults: defaults).isEmpty)
+ }
+}