feat(search): add contextual and workspace-wide document search
Two entry points backed by documents.search: a per-collection toolbar search field, and a sidebar-driven global search with collection/date/ sort/status filters. Both debounce via .task(id:) instead of a timer.
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<DocumentStatusFilter> = []
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user