diff options
| author | Christian Cleberg <[email protected]> | 2026-04-02 00:27:34 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-02 00:27:34 -0500 |
| commit | f820109a956c627e510880a7cac88cb35032203b (patch) | |
| tree | 942bfad93efbb8a2a98e98b0a9590e2631613b3a | |
| parent | 7f79cadf99ecf014834855e2e4a5c7d60dd709ac (diff) | |
| download | hutch-f820109a956c627e510880a7cac88cb35032203b.tar.gz hutch-f820109a956c627e510880a7cac88cb35032203b.tar.bz2 hutch-f820109a956c627e510880a7cac88cb35032203b.zip | |
Implement search history for Look Up page
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 1 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupHistoryStore.swift | 60 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 57 | ||||
| -rw-r--r-- | 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) + } +} |
