krz/hutch

an ios client for sourcehut

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

f820109a956c627e510880a7cac88cb35032203b

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-02T05:27:34Z

Implement search history for Look Up page
 Hutch.xcodeproj/project.pbxproj             | 16 +++---
 Hutch/App/AppStorageKeys.swift              |  1 +
 Hutch/Views/Lookup/LookupHistoryStore.swift | 60 +++++++++++++++++++++++
 Hutch/Views/Lookup/LookupView.swift         | 57 +++++++++++++++++++++-
 HutchTests/LookupHistoryStoreTests.swift    | 75 +++++++++++++++++++++++++++++
 5 files changed, 199 insertions(+), 10 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index b1e277e..e815185 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -539,7 +539,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 23;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.0;
+				MARKETING_VERSION = 2.9.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 23;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.9.0;
+				MARKETING_VERSION = 2.9.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 23;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.8.3;
+				MARKETING_VERSION = 2.9.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 22;
+				CURRENT_PROJECT_VERSION = 23;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.8.3;
+				MARKETING_VERSION = 2.9.1;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift
index 87ec420..6b8c804 100644
--- a/Hutch/App/AppStorageKeys.swift
+++ b/Hutch/App/AppStorageKeys.swift
@@ -3,4 +3,5 @@ enum AppStorageKeys {
     static let swipeActionsEnabled = "swipeActionsEnabled"
     static let activeAccountID = "activeAccountID"
     static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
+    static let lookupHistory = "lookupHistory"
 }
diff --git a/Hutch/Views/Lookup/LookupHistoryStore.swift b/Hutch/Views/Lookup/LookupHistoryStore.swift
new file mode 100644
index 0000000..e127970
--- /dev/null
+++ b/Hutch/Views/Lookup/LookupHistoryStore.swift
@@ -0,0 +1,60 @@
+import Foundation
+
+struct LookupHistoryEntry: Codable, Hashable, Identifiable, Sendable {
+    let type: LookupType
+    let query: String
+    let createdAt: Date
+
+    var id: String {
+        "\(type.rawValue):\(query)"
+    }
+}
+
+enum LookupHistoryStore {
+    private static let maximumEntries = 20
+
+    static func load(defaults: UserDefaults = .standard) -> [LookupHistoryEntry] {
+        guard let data = defaults.data(forKey: AppStorageKeys.lookupHistory) else {
+            return []
+        }
+
+        do {
+            return try JSONDecoder().decode([LookupHistoryEntry].self, from: data)
+        } catch {
+            defaults.removeObject(forKey: AppStorageKeys.lookupHistory)
+            return []
+        }
+    }
+
+    static func record(
+        type: LookupType,
+        query: String,
+        defaults: UserDefaults = .standard,
+        now: Date = .now
+    ) {
+        let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !normalizedQuery.isEmpty else { return }
+
+        var entries = load(defaults: defaults)
+        entries.removeAll { $0.type == type && $0.query == normalizedQuery }
+        entries.insert(
+            LookupHistoryEntry(type: type, query: normalizedQuery, createdAt: now),
+            at: 0
+        )
+
+        if entries.count > maximumEntries {
+            entries = Array(entries.prefix(maximumEntries))
+        }
+
+        save(entries, defaults: defaults)
+    }
+
+    static func clear(defaults: UserDefaults = .standard) {
+        defaults.removeObject(forKey: AppStorageKeys.lookupHistory)
+    }
+
+    private static func save(_ entries: [LookupHistoryEntry], defaults: UserDefaults) {
+        guard let data = try? JSONEncoder().encode(entries) else { return }
+        defaults.set(data, forKey: AppStorageKeys.lookupHistory)
+    }
+}
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 413ea12..bee2453 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -3,7 +3,7 @@ import os
 
 private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup")
 
-enum LookupType: String, CaseIterable, Identifiable {
+enum LookupType: String, CaseIterable, Identifiable, Codable, Sendable {
     case user = "User"
     case gitRepo = "Git Repo"
     case hgRepo = "Hg Repo"
@@ -74,10 +74,12 @@ final class LookupViewModel {
     var inputText: String = ""
     private(set) var result: LookupResult?
     private(set) var isLooking = false
+    private(set) var history: [LookupHistoryEntry]
     var error: String?
 
     private let client: SRHTClient
     private let appState: AppState
+    private let defaults: UserDefaults
 
     var resultBinding: Binding<LookupResult?> {
         Binding(
@@ -90,9 +92,11 @@ final class LookupViewModel {
         )
     }
 
-    init(client: SRHTClient, appState: AppState) {
+    init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) {
         self.client = client
         self.appState = appState
+        self.defaults = defaults
+        self.history = LookupHistoryStore.load(defaults: defaults)
     }
 
     func lookup() async {
@@ -123,6 +127,17 @@ final class LookupViewModel {
         }
     }
 
+    func rerun(_ entry: LookupHistoryEntry) async {
+        selectedType = entry.type
+        inputText = entry.query
+        await lookup()
+    }
+
+    func clearHistory() {
+        LookupHistoryStore.clear(defaults: defaults)
+        history = []
+    }
+
     private func parseOwnerAndName() -> (owner: String, name: String)? {
         let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
         let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
@@ -161,6 +176,7 @@ final class LookupViewModel {
 
     private func lookupUser() async throws -> LookupResult {
         guard let username = parseUsername() else { throw LookupError.invalidInput }
+        recordHistory(type: .user, query: "~\(username)")
 
         struct Response: Decodable, Sendable {
             let user: User
@@ -216,6 +232,8 @@ final class LookupViewModel {
 
     private func lookupRepository(service: SRHTService) async throws -> LookupResult {
         guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+        let type: LookupType = service == .git ? .gitRepo : .hgRepo
+        recordHistory(type: type, query: "~\(owner)/\(name)")
 
         let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
         let resolvedRepository = RepositorySummary(
@@ -235,6 +253,7 @@ final class LookupViewModel {
 
     private func lookupMailingList() async throws -> LookupResult {
         guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+        recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
 
         struct Response: Decodable, Sendable {
             let user: UserWithList
@@ -266,12 +285,14 @@ final class LookupViewModel {
 
     private func lookupTracker() async throws -> LookupResult {
         guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
+        recordHistory(type: .tracker, query: "~\(owner)/\(name)")
         let tracker = try await appState.resolveTracker(owner: owner, name: name)
         return .tracker(tracker)
     }
 
     private func lookupBuildJob() async throws -> LookupResult {
         guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
+        recordHistory(type: .buildJob, query: String(jobId))
 
         struct Response: Decodable, Sendable {
             let job: JobIdOnly
@@ -300,6 +321,11 @@ final class LookupViewModel {
     private enum LookupError: Error {
         case invalidInput
     }
+
+    private func recordHistory(type: LookupType, query: String) {
+        LookupHistoryStore.record(type: type, query: query, defaults: defaults)
+        history = LookupHistoryStore.load(defaults: defaults)
+    }
 }
 
 struct LookupView: View {
@@ -361,6 +387,33 @@ struct LookupView: View {
                     }
                 }
             }
+
+            if !vm.history.isEmpty {
+                Section("Recent Searches") {
+                    ForEach(vm.history) { entry in
+                        Button {
+                            Task { await vm.rerun(entry) }
+                        } label: {
+                            HStack {
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(entry.query)
+                                        .foregroundStyle(.primary)
+                                    Text(entry.type.rawValue)
+                                        .font(.caption)
+                                        .foregroundStyle(.secondary)
+                                }
+                                Spacer()
+                            }
+                        }
+                        .disabled(vm.isLooking)
+                    }
+
+                    Button("Clear History", role: .destructive) {
+                        vm.clearHistory()
+                    }
+                    .disabled(vm.isLooking)
+                }
+            }
         }
         .formStyle(.grouped)
         .srhtErrorBanner(error: $vm.error)
diff --git a/HutchTests/LookupHistoryStoreTests.swift b/HutchTests/LookupHistoryStoreTests.swift
new file mode 100644
index 0000000..414d016
--- /dev/null
+++ b/HutchTests/LookupHistoryStoreTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct LookupHistoryStoreTests {
+
+    @Test
+    func recordsMostRecentUniqueSearchFirst() {
+        let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        LookupHistoryStore.record(
+            type: .user,
+            query: "~alice",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 100)
+        )
+        LookupHistoryStore.record(
+            type: .gitRepo,
+            query: "~alice/hutch",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 200)
+        )
+        LookupHistoryStore.record(
+            type: .user,
+            query: "~alice",
+            defaults: defaults,
+            now: Date(timeIntervalSince1970: 300)
+        )
+
+        let history = LookupHistoryStore.load(defaults: defaults)
+
+        #expect(history.count == 2)
+        #expect(history.map(\.type) == [.user, .gitRepo])
+        #expect(history.map(\.query) == ["~alice", "~alice/hutch"])
+        #expect(history.first?.createdAt == Date(timeIntervalSince1970: 300))
+    }
+
+    @Test
+    func trimsHistoryToMaximumSize() {
+        let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        for index in 0..<25 {
+            LookupHistoryStore.record(
+                type: .buildJob,
+                query: "\(index)",
+                defaults: defaults,
+                now: Date(timeIntervalSince1970: TimeInterval(index))
+            )
+        }
+
+        let history = LookupHistoryStore.load(defaults: defaults)
+
+        #expect(history.count == 20)
+        #expect(history.first?.query == "24")
+        #expect(history.last?.query == "5")
+    }
+
+    @Test
+    func clearsPersistedHistory() {
+        let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defer { defaults.removePersistentDomain(forName: suiteName) }
+
+        LookupHistoryStore.record(type: .tracker, query: "~owner/todo", defaults: defaults)
+        #expect(!LookupHistoryStore.load(defaults: defaults).isEmpty)
+
+        LookupHistoryStore.clear(defaults: defaults)
+
+        #expect(LookupHistoryStore.load(defaults: defaults).isEmpty)
+    }
+}