Add live search to macOS popover
This commit is contained in:
@@ -210,6 +210,24 @@ public actor APIClient {
|
|||||||
return try await perform(request)
|
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
|
// MARK: - Sync
|
||||||
|
|
||||||
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ public struct DocumentSaveResponse: Codable {
|
|||||||
public let hash: String
|
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 struct SyncInitRequest: Codable {
|
||||||
public let deviceId: String
|
public let deviceId: String
|
||||||
public let rootPath: String
|
public let rootPath: String
|
||||||
@@ -178,6 +184,10 @@ public struct DocumentsListResponse: Codable {
|
|||||||
public let documents: [DocumentRecord]
|
public let documents: [DocumentRecord]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SearchResponse: Codable {
|
||||||
|
public let results: [SearchResult]
|
||||||
|
}
|
||||||
|
|
||||||
public enum UploadDuplicateAction: String {
|
public enum UploadDuplicateAction: String {
|
||||||
case overwrite
|
case overwrite
|
||||||
case rename
|
case rename
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
@Published public private(set) var isLoadingRecentUploads = false
|
@Published public private(set) var isLoadingRecentUploads = false
|
||||||
@Published public private(set) var recentUploadsError: String?
|
@Published public private(set) var recentUploadsError: String?
|
||||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
@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>()
|
public var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
private var uploadManager: UploadManager
|
private var uploadManager: UploadManager
|
||||||
private var folderWatcher: FolderWatcher?
|
private var folderWatcher: FolderWatcher?
|
||||||
private var syncTimer: Timer?
|
private var syncTimer: Timer?
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var settingsWindow: NSWindow?
|
private var settingsWindow: NSWindow?
|
||||||
private var uploadsWindow: NSWindow?
|
private var uploadsWindow: NSWindow?
|
||||||
@@ -59,6 +65,7 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||||
|
resetSearch()
|
||||||
await refreshRecentUploads()
|
await refreshRecentUploads()
|
||||||
await setupSync()
|
await setupSync()
|
||||||
}
|
}
|
||||||
@@ -309,6 +316,100 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
public func createNote() {
|
||||||
switch config.effectiveNewNoteAction {
|
switch config.effectiveNewNoteAction {
|
||||||
case .clipboard:
|
case .clipboard:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
public struct StatusPopoverView: View {
|
public struct StatusPopoverView: View {
|
||||||
@ObservedObject public var controller: MenuBarController
|
@ObservedObject public var controller: MenuBarController
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
public init(controller: MenuBarController) {
|
public init(controller: MenuBarController) {
|
||||||
self.controller = controller
|
self.controller = controller
|
||||||
@@ -14,6 +15,10 @@ public struct StatusPopoverView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
searchSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
// Quick actions
|
// Quick actions
|
||||||
actionsSection
|
actionsSection
|
||||||
|
|
||||||
@@ -27,8 +32,23 @@ public struct StatusPopoverView: View {
|
|||||||
// Footer
|
// Footer
|
||||||
footerSection
|
footerSection
|
||||||
}
|
}
|
||||||
.frame(width: 320)
|
.frame(width: 360)
|
||||||
.padding(.vertical, 12)
|
.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
|
// MARK: - Sections
|
||||||
@@ -131,6 +151,75 @@ public struct StatusPopoverView: View {
|
|||||||
.padding(.vertical, 12)
|
.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
|
@ViewBuilder
|
||||||
private var recentUploadsSection: some View {
|
private var recentUploadsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -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
|
// MARK: - Action Button
|
||||||
|
|
||||||
struct ActionButton: View {
|
struct ActionButton: View {
|
||||||
|
|||||||
@@ -223,8 +223,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private func showPopover() {
|
private func showPopover() {
|
||||||
guard let button = dropButton else { return }
|
guard let button = dropButton else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
controller.clearSearch()
|
||||||
await controller.refreshRecentUploads()
|
await controller.refreshRecentUploads()
|
||||||
}
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
|
|
||||||
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||||
|
|||||||
Reference in New Issue
Block a user