Files
Outpost/Outpost/Features/Search/GlobalSearchResultsView.swift
T
Puranjay Savar Mattas f01c2b9467 feat: pointer cursor preference
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.
2026-08-20 22:21:36 +01:00

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