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.
142 lines
5.3 KiB
Swift
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
|