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 @@
539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 539 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 540 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
541 CODE_SIGN_STYLE = Automatic; 541 CODE_SIGN_STYLE = Automatic;
542 CURRENT_PROJECT_VERSION = 22; 542 CURRENT_PROJECT_VERSION = 23;
543 DEVELOPMENT_TEAM = ZCNAX3VL9D; 543 DEVELOPMENT_TEAM = ZCNAX3VL9D;
544 ENABLE_PREVIEWS = YES; 544 ENABLE_PREVIEWS = YES;
545 GENERATE_INFOPLIST_FILE = YES; 545 GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
556 "$(inherited)", 556 "$(inherited)",
557 "@executable_path/Frameworks", 557 "@executable_path/Frameworks",
558 ); 558 );
559 MARKETING_VERSION = 2.9.0; 559 MARKETING_VERSION = 2.9.1;
560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 560 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
561 PRODUCT_NAME = "$(TARGET_NAME)"; 561 PRODUCT_NAME = "$(TARGET_NAME)";
562 STRING_CATALOG_GENERATE_SYMBOLS = YES; 562 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 576 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 577 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
578 CODE_SIGN_STYLE = Automatic; 578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 22; 579 CURRENT_PROJECT_VERSION = 23;
580 DEVELOPMENT_TEAM = ZCNAX3VL9D; 580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581 ENABLE_PREVIEWS = YES; 581 ENABLE_PREVIEWS = YES;
582 GENERATE_INFOPLIST_FILE = YES; 582 GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
593 "$(inherited)", 593 "$(inherited)",
594 "@executable_path/Frameworks", 594 "@executable_path/Frameworks",
595 ); 595 );
596 MARKETING_VERSION = 2.9.0; 596 MARKETING_VERSION = 2.9.1;
597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 597 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
598 PRODUCT_NAME = "$(TARGET_NAME)"; 598 PRODUCT_NAME = "$(TARGET_NAME)";
599 STRING_CATALOG_GENERATE_SYMBOLS = YES; 599 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
656 APPLICATION_EXTENSION_API_ONLY = YES; 656 APPLICATION_EXTENSION_API_ONLY = YES;
657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 657 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
658 CODE_SIGN_STYLE = Automatic; 658 CODE_SIGN_STYLE = Automatic;
659 CURRENT_PROJECT_VERSION = 22; 659 CURRENT_PROJECT_VERSION = 23;
660 DEVELOPMENT_TEAM = ZCNAX3VL9D; 660 DEVELOPMENT_TEAM = ZCNAX3VL9D;
661 GENERATE_INFOPLIST_FILE = NO; 661 GENERATE_INFOPLIST_FILE = NO;
662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 662 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
666 "@executable_path/Frameworks", 666 "@executable_path/Frameworks",
667 "@executable_path/../../Frameworks", 667 "@executable_path/../../Frameworks",
668 ); 668 );
669 MARKETING_VERSION = 2.8.3; 669 MARKETING_VERSION = 2.9.1;
670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 670 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
671 PRODUCT_NAME = "$(TARGET_NAME)"; 671 PRODUCT_NAME = "$(TARGET_NAME)";
672 SKIP_INSTALL = YES; 672 SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
685 APPLICATION_EXTENSION_API_ONLY = YES; 685 APPLICATION_EXTENSION_API_ONLY = YES;
686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 686 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
687 CODE_SIGN_STYLE = Automatic; 687 CODE_SIGN_STYLE = Automatic;
688 CURRENT_PROJECT_VERSION = 22; 688 CURRENT_PROJECT_VERSION = 23;
689 DEVELOPMENT_TEAM = ZCNAX3VL9D; 689 DEVELOPMENT_TEAM = ZCNAX3VL9D;
690 GENERATE_INFOPLIST_FILE = NO; 690 GENERATE_INFOPLIST_FILE = NO;
691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 691 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
695 "@executable_path/Frameworks", 695 "@executable_path/Frameworks",
696 "@executable_path/../../Frameworks", 696 "@executable_path/../../Frameworks",
697 ); 697 );
698 MARKETING_VERSION = 2.8.3; 698 MARKETING_VERSION = 2.9.1;
699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 699 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
700 PRODUCT_NAME = "$(TARGET_NAME)"; 700 PRODUCT_NAME = "$(TARGET_NAME)";
701 SKIP_INSTALL = YES; 701 SKIP_INSTALL = YES;
Hutch/App/AppStorageKeys.swift +1
@@ -3,4 +3,5 @@ enum AppStorageKeys {
3 static let swipeActionsEnabled = "swipeActionsEnabled" 3 static let swipeActionsEnabled = "swipeActionsEnabled"
4 static let activeAccountID = "activeAccountID" 4 static let activeAccountID = "activeAccountID"
5 static let wrapRepositoryFileLines = "wrapRepositoryFileLines" 5 static let wrapRepositoryFileLines = "wrapRepositoryFileLines"
6 static let lookupHistory = "lookupHistory"
6} 7}
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
3 3
4private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup") 4private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup")
5 5
6enum LookupType: String, CaseIterable, Identifiable { 6enum LookupType: String, CaseIterable, Identifiable, Codable, Sendable {
7 case user = "User" 7 case user = "User"
8 case gitRepo = "Git Repo" 8 case gitRepo = "Git Repo"
9 case hgRepo = "Hg Repo" 9 case hgRepo = "Hg Repo"
@@ -74,10 +74,12 @@ final class LookupViewModel {
74 var inputText: String = "" 74 var inputText: String = ""
75 private(set) var result: LookupResult? 75 private(set) var result: LookupResult?
76 private(set) var isLooking = false 76 private(set) var isLooking = false
77 private(set) var history: [LookupHistoryEntry]
77 var error: String? 78 var error: String?
78 79
79 private let client: SRHTClient 80 private let client: SRHTClient
80 private let appState: AppState 81 private let appState: AppState
82 private let defaults: UserDefaults
81 83
82 var resultBinding: Binding<LookupResult?> { 84 var resultBinding: Binding<LookupResult?> {
83 Binding( 85 Binding(
@@ -90,9 +92,11 @@ final class LookupViewModel {
90 ) 92 )
91 } 93 }
92 94
93 init(client: SRHTClient, appState: AppState) { 95 init(client: SRHTClient, appState: AppState, defaults: UserDefaults = .standard) {
94 self.client = client 96 self.client = client
95 self.appState = appState 97 self.appState = appState
98 self.defaults = defaults
99 self.history = LookupHistoryStore.load(defaults: defaults)
96 } 100 }
97 101
98 func lookup() async { 102 func lookup() async {
@@ -123,6 +127,17 @@ final class LookupViewModel {
123 } 127 }
124 } 128 }
125 129
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
126 private func parseOwnerAndName() -> (owner: String, name: String)? { 141 private func parseOwnerAndName() -> (owner: String, name: String)? {
127 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) 142 let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
128 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed 143 let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
@@ -161,6 +176,7 @@ final class LookupViewModel {
161 176
162 private func lookupUser() async throws -> LookupResult { 177 private func lookupUser() async throws -> LookupResult {
163 guard let username = parseUsername() else { throw LookupError.invalidInput } 178 guard let username = parseUsername() else { throw LookupError.invalidInput }
179 recordHistory(type: .user, query: "~\(username)")
164 180
165 struct Response: Decodable, Sendable { 181 struct Response: Decodable, Sendable {
166 let user: User 182 let user: User
@@ -216,6 +232,8 @@ final class LookupViewModel {
216 232
217 private func lookupRepository(service: SRHTService) async throws -> LookupResult { 233 private func lookupRepository(service: SRHTService) async throws -> LookupResult {
218 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } 234 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
235 let type: LookupType = service == .git ? .gitRepo : .hgRepo
236 recordHistory(type: type, query: "~\(owner)/\(name)")
219 237
220 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service) 238 let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
221 let resolvedRepository = RepositorySummary( 239 let resolvedRepository = RepositorySummary(
@@ -235,6 +253,7 @@ final class LookupViewModel {
235 253
236 private func lookupMailingList() async throws -> LookupResult { 254 private func lookupMailingList() async throws -> LookupResult {
237 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } 255 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
256 recordHistory(type: .mailingList, query: "~\(owner)/\(name)")
238 257
239 struct Response: Decodable, Sendable { 258 struct Response: Decodable, Sendable {
240 let user: UserWithList 259 let user: UserWithList
@@ -266,12 +285,14 @@ final class LookupViewModel {
266 285
267 private func lookupTracker() async throws -> LookupResult { 286 private func lookupTracker() async throws -> LookupResult {
268 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } 287 guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
288 recordHistory(type: .tracker, query: "~\(owner)/\(name)")
269 let tracker = try await appState.resolveTracker(owner: owner, name: name) 289 let tracker = try await appState.resolveTracker(owner: owner, name: name)
270 return .tracker(tracker) 290 return .tracker(tracker)
271 } 291 }
272 292
273 private func lookupBuildJob() async throws -> LookupResult { 293 private func lookupBuildJob() async throws -> LookupResult {
274 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput } 294 guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
295 recordHistory(type: .buildJob, query: String(jobId))
275 296
276 struct Response: Decodable, Sendable { 297 struct Response: Decodable, Sendable {
277 let job: JobIdOnly 298 let job: JobIdOnly
@@ -300,6 +321,11 @@ final class LookupViewModel {
300 private enum LookupError: Error { 321 private enum LookupError: Error {
301 case invalidInput 322 case invalidInput
302 } 323 }
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 }
303} 329}
304 330
305struct LookupView: View { 331struct LookupView: View {
@@ -361,6 +387,33 @@ struct LookupView: View {
361 } 387 }
362 } 388 }
363 } 389 }
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 }
364 } 417 }
365 .formStyle(.grouped) 418 .formStyle(.grouped)
366 .srhtErrorBanner(error: $vm.error) 419 .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}