App-side chrome: new "Pointer Cursor" toggle (Settings -> Editor, outpost.pointerCursorEnabled, on by default) driving a shared .pointerCursorOnHover() view modifier (push/pop NSCursor.pointingHand, macOS-only, no-op on iOS) wired onto every clickable sidebar/list row: collection tree rows + disclosure chevron, flat collection list, document outline rows, collection-overview tab bar + document/search rows, global search results, command palette rows. In-document link hover: MarkdownEditorConfiguration gets a new pointerCursorOverLinksWhileEditing flag (default true). Read-only mode already showed a pointing hand over links unconditionally; editable mode never did (I-beam only) until now - applyReadOnlyCursor in NativeTextView+CursorRects.swift now applies the same over any .link range while editing too, gated by the flag. Wiki links already carry .link alongside their own custom .wikiLinkID, so they're covered with no extra work. Closes out the last item from the original preferences-wiring scope.
143 lines
5.4 KiB
Swift
143 lines
5.4 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)
|
|
.pointerCursorOnHover()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|