diff --git a/apps/macos-sync/Sources/CairnquireSync/API/Client.swift b/apps/macos-sync/Sources/CairnquireSync/API/Client.swift index 4df1756..f871032 100644 --- a/apps/macos-sync/Sources/CairnquireSync/API/Client.swift +++ b/apps/macos-sync/Sources/CairnquireSync/API/Client.swift @@ -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 diff --git a/apps/macos-sync/Sources/CairnquireSync/API/Models.swift b/apps/macos-sync/Sources/CairnquireSync/API/Models.swift index 7c816e8..1fbcdd5 100644 --- a/apps/macos-sync/Sources/CairnquireSync/API/Models.swift +++ b/apps/macos-sync/Sources/CairnquireSync/API/Models.swift @@ -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 diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift index 03f1f47..a57e0ee 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift @@ -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() @@ -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? 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 { diff --git a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift index 667e007..9830415 100644 --- a/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift +++ b/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift @@ -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 { diff --git a/apps/macos-sync/Sources/CairnquireSyncApp/App.swift b/apps/macos-sync/Sources/CairnquireSyncApp/App.swift index acd6b8f..3c887cf 100644 --- a/apps/macos-sync/Sources/CairnquireSyncApp/App.swift +++ b/apps/macos-sync/Sources/CairnquireSyncApp/App.swift @@ -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