Files
Outpost/Outpost/Features/Search/GlobalSearchResultsView.swift
T
Puranjay Savar Mattas 4b23713c2b feat(collections): show full document hierarchy in the toolbar
Sidebar clicks on a nested document now push the whole ancestor chain
onto documentPath at once (via DocumentNode, which already has it
from the client-side tree) instead of just the leaf — that array is
now the single source of truth for both the NavigationStack and the
toolbar breadcrumb, so they can't drift out of sync the way a
separate "ancestors" state did.

Ancestors render icon-only in the breadcrumb; only the document
actually being viewed gets its full title, so a deep chain doesn't
overrun the toolbar.

Flat-list and search-result clicks (no known ancestors) now go
through the same onOpenDocument callback instead of a bare
NavigationLink, so every document-opening path resets the chain
consistently rather than risking a stale one bleeding through.
Opening a sub-document from inside the reader itself is a genuine
stack append (correct back-button semantics: back returns to the
parent's own reader, not the collection) rather than the
reset-and-replace used for jumps between unrelated documents.

Also renders a document's children in the reader, fetched via
documents.list's parentDocumentId filter — previously not shown
anywhere.
2026-08-14 01:59:36 +01:00

142 lines
5.3 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct GlobalSearchResultsView: View {
@State private var viewModel: GlobalSearchViewModel
let query: String
let onOpenDocument: (OutlineDocument) -> Void
init(apiClient: OutlineAPIClient, query: String, onOpenDocument: @escaping (OutlineDocument) -> Void) {
_viewModel = State(initialValue: GlobalSearchViewModel(apiClient: apiClient))
self.query = query
self.onOpenDocument = onOpenDocument
}
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
Button {
onOpenDocument(result.document)
} label: {
VStack(alignment: .leading, spacing: 4) {
DocumentRowView(document: result.document)
Text(result.context)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
}
}
}
#endif