krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

a0ed11a3c4ddb2f532fa70884dc2829a23a5eec8

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T06:44:17Z

Let users clear recent activity on Home (#11)

Add a Clear button in the Home Recent section header to drop all
entries, plus swipe-to-delete on each row to forget a single item
(gated on the existing swipe-actions setting). Back it with
RecentActivityStore.clear and remove(id:), with unit tests.
 Hutch/Views/Home/HomeView.swift            | 33 +++++++++++++++++++++++++-
 Hutch/Views/Home/RecentActivityStore.swift | 10 ++++++++
 HutchTests/RecentActivityStoreTests.swift  | 38 ++++++++++++++++++++++++++++++
 3 files changed, 80 insertions(+), 1 deletion(-)

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/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/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)
+    }
+}