Add live search to macOS popover
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user