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:
2026-08-14 00:24:22 +01:00
parent 84b4704f89
commit 654ec94b80
5 changed files with 305 additions and 0 deletions
@@ -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
}