Add live search to macOS popover
This commit is contained in:
@@ -209,6 +209,24 @@ public actor APIClient {
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func searchDocuments(query: String) async throws -> [SearchResult] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)
|
||||
components?.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw APIError(error: "invalid search URL")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
if let token = apiToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let response: SearchResponse = try await perform(request)
|
||||
return response.results
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ public struct DocumentSaveResponse: Codable {
|
||||
public let hash: String
|
||||
}
|
||||
|
||||
public struct SearchResult: Codable, Identifiable {
|
||||
public var id: String { path }
|
||||
public let path: String
|
||||
public let title: String
|
||||
}
|
||||
|
||||
public struct SyncInitRequest: Codable {
|
||||
public let deviceId: String
|
||||
public let rootPath: String
|
||||
@@ -178,6 +184,10 @@ public struct DocumentsListResponse: Codable {
|
||||
public let documents: [DocumentRecord]
|
||||
}
|
||||
|
||||
public struct SearchResponse: Codable {
|
||||
public let results: [SearchResult]
|
||||
}
|
||||
|
||||
public enum UploadDuplicateAction: String {
|
||||
case overwrite
|
||||
case rename
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -223,8 +223,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private func showPopover() {
|
||||
guard let button = dropButton else { return }
|
||||
Task { @MainActor in
|
||||
controller.clearSearch()
|
||||
await controller.refreshRecentUploads()
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
|
||||
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||
|
||||
Reference in New Issue
Block a user