diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 02:24:43 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-08-07 02:24:43 -0500 |
| commit | 0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3 (patch) | |
| tree | 7137471dd7cdd5583db4ffbead31414497c431a9 | |
| parent | fbcb4113f110a7db9cf21d18510cb59c2e90ba23 (diff) | |
| parent | 1c4e968c31ee451666a65e05c762c9422c768426 (diff) | |
| download | hutch-0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3.tar.gz hutch-0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3.tar.bz2 hutch-0a3e55c15cfb9ce1bd3df0c7b352760bc2ee33c3.zip | |
Merge pull request #29 from krazywarez/issues-9-11-18
Fix checklist rendering, add clear-recent-activity, speed up pull-to-refresh
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppState.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 33 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 51 | ||||
| -rw-r--r-- | Hutch/Views/Home/RecentActivityStore.swift | 10 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ReadmeView.swift | 4 | ||||
| -rw-r--r-- | HutchTests/RecentActivityStoreTests.swift | 38 |
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) + } +} |
