summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-02 00:27:34 -0500
committerChristian Cleberg <[email protected]>2026-04-02 00:27:34 -0500
commitf820109a956c627e510880a7cac88cb35032203b (patch)
tree942bfad93efbb8a2a98e98b0a9590e2631613b3a /Hutch
parent7f79cadf99ecf014834855e2e4a5c7d60dd709ac (diff)
downloadhutch-f820109a956c627e510880a7cac88cb35032203b.tar.gz
hutch-f820109a956c627e510880a7cac88cb35032203b.tar.bz2
hutch-f820109a956c627e510880a7cac88cb35032203b.zip
Implement search history for Look Up page
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppStorageKeys.swift1
-rw-r--r--Hutch/Views/Lookup/LookupHistoryStore.swift60
-rw-r--r--Hutch/Views/Lookup/LookupView.swift57
3 files changed, 116 insertions, 2 deletions
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)