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.
181 lines
5.7 KiB
Swift
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
|