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

@@ -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 {