Add live search to macOS popover

This commit is contained in:
2026-06-09 16:40:02 -04:00
parent 4666000ca3
commit 632621b226
5 changed files with 262 additions and 1 deletions

View File

@@ -18,6 +18,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
@Published public private(set) var isLoadingRecentUploads = false
@Published public private(set) var recentUploadsError: String?
@Published public private(set) var clipboardNotice: ClipboardNotice?
@Published public var searchQuery = ""
@Published public private(set) var searchResults: [SearchResult] = []
@Published public private(set) var isSearching = false
@Published public private(set) var searchError: String?
@Published public var selectedSearchIndex = 0
public var cancellables = Set<AnyCancellable>()
@@ -27,6 +32,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
private var uploadManager: UploadManager
private var folderWatcher: FolderWatcher?
private var syncTimer: Timer?
private var searchTask: Task<Void, Never>?
private var settingsWindow: NSWindow?
private var uploadsWindow: NSWindow?
@@ -59,6 +65,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
uploadManager = UploadManager(client: apiClient, config: newConfig)
syncEngine = SyncEngine(client: apiClient, config: newConfig)
resetSearch()
await refreshRecentUploads()
await setupSync()
}
@@ -308,6 +315,100 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
recentUploadsError = error.localizedDescription
}
}
public func updateSearchQuery(_ query: String) {
searchQuery = query
selectedSearchIndex = 0
searchTask?.cancel()
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
searchResults = []
isSearching = false
searchError = nil
return
}
isSearching = true
searchError = nil
searchTask = Task { [weak self] in
do {
try await Task.sleep(for: .milliseconds(180))
await self?.performSearch(for: trimmed)
} catch {
await MainActor.run {
if self?.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
self?.isSearching = false
}
}
}
}
}
public func clearSearch() {
resetSearch()
}
public func moveSearchSelection(by delta: Int) {
guard !searchResults.isEmpty else { return }
selectedSearchIndex = min(max(selectedSearchIndex + delta, 0), searchResults.count - 1)
}
public func openSelectedSearchResult() {
guard searchResults.indices.contains(selectedSearchIndex) else { return }
openSearchResult(searchResults[selectedSearchIndex])
}
public func openSearchResult(_ result: SearchResult) {
guard let url = documentURL(for: result) else { return }
NSWorkspace.shared.open(url)
}
private func performSearch(for query: String) async {
do {
let results = try await apiClient.searchDocuments(query: liveSearchQuery(for: query))
guard !Task.isCancelled else { return }
guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) == query else { return }
searchResults = Array(results.prefix(8))
selectedSearchIndex = min(selectedSearchIndex, max(searchResults.count - 1, 0))
searchError = nil
} catch {
guard !Task.isCancelled else { return }
searchResults = []
searchError = error.localizedDescription
}
isSearching = false
}
private func resetSearch() {
searchTask?.cancel()
searchQuery = ""
searchResults = []
isSearching = false
searchError = nil
selectedSearchIndex = 0
}
private func liveSearchQuery(for query: String) -> String {
let terms = query
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !terms.isEmpty else { return query }
return terms.map { "\($0)*" }.joined(separator: " ")
}
private func documentURL(for result: SearchResult) -> URL? {
guard let baseURL = config.serverBaseURL else { return nil }
let trimmedPath = result.path.hasSuffix(".md") ? String(result.path.dropLast(3)) : result.path
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "?#")
guard let encodedPath = trimmedPath.addingPercentEncoding(withAllowedCharacters: allowed) else {
return nil
}
return URL(string: "docs/\(encodedPath)", relativeTo: baseURL)?.absoluteURL
}
public func createNote() {
switch config.effectiveNewNoteAction {