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 {

View File

@@ -2,6 +2,7 @@ import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) {
self.controller = controller
@@ -12,6 +13,10 @@ public struct StatusPopoverView: View {
// Header with status
headerSection
Divider()
searchSection
Divider()
// Quick actions
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
// Footer
footerSection
}
.frame(width: 320)
.frame(width: 360)
.padding(.vertical, 12)
.onAppear {
Task { @MainActor in
searchFocused = true
}
}
.onMoveCommand { direction in
switch direction {
case .up:
controller.moveSearchSelection(by: -1)
case .down:
controller.moveSearchSelection(by: 1)
default:
break
}
}
}
// MARK: - Sections
@@ -130,6 +150,75 @@ public struct StatusPopoverView: View {
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
@ViewBuilder
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
TextField(
"Search documents",
text: Binding(
get: { controller.searchQuery },
set: { controller.updateSearchQuery($0) }
)
)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit {
controller.openSelectedSearchResult()
}
if controller.isSearching {
ProgressView()
.controlSize(.small)
.scaleEffect(0.65)
.frame(width: 14, height: 14)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
searchResultsList
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
@ViewBuilder
private var searchResultsList: some View {
if controller.searchResults.isEmpty {
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
VStack(spacing: 2) {
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
SearchResultRow(
result: result,
isSelected: index == controller.selectedSearchIndex
) {
controller.selectedSearchIndex = index
controller.openSearchResult(result)
}
.onHover { isHovered in
if isHovered {
controller.selectedSearchIndex = index
}
}
}
}
}
}
@ViewBuilder
private var recentUploadsSection: some View {
@@ -272,6 +361,47 @@ private struct RecentUploadRow: View {
}
}
private struct SearchResultRow: View {
let result: SearchResult
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.font(.system(size: 14, weight: .medium))
.foregroundColor(isSelected ? .white : .accentColor)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(result.title.isEmpty ? result.path : result.title)
.font(.caption)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
Text(result.path)
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "return")
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
.opacity(isSelected ? 1 : 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(height: 44)
.background(isSelected ? Color.accentColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
// MARK: - Action Button
struct ActionButton: View {