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

181 lines
5.7 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionTreeRow: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
let isExpanded: Bool
let isSelected: Bool
let selectedDocumentID: String?
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 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)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
if isExpanded {
CollectionDocumentsOutline(
apiClient: apiClient,
collection: collection,
sortOption: sortOption,
refreshToken: documentsRefreshToken,
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 ?? "")
}
}
@ViewBuilder
private var contextMenuContent: some View {
Button("Star") {
Task { await star() }
}
Divider()
Button("New Document") {
Task { await createDocument() }
}
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 apiClient.starCollection(StarCollectionRequest(collectionId: collection.id))
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this collection.")
}
}
private func createDocument() async {
do {
_ = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id)
)
documentsRefreshToken += 1
await onCollectionsChanged()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
}
}
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