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:
2026-08-14 00:58:27 +01:00
parent 4bec511b62
commit 62463d420c
2 changed files with 92 additions and 28 deletions
@@ -2,17 +2,17 @@
import SwiftUI
import OutlineKit
/// Flat document list nested under an expanded collection in the sidebar tree.
/// Documents can themselves have children (`parentDocumentId`) in Outline not
/// represented here yet, this is a first pass at the collection-level expansion.
/// Nested document outline under an expanded collection in the sidebar tree
/// real hierarchy via `parentDocumentId`, see DocumentNode for why that's
/// client-side rather than `collections.documents`.
struct CollectionDocumentsOutline: View {
@State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption
let refreshToken: Int
let onSelectDocument: (OutlineDocument) -> Void
private var sortedDocuments: [OutlineDocument] {
sortOption.sorted(viewModel.documents)
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
}
init(
@@ -40,33 +40,71 @@ struct CollectionDocumentsOutline: View {
.foregroundStyle(.secondary)
.padding(.vertical, 6)
} else {
ForEach(sortedDocuments) { document in
Button {
onSelectDocument(document)
} label: {
HStack(spacing: 8) {
if let emoji = document.emoji {
Text(emoji)
.font(.body)
} else {
Image(systemName: "doc.text")
.font(.callout)
.foregroundStyle(.secondary)
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.body)
.lineLimit(1)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 6)
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 {
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: {
HStack(spacing: 8) {
if let emoji = node.document.emoji {
Text(emoji)
.font(.body)
} else {
Image(systemName: "doc.text")
.font(.callout)
.foregroundStyle(.secondary)
}
Text(node.document.title.isEmpty ? "Untitled" : node.document.title)
.font(.body)
.lineLimit(1)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.padding(.leading, CGFloat(depth) * 16)
if isExpanded {
ForEach(node.children) { child in
DocumentNodeRow(node: child, depth: depth + 1, onSelectDocument: onSelectDocument)
}
}
}
}
}
#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)
}