feat(collections): show real document hierarchy in sidebar outline
Builds a tree from parentDocumentId over the flat documents.list response instead of a flat list — collections.documents returns this shape server-side already, but its NavigationNode has no emoji or timestamps, which would break per-row icons and date-based sidebar sort. Each node gets its own expand/collapse now.
This commit is contained in:
@@ -2,17 +2,17 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Flat document list nested under an expanded collection in the sidebar tree.
|
/// Nested document outline under an expanded collection in the sidebar tree —
|
||||||
/// Documents can themselves have children (`parentDocumentId`) in Outline — not
|
/// real hierarchy via `parentDocumentId`, see DocumentNode for why that's
|
||||||
/// represented here yet, this is a first pass at the collection-level expansion.
|
/// client-side rather than `collections.documents`.
|
||||||
struct CollectionDocumentsOutline: View {
|
struct CollectionDocumentsOutline: View {
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
let sortOption: SidebarSortOption
|
let sortOption: SidebarSortOption
|
||||||
let refreshToken: Int
|
let refreshToken: Int
|
||||||
let onSelectDocument: (OutlineDocument) -> Void
|
let onSelectDocument: (OutlineDocument) -> Void
|
||||||
|
|
||||||
private var sortedDocuments: [OutlineDocument] {
|
private var tree: [DocumentNode] {
|
||||||
sortOption.sorted(viewModel.documents)
|
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -40,12 +40,45 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
} else {
|
} else {
|
||||||
ForEach(sortedDocuments) { document in
|
ForEach(tree) { node in
|
||||||
|
DocumentNodeRow(node: node, depth: 0, onSelectDocument: onSelectDocument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: refreshToken) { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DocumentNodeRow: View {
|
||||||
|
let node: DocumentNode
|
||||||
|
let depth: Int
|
||||||
|
let onSelectDocument: (OutlineDocument) -> Void
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if node.children.isEmpty {
|
||||||
|
Color.clear.frame(width: 12)
|
||||||
|
} else {
|
||||||
Button {
|
Button {
|
||||||
onSelectDocument(document)
|
isExpanded.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||||
|
.frame(width: 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onSelectDocument(node.document)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let emoji = document.emoji {
|
if let emoji = node.document.emoji {
|
||||||
Text(emoji)
|
Text(emoji)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
} else {
|
} else {
|
||||||
@@ -53,7 +86,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
Text(node.document.title.isEmpty ? "Untitled" : node.document.title)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
@@ -62,11 +95,16 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
.padding(.leading, CGFloat(depth) * 16)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
ForEach(node.children) { child in
|
||||||
|
DocumentNodeRow(node: child, depth: depth + 1, onSelectDocument: onSelectDocument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: refreshToken) { await viewModel.load() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Reconstructs the real document hierarchy client-side from `parentDocumentId`.
|
||||||
|
/// `documents.list` already returns the full flat set for a collection
|
||||||
|
/// (including children, not just roots), so no separate tree-shaped endpoint
|
||||||
|
/// is needed — `collections.documents` exists and returns exactly this shape,
|
||||||
|
/// but its `NavigationNode` carries no emoji/timestamps, which would break
|
||||||
|
/// per-row icons and date-based sidebar sorting.
|
||||||
|
struct DocumentNode: Identifiable {
|
||||||
|
let document: OutlineDocument
|
||||||
|
let children: [DocumentNode]
|
||||||
|
var id: String { document.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDocumentTree(from documents: [OutlineDocument], sortedBy sort: SidebarSortOption) -> [DocumentNode] {
|
||||||
|
let byParent = Dictionary(grouping: documents, by: { $0.parentDocumentId })
|
||||||
|
|
||||||
|
func makeNodes(parentId: String?) -> [DocumentNode] {
|
||||||
|
sort.sorted(byParent[parentId] ?? []).map { document in
|
||||||
|
DocumentNode(document: document, children: makeNodes(parentId: document.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeNodes(parentId: nil)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user