diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 098a150..96958b1 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -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 diff --git a/Outpost/Features/Collections/DocumentNode.swift b/Outpost/Features/Collections/DocumentNode.swift new file mode 100644 index 0000000..d66d7b2 --- /dev/null +++ b/Outpost/Features/Collections/DocumentNode.swift @@ -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) +}