Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 10 additions & 18 deletions Sources/Kaset/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import AppKit
import SwiftUI

extension EnvironmentValues {
@Entry var searchFocusTrigger: Binding<Bool> = .constant(false)
@Entry var navigationSelection: Binding<NavigationItem?> = .constant(nil)
}

extension EnvironmentValues {
@Entry var navigationSelection: Binding<NavigationItem?> = .constant(nil)
@Entry var showCommandBar: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
@Entry var showCommandBar: Binding<Bool> = .constant(false)
@Entry var showSearchOverlay: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
Expand Down Expand Up @@ -49,9 +49,6 @@ struct KasetApp: App {
@State private var settings = SettingsManager.shared
@State private var podcastsAvailabilityService = PodcastsAvailabilityService()

/// Triggers search field focus when set to true.
@State private var searchFocusTrigger = false

/// Current navigation selection for keyboard navigation.
@State private var navigationSelection: NavigationItem? = SettingsManager.shared.launchNavigationItem

Expand All @@ -61,6 +58,9 @@ struct KasetApp: App {
/// Whether the command bar is visible.
@State private var showCommandBar = false

/// Whether the search overlay is visible.
@State private var showSearchOverlay = false

/// Whether the "What's New" sheet should be shown.
@State private var showWhatsNew = false

Expand Down Expand Up @@ -165,6 +165,7 @@ struct KasetApp: App {
navigationSelection: self.$navigationSelection,
youtubeNavigationSelection: self.$youtubeNavigationSelection,
didCompleteStartupPlaybackCleanup: self.$didCompleteStartupPlaybackCleanup,
showSearchOverlayRequest: self.$showSearchOverlay,
client: self.sharedClient,
youtubeClient: self.sharedYouTubeClient
)
Expand All @@ -182,9 +183,9 @@ struct KasetApp: App {
.environment(self.syncedLyricsService)
.environment(self.equalizerService)
.environment(self.podcastsAvailabilityService)
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
.environment(\.navigationSelection, self.$navigationSelection)
.environment(\.showCommandBar, self.$showCommandBar)
.environment(\.showSearchOverlay, self.$showSearchOverlay)
.environment(\.showWhatsNew, self.$showWhatsNew)
.environment(\.usesLegacyMacOS15UI, self.settings.useLegacyMacOS15UI)
.onAppear {
Expand Down Expand Up @@ -431,18 +432,9 @@ struct KasetApp: App {

Divider()

// Search - ⌘F
// Search - Command-F opens the floating search overlay for the active source.
Button("Search") {
if self.settings.appSource == .video {
self.youtubeNavigationSelection = .search
return
}
self.navigationSelection = .search
// Trigger focus after a brief delay to allow view to appear
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
self.searchFocusTrigger = true
}
self.showSearchOverlay = true
}
.keyboardShortcut("f", modifiers: .command)

Expand Down
168 changes: 168 additions & 0 deletions Sources/Kaset/Services/SearchHistoryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import Foundation
import Observation

// MARK: - SearchHistoryStore

/// Persists a small, ordered, de-duplicated list of recent search queries for a
/// given source (Music or YouTube). Backs the "Latest Searches" list in the
/// search overlay. Modeled on `FavoritesManager` persistence: JSON file in the
/// sandboxed Application Support folder, debounced off-main writes.
@MainActor
@Observable
final class SearchHistoryStore {
/// The search surface a store belongs to; determines the on-disk filename.
enum Source: String {
case music
case youtube

var fileName: String {
"search-history-\(self.rawValue).json"
}
}

/// Maximum number of recent queries kept.
static let maxItems = 30

/// Recent queries, most-recent first.
private(set) var items: [String] = []

private let source: Source
private let skipPersistence: Bool
private var saveTask: Task<Void, Never>?

// MARK: - Persistence

private var fileURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true)
let kasetDir = appSupport.appendingPathComponent("Kaset", isDirectory: true)
return kasetDir.appendingPathComponent(self.source.fileName)
}

// MARK: - Initialization

init(source: Source) {
self.source = source
// In UI test mode we keep history in-memory only so live user data is untouched.
if UITestConfig.isUITestMode {
self.skipPersistence = true
self.loadMockHistoryIfAvailable()
} else {
self.skipPersistence = false
self.load()
}
}

/// Test initializer that never touches disk.
init(source: Source, skipPersistence: Bool) {
self.source = source
self.skipPersistence = skipPersistence
if !skipPersistence {
self.load()
}
}

// MARK: - Load & Save

/// Loads items from disk (once, at init).
func load() {
do {
guard FileManager.default.fileExists(atPath: self.fileURL.path) else {
DiagnosticsLogger.ui.debug("Search history file does not exist, starting fresh")
return
}
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String].self, from: data)
self.items = Self.normalized(decoded)
DiagnosticsLogger.ui.info("Loaded \(self.items.count) \(self.source.rawValue) search history items")
} catch {
DiagnosticsLogger.ui.error("Failed to load search history: \(error.localizedDescription)")
self.items = []
}
}

/// Loads mock history from UI-test launch environment when provided.
private func loadMockHistoryIfAvailable() {
guard let raw = UITestConfig.environmentValue(for: UITestConfig.mockSearchHistoryKey),
let data = raw.data(using: .utf8),
let decoded = try? JSONDecoder().decode([String].self, from: data)
else { return }
self.items = Self.normalized(decoded)
}

/// Persists the current items off the main actor, debounced.
private func save() {
guard !self.skipPersistence else { return }

self.saveTask?.cancel()
let itemsSnapshot = self.items
let targetURL = self.fileURL

self.saveTask = Task(priority: .utility) {
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }

do {
let directory = targetURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(itemsSnapshot)
try data.write(to: targetURL, options: .atomic)
DiagnosticsLogger.ui.debug("Saved \(itemsSnapshot.count) search history items")
} catch {
DiagnosticsLogger.ui.error("Failed to save search history: \(error.localizedDescription)")
}
}
}

// MARK: - Actions

/// Records a query at the front, de-duplicating case-insensitively and capping the list.
/// Blank queries are ignored.
func record(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }

self.items.removeAll { $0.caseInsensitiveCompare(trimmed) == .orderedSame }
self.items.insert(trimmed, at: 0)
if self.items.count > Self.maxItems {
self.items.removeLast(self.items.count - Self.maxItems)
}
self.save()
}

/// Removes a single recorded query, matching case-insensitively. Blank input
/// and queries not present are ignored (no save).
func remove(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }

let originalCount = self.items.count
self.items.removeAll { $0.caseInsensitiveCompare(trimmed) == .orderedSame }
guard self.items.count != originalCount else { return }
self.save()
}

/// Clears all recent queries.
func clear() {
guard !self.items.isEmpty else { return }
self.items.removeAll()
self.save()
}

// MARK: - Helpers

/// Trims, drops blanks, de-duplicates case-insensitively (keeping first occurrence),
/// and caps to `maxItems`. Used when loading possibly-stale data from disk.
private static func normalized(_ raw: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for entry in raw {
let trimmed = entry.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard seen.insert(trimmed.lowercased()).inserted else { continue }
result.append(trimmed)
if result.count >= Self.maxItems { break }
}
return result
}
}
18 changes: 18 additions & 0 deletions Sources/Kaset/Utilities/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ enum AccessibilityID {
}
}

// MARK: - Search Overlay

enum SearchOverlay {
static let backdrop = "searchOverlay.backdrop"
static let window = "searchOverlay.window"
static let input = "searchOverlay.input"
static let returnHint = "searchOverlay.returnHint"
static let historyList = "searchOverlay.historyList"

static func historyRow(index: Int) -> String {
"searchOverlay.history.\(index)"
}

static func removeHistoryButton(index: Int) -> String {
"searchOverlay.removeHistoryButton.\(index)"
}
}

// MARK: - LibraryView

enum Library {
Expand Down
18 changes: 18 additions & 0 deletions Sources/Kaset/Utilities/UITestConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ enum UITestConfig {
/// When present, skip auth and assume logged in.
static let skipAuthArgument = "-SkipAuth"

/// When present, opens the search overlay as soon as the main window appears.
static let openSearchOverlayArgument = "-OpenSearchOverlay"

// MARK: - Environment Keys

/// JSON-encoded mock home sections data.
Expand Down Expand Up @@ -51,6 +54,15 @@ enum UITestConfig {
/// Podcasts discovery surface. Used to UI-test sidebar visibility.
static let mockPodcastsRegionUnavailableKey = "MOCK_PODCASTS_REGION_UNAVAILABLE"

/// JSON-encoded search history items for UI tests.
static let mockSearchHistoryKey = "MOCK_SEARCH_HISTORY"

/// Initial search overlay query for UI tests.
static let mockSearchOverlayQueryKey = "MOCK_SEARCH_OVERLAY_QUERY"

/// When true, open the search overlay after launch in UI tests.
static let openSearchOverlayKey = "OPEN_SEARCH_OVERLAY"

// MARK: - Detection

/// Returns true if the app was launched in UI test mode.
Expand Down Expand Up @@ -81,4 +93,10 @@ enum UITestConfig {
static func environmentValue(for key: String) -> String? {
ProcessInfo.processInfo.environment[key]
}

/// Returns true when UI tests should open the search overlay after launch.
static var shouldOpenSearchOverlay: Bool {
CommandLine.arguments.contains(openSearchOverlayArgument)
|| ProcessInfo.processInfo.environment[openSearchOverlayKey] == "1"
}
}
29 changes: 21 additions & 8 deletions Sources/Kaset/ViewModels/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ final class SearchViewModel {
var selectedFilter: SearchFilter = .all {
didSet {
guard oldValue != self.selectedFilter, !self.query.isEmpty else { return }
// A batched overlay submit sets query + filter together and starts its
// own search; skip the reactive search this setter would otherwise run.
guard !self.suppressFilterSearch else { return }

// If we've previously searched this query, perform a filtered search
// to get the best results for the selected filter. If no prior
Expand Down Expand Up @@ -157,6 +160,9 @@ final class SearchViewModel {
@ObservationIgnored private var searchTask: Task<Void, Never>?
@ObservationIgnored private var suggestionsTask: Task<Void, Never>?
@ObservationIgnored private var suppressedSuggestionsQuery: String?
/// While true, `selectedFilter.didSet` skips its reactive search. Used by the
/// overlay submit path, which sets query + filter and starts a single search.
@ObservationIgnored private var suppressFilterSearch = false
// swiftformat:enable modifierOrder

private struct SearchAllAttempt {
Expand Down Expand Up @@ -276,6 +282,20 @@ final class SearchViewModel {
}
}

/// Submits a query and filter as one intentional operation (used by the
/// search overlay). Sets both without triggering the `selectedFilter`
/// reactive search, then starts exactly one immediate search.
func searchImmediately(query submittedQuery: String, filter: SearchFilter = .all) {
let trimmed = submittedQuery.trimmingCharacters(in: .whitespacesAndNewlines)

self.suppressFilterSearch = true
defer { self.suppressFilterSearch = false }
self.selectedFilter = filter
self.query = trimmed

self.searchImmediately()
}

/// Performs a search with the current filter (no debounce, called when filter changes).
private func searchWithFilter() {
self.searchTask?.cancel()
Expand Down Expand Up @@ -365,7 +385,7 @@ final class SearchViewModel {

/// Performs the broadest search for the All filter by combining the mixed search response
/// with the dedicated result-type searches.
private func searchAll(query: String, filter: SearchFilter) async throws -> SearchResponse {
private func searchAll(query: String, filter _: SearchFilter) async throws -> SearchResponse {
async let mixedResults = self.attemptSearch(label: "mixed search") {
try await self.client.search(query: query)
}
Expand All @@ -389,13 +409,6 @@ final class SearchViewModel {
}

let mixedAttempt = await mixedResults
if let mixedResponse = mixedAttempt.response,
!mixedResponse.isEmpty,
self.isCurrentSearch(query: query, filter: filter)
{
self.publishSearchResults(mixedResponse, query: query, filter: filter)
}

let attempts = await [
mixedAttempt,
songResults,
Expand Down
Loading
Loading