Files
Outpost/Outpost/Features/Collections/CollectionTreeRow.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

189 lines
6.1 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
@MainActor
struct CollectionTreeRow: View {
@Environment(StarStore.self) private var starStore
let apiClient: OutlineAPIClient
let collection: OutlineCollection
let isExpanded: Bool
let isSelected: Bool
let selectedDocumentID: String?
/// See the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let onToggle: () -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
let onCollectionsChanged: () async -> Void
@State private var sortOption: SidebarSortOption = .manual
@State private var documentsRefreshToken = 0
@State private var isShowingRenameAlert = false
@State private var renameText = ""
@State private var isShowingDeleteConfirmation = false
@State private var isShowingNewDocumentSheet = false
@State private var actionErrorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onToggle) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12)
CollectionRowView(collection: collection)
Spacer(minLength: 0)
if starStore.isStarred(collectionId: collection.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
if isExpanded {
CollectionDocumentsOutline(
apiClient: apiClient,
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
externalRefreshToken: externalRefreshToken,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
.padding(.leading, 18)
}
}
.alert("Rename Collection", isPresented: $isShowingRenameAlert) {
TextField("Name", text: $renameText)
Button("Cancel", role: .cancel) {}
Button("Rename") {
Task { await rename() }
}
}
.confirmationDialog(
"Delete \"\(collection.name)\"?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
Task { await delete() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This deletes the collection and all of its documents. This can't be undone.")
}
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
Button("OK") { actionErrorMessage = nil }
} message: {
Text(actionErrorMessage ?? "")
}
.sheet(isPresented: $isShowingNewDocumentSheet) {
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
documentsRefreshToken += 1
Task { await onCollectionsChanged() }
}
}
}
@ViewBuilder
private var contextMenuContent: some View {
Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") {
Task { await star() }
}
Divider()
Button("New Document") {
isShowingNewDocumentSheet = true
}
Divider()
Button("Rename…") {
renameText = collection.name
isShowingRenameAlert = true
}
Divider()
Menu("Sort in Sidebar") {
Picker("Sort", selection: $sortOption) {
ForEach(SidebarSortOption.allCases) { option in
Text(option.label).tag(option)
}
}
}
Divider()
Button("Export…") {
Task { await export() }
}
Button("Search in Collection") {
onSearchInCollection(collection)
}
Divider()
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
}
private func star() async {
do {
try await starStore.toggleCollection(collection.id, apiClient: apiClient)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.")
}
}
private func rename() async {
do {
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't rename this collection.")
}
}
private func export() async {
do {
_ = try await apiClient.exportCollection(ExportCollectionRequest(id: collection.id))
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't start the export.")
}
}
private func delete() async {
do {
try await apiClient.deleteCollection(id: collection.id)
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this collection.")
}
}
}
#endif