diff --git a/Outpost/Features/Search/DocumentTitleSearchViewModel.swift b/Outpost/Features/Search/DocumentTitleSearchViewModel.swift new file mode 100644 index 0000000..15da66f --- /dev/null +++ b/Outpost/Features/Search/DocumentTitleSearchViewModel.swift @@ -0,0 +1,48 @@ +import Foundation +import Observation +import OutlineKit + +/// Backs the contextual toolbar search — matches scoped to whichever collection +/// is currently open ("search what you're looking at"). +/// +/// Uses `documents.search` rather than `documents.search_titles`: the latter +/// consistently failed against the connected server (likely unavailable on +/// this Outline version — see ARCHITECTURE.md's note on server version drift), +/// while `documents.search` is the same, longer-established endpoint the +/// sidebar's global search already relies on. +@MainActor +@Observable +final class DocumentTitleSearchViewModel { + var results: [OutlineDocumentSearchResult] = [] + var isSearching = false + var errorMessage: String? + + private let apiClient: OutlineAPIClient + private let collectionId: String + + init(apiClient: OutlineAPIClient, collectionId: String) { + self.apiClient = apiClient + self.collectionId = collectionId + } + + func search(query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + errorMessage = nil + return + } + + isSearching = true + errorMessage = nil + defer { isSearching = false } + + do { + results = try await apiClient.searchDocuments( + DocumentSearchRequest(query: trimmed, collectionId: collectionId) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Search failed. Try again.") + } + } +} diff --git a/Outpost/Features/Search/GlobalSearchResultsView.swift b/Outpost/Features/Search/GlobalSearchResultsView.swift new file mode 100644 index 0000000..790e0a7 --- /dev/null +++ b/Outpost/Features/Search/GlobalSearchResultsView.swift @@ -0,0 +1,136 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct GlobalSearchResultsView: View { + @State private var viewModel: GlobalSearchViewModel + let query: String + + init(apiClient: OutlineAPIClient, query: String) { + _viewModel = State(initialValue: GlobalSearchViewModel(apiClient: apiClient)) + self.query = query + } + + private var searchKey: String { + [ + query, + viewModel.collectionFilter?.id ?? "", + viewModel.dateFilter?.rawValue ?? "", + viewModel.sort.rawValue, + viewModel.direction.rawValue, + viewModel.statusFilter.map(\.rawValue).sorted().joined(separator: ",") + ].joined(separator: "|") + } + + var body: some View { + // No `.navigationTitle` — `ContentView_macOS`'s leading toolbar item + // already shows a "Search" pill while global search is active. + results + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + collectionPicker + sortPicker + dateFilterPicker + statusFilterMenu + } + } + .task { await viewModel.loadCollectionsIfNeeded() } + .task(id: searchKey) { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await viewModel.search(query: query) + } + } + + // `documents.search`'s `collectionId` is a single UUID, not an array — this + // stays single-select because that's genuinely all the endpoint supports, + // not an arbitrary UI limitation. + private var collectionPicker: some View { + Picker("Collection", selection: $viewModel.collectionFilter) { + Text("All Collections").tag(OutlineCollection?.none) + ForEach(viewModel.availableCollections) { collection in + Text(collection.name).tag(Optional(collection)) + } + } + .padding(.horizontal, 4) + } + + private var sortPicker: some View { + Picker("Sort", selection: $viewModel.sort) { + Text("Relevance").tag(DocumentSearchSort.relevance) + Text("Updated").tag(DocumentSearchSort.updatedAt) + Text("Created").tag(DocumentSearchSort.createdAt) + Text("Title").tag(DocumentSearchSort.title) + } + .padding(.horizontal, 4) + } + + private var dateFilterPicker: some View { + Picker("Date", selection: $viewModel.dateFilter) { + Text("Any Time").tag(DocumentSearchDateFilter?.none) + Text("Past Day").tag(Optional(DocumentSearchDateFilter.day)) + Text("Past Week").tag(Optional(DocumentSearchDateFilter.week)) + Text("Past Month").tag(Optional(DocumentSearchDateFilter.month)) + Text("Past Year").tag(Optional(DocumentSearchDateFilter.year)) + } + .padding(.horizontal, 4) + } + + private var statusFilterMenu: some View { + Menu { + ForEach(DocumentStatusFilter.allCases, id: \.self) { status in + Button { + if viewModel.statusFilter.contains(status) { + viewModel.statusFilter.remove(status) + } else { + viewModel.statusFilter.insert(status) + } + } label: { + // `Label(_:systemImage:)` with an empty string logs "No + // symbol named ''" — use the icon-closure init instead so + // the unselected case can render no icon at all. + Label { + Text(status.rawValue.capitalized) + } icon: { + if viewModel.statusFilter.contains(status) { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Text(viewModel.statusFilter.isEmpty ? "Any Status" : "\(viewModel.statusFilter.count) Status") + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private var results: some View { + if viewModel.isSearching && viewModel.results.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage { + ContentUnavailableView { + Label("Search Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if viewModel.results.isEmpty { + ContentUnavailableView.search(text: query) + } else { + List(viewModel.results) { result in + NavigationLink(value: result.document) { + VStack(alignment: .leading, spacing: 4) { + DocumentRowView(document: result.document) + Text(result.context) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } + } + } + } +} +#endif diff --git a/Outpost/Features/Search/GlobalSearchViewModel.swift b/Outpost/Features/Search/GlobalSearchViewModel.swift new file mode 100644 index 0000000..a97324e --- /dev/null +++ b/Outpost/Features/Search/GlobalSearchViewModel.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation +import OutlineKit + +/// Backs the sidebar's whole-workspace search — full-text via `documents.search`, +/// with filters mirroring what that endpoint actually supports. +@MainActor +@Observable +final class GlobalSearchViewModel { + var results: [OutlineDocumentSearchResult] = [] + var availableCollections: [OutlineCollection] = [] + var isSearching = false + var errorMessage: String? + + var collectionFilter: OutlineCollection? + var dateFilter: DocumentSearchDateFilter? + var sort: DocumentSearchSort = .relevance + var direction: DocumentSearchDirection = .descending + var statusFilter: Set = [] + + private let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient) { + self.apiClient = apiClient + } + + func loadCollectionsIfNeeded() async { + guard availableCollections.isEmpty else { return } + availableCollections = (try? await apiClient.listCollections(offset: 0, limit: 100)) ?? [] + } + + func search(query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + errorMessage = nil + return + } + + isSearching = true + errorMessage = nil + defer { isSearching = false } + + do { + results = try await apiClient.searchDocuments( + DocumentSearchRequest( + query: trimmed, + collectionId: collectionFilter?.id, + statusFilter: statusFilter.isEmpty ? nil : Array(statusFilter), + dateFilter: dateFilter, + // The server rejects `sort: "relevance"` outright — it's + // apparently only valid as the *implicit* default when the + // field is omitted entirely, not as an explicit value. + sort: sort == .relevance ? nil : sort, + direction: direction + ) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Search failed. Try again.") + } + } +} diff --git a/Outpost/Features/Search/SidebarSearchField.swift b/Outpost/Features/Search/SidebarSearchField.swift new file mode 100644 index 0000000..213867f --- /dev/null +++ b/Outpost/Features/Search/SidebarSearchField.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import SwiftUI + +struct SidebarSearchField: View { + @Binding var text: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search workspace…", text: $text) + .textFieldStyle(.plain) + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } +} +#endif diff --git a/Outpost/Support/OutlineAPIError+Message.swift b/Outpost/Support/OutlineAPIError+Message.swift new file mode 100644 index 0000000..012960f --- /dev/null +++ b/Outpost/Support/OutlineAPIError+Message.swift @@ -0,0 +1,29 @@ +import Foundation +import OutlineKit + +extension OutlineAPIError { + /// User-facing message, distinguishing failure causes instead of one blanket + /// "try again" — a 404 (e.g. an endpoint missing on an older server version) + /// looks nothing like a dropped connection, and surfacing that difference is + /// what actually helps diagnose it. + var userFacingMessage: String { + switch self { + case .unauthorized: + return "Sign-in expired. Try signing in again." + case .notFound: + return "That isn't available on this server." + case .transport: + return "Couldn't reach the server. Check your connection." + case .server(let status, let message): + return message ?? "The server returned an error (\(status))." + case .decoding: + return "Got an unexpected response from the server." + case .tokenUnavailable: + return "Couldn't access your saved sign-in." + } + } +} + +func outlineErrorMessage(_ error: Error, fallback: String) -> String { + (error as? OutlineAPIError)?.userFacingMessage ?? fallback +}