Commit fddfb1cbb5

fddfb1cbb5713eb60ff928ea625631f40783884c

parent: e2749ef0c0

Unsigned

cmc <hello@cleberg.net> · 2026-04-02 05:27 UTC

Implement search history for Look Up page

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -539,7 +539,7 @@
539539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 22;
542 CURRENT_PROJECT_VERSION = 23;
543543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544544 ENABLE_PREVIEWS = YES;
545545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556556 "$(inherited)",
557557 "@executable_path/Frameworks",
558558 );
559 MARKETING_VERSION = 2.9.0;
559 MARKETING_VERSION = 2.9.1;
560560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561561 PRODUCT_NAME = "$(TARGET_NAME)";
562562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 22;
579 CURRENT_PROJECT_VERSION = 23;
580580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581581 ENABLE_PREVIEWS = YES;
582582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593593 "$(inherited)",
594594 "@executable_path/Frameworks",
595595 );
596 MARKETING_VERSION = 2.9.0;
596 MARKETING_VERSION = 2.9.1;
597597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598598 PRODUCT_NAME = "$(TARGET_NAME)";
599599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656656 APPLICATION_EXTENSION_API_ONLY = YES;
657657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 22;
659 CURRENT_PROJECT_VERSION = 23;
660660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661661 GENERATE_INFOPLIST_FILE = NO;
662662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666666 "@executable_path/Frameworks",
667667 "@executable_path/../../Frameworks",
668668 );
669 MARKETING_VERSION = 2.8.3;
669 MARKETING_VERSION = 2.9.1;
670670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671671 PRODUCT_NAME = "$(TARGET_NAME)";
672672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685685 APPLICATION_EXTENSION_API_ONLY = YES;
686686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 22;
688 CURRENT_PROJECT_VERSION = 23;
689689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690690 GENERATE_INFOPLIST_FILE = NO;
691691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695695 "@executable_path/Frameworks",
696696 "@executable_path/../../Frameworks",
697697 );
698 MARKETING_VERSION = 2.8.3;
698 MARKETING_VERSION = 2.9.1;
699699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700700 PRODUCT_NAME = "$(TARGET_NAME)";
701701 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -3,4 +3,5 @@ enum AppStorageKeys {
33 static let swipeActionsEnabled = "swipeActionsEnabled"
44 static let activeAccountID = "activeAccountID"
55 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
6 static let lookupHistory = "lookupHistory"
67}
Hutch/Views/Lookup/LookupHistoryStore.swift added +60
@@ -0,0 +1,60 @@
1import Foundation
2
3struct LookupHistoryEntry: Codable, Hashable, Identifiable, Sendable {
4 let type: LookupType
5 let query: String
6 let createdAt: Date
7
8 var id: String {
9 "\(type.rawValue):\(query)"
10 }
11}
12
13enum LookupHistoryStore {
14 private static let maximumEntries = 20
15
16 static func load(defaults: UserDefaults = .standard) -> [LookupHistoryEntry] {
17 guard let data = defaults.data(forKey: AppStorageKeys.lookupHistory) else {
18 return []
19 }
20
21 do {
22 return try JSONDecoder().decode([LookupHistoryEntry].self, from: data)
23 } catch {
24 defaults.removeObject(forKey: AppStorageKeys.lookupHistory)
25 return []
26 }
27 }
28
29 static func record(
30 type: LookupType,
31 query: String,
32 defaults: UserDefaults = .standard,
33 now: Date = .now
34 ) {
35 let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
36 guard !normalizedQuery.isEmpty else { return }
37
38 var entries = load(defaults: defaults)
39 entries.removeAll { $0.type == type && $0.query == normalizedQuery }
40 entries.insert(
41 LookupHistoryEntry(type: type, query: normalizedQuery, createdAt: now),
42 at: 0
43 )
44
45 if entries.count > maximumEntries {
46 entries = Array(entries.prefix(maximumEntries))
47 }
48
49 save(entries, defaults: defaults)
50 }
51
52 static func clear(defaults: UserDefaults = .standard) {
53 defaults.removeObject(forKey: AppStorageKeys.lookupHistory)
54 }
55
56 private static func save(_ entries: [LookupHistoryEntry], defaults: UserDefaults) {
57 guard let data = try? JSONEncoder().encode(entries) else { return }
58 defaults.set(data, forKey: AppStorageKeys.lookupHistory)
59 }
60}
Hutch/Views/Lookup/LookupView.swift +55 −2
@@ -3,7 +3,7 @@ import os
33
44private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup")
55
6enum LookupType: String, CaseIterable, Identifiable {
6enum LookupType: String, CaseIterable, Identifiable, Codable, Sendable {
77 case user = "User"
88 case gitRepo = "Git Repo"
99 case hgRepo = "Hg Repo"
@@ -74,10 +74,12 @@ final class LookupViewModel {
7474 var inputText: String = ""
7575 private(set) var result: LookupResult?
7676 private(set) var isLooking = false
77 private(set) var history: [LookupHistoryEntry]
7778 var error: String?
7879
7980 private let client: SRHTClient
8081 private let appState: AppState
82 private let defaults: UserDefaults
8183
8284 var resultBinding: Binding<LookupResult?> {
8385 Binding(
@@ -90,9 +92,11 @@ final class LookupViewModel {
9092 )
9193 }
9294
93 init(client: SRHTClient, appState: AppState) {
95 init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) {
9496 self.client = client
9597 self.appState = appState
98 self.defaults = defaults
99 self.history = LookupHistoryStore.load(defaults: defaults)
96100 }
97101
98102 func lookup() async {
@@ -123,6 +127,17 @@ final class LookupViewModel {
123127 }
124128 }
125129
130 func rerun(_ entry: LookupHistoryEntry) async {
131 selectedType = entry.type
132 inputText = entry.query
133 await lookup()
134 }
135
136 func clearHistory() {
137 LookupHistoryStore.clear(defaults: defaults)
138 history = []
139 }
140
126141 private func parseOwnerAndName() -> (owner: String, name: String)? {
127142 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
128143 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
@@ -161,6 +176,7 @@ final class LookupViewModel {
161176
162177 private func lookupUser() async throws -> LookupResult {
163178 guard let username = parseUsername() else { throw LookupError.invalidInput }
179 recordHistory(type: .user, query: "~\(username)")
164180
165181 struct Response: Decodable, Sendable {
166182 let user: User
@@ -216,6 +232,8 @@ final class LookupViewModel {
216232
217233 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
218234 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
235 let type: LookupType = service == .git ? .gitRepo : .hgRepo
236 recordHistory(type: type, query: "~\(owner)/\(name)")
219237
220238 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
221239 let resolvedRepository = RepositorySummary(
@@ -235,6 +253,7 @@ final class LookupViewModel {
235253
236254 private func lookupMailingList() async throws -> LookupResult {
237255 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
256 recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
238257
239258 struct Response: Decodable, Sendable {
240259 let user: UserWithList
@@ -266,12 +285,14 @@ final class LookupViewModel {
266285
267286 private func lookupTracker() async throws -> LookupResult {
268287 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
288 recordHistory(type: .tracker, query: "~\(owner)/\(name)")
269289 let tracker = try await appState.resolveTracker(owner: owner, name: name)
270290 return .tracker(tracker)
271291 }
272292
273293 private func lookupBuildJob() async throws -> LookupResult {
274294 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
295 recordHistory(type: .buildJob, query: String(jobId))
275296
276297 struct Response: Decodable, Sendable {
277298 let job: JobIdOnly
@@ -300,6 +321,11 @@ final class LookupViewModel {
300321 private enum LookupError: Error {
301322 case invalidInput
302323 }
324
325 private func recordHistory(type: LookupType, query: String) {
326 LookupHistoryStore.record(type: type, query: query, defaults: defaults)
327 history = LookupHistoryStore.load(defaults: defaults)
328 }
303329}
304330
305331struct LookupView: View {
@@ -361,6 +387,33 @@ struct LookupView: View {
361387 }
362388 }
363389 }
390
391 if !vm.history.isEmpty {
392 Section("Recent Searches") {
393 ForEach(vm.history) { entry in
394 Button {
395 Task { await vm.rerun(entry) }
396 } label: {
397 HStack {
398 VStack(alignment: .leading, spacing: 2) {
399 Text(entry.query)
400 .foregroundStyle(.primary)
401 Text(entry.type.rawValue)
402 .font(.caption)
403 .foregroundStyle(.secondary)
404 }
405 Spacer()
406 }
407 }
408 .disabled(vm.isLooking)
409 }
410
411 Button("Clear History", role: .destructive) {
412 vm.clearHistory()
413 }
414 .disabled(vm.isLooking)
415 }
416 }
364417 }
365418 .formStyle(.grouped)
366419 .srhtErrorBanner(error: $vm.error)
HutchTests/LookupHistoryStoreTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct LookupHistoryStoreTests {
6
7 @Test
8 func recordsMostRecentUniqueSearchFirst() {
9 let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
10 let defaults = UserDefaults(suiteName: suiteName)!
11 defer { defaults.removePersistentDomain(forName: suiteName) }
12
13 LookupHistoryStore.record(
14 type: .user,
15 query: "~alice",
16 defaults: defaults,
17 now: Date(timeIntervalSince1970: 100)
18 )
19 LookupHistoryStore.record(
20 type: .gitRepo,
21 query: "~alice/hutch",
22 defaults: defaults,
23 now: Date(timeIntervalSince1970: 200)
24 )
25 LookupHistoryStore.record(
26 type: .user,
27 query: "~alice",
28 defaults: defaults,
29 now: Date(timeIntervalSince1970: 300)
30 )
31
32 let history = LookupHistoryStore.load(defaults: defaults)
33
34 #expect(history.count == 2)
35 #expect(history.map(\.type) == [.user, .gitRepo])
36 #expect(history.map(\.query) == ["~alice", "~alice/hutch"])
37 #expect(history.first?.createdAt == Date(timeIntervalSince1970: 300))
38 }
39
40 @Test
41 func trimsHistoryToMaximumSize() {
42 let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
43 let defaults = UserDefaults(suiteName: suiteName)!
44 defer { defaults.removePersistentDomain(forName: suiteName) }
45
46 for index in 0..<25 {
47 LookupHistoryStore.record(
48 type: .buildJob,
49 query: "\(index)",
50 defaults: defaults,
51 now: Date(timeIntervalSince1970: TimeInterval(index))
52 )
53 }
54
55 let history = LookupHistoryStore.load(defaults: defaults)
56
57 #expect(history.count == 20)
58 #expect(history.first?.query == "24")
59 #expect(history.last?.query == "5")
60 }
61
62 @Test
63 func clearsPersistedHistory() {
64 let suiteName = "LookupHistoryStoreTests-\(UUID().uuidString)"
65 let defaults = UserDefaults(suiteName: suiteName)!
66 defer { defaults.removePersistentDomain(forName: suiteName) }
67
68 LookupHistoryStore.record(type: .tracker, query: "~owner/todo", defaults: defaults)
69 #expect(!LookupHistoryStore.load(defaults: defaults).isEmpty)
70
71 LookupHistoryStore.clear(defaults: defaults)
72
73 #expect(LookupHistoryStore.load(defaults: defaults).isEmpty)
74 }
75}