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.
This commit is contained in:
2026-08-14 01:59:36 +01:00
parent 3ecc463121
commit 4b23713c2b
9 changed files with 140 additions and 47 deletions
@@ -10,7 +10,9 @@ struct CollectionDocumentsOutline: View {
let sortOption: SidebarSortOption
let refreshToken: Int
let selectedDocumentID: String?
let onSelectDocument: (OutlineDocument) -> Void
/// Full chain from root to the clicked document (inclusive) lets the
/// toolbar render the real hierarchy instead of just the leaf title.
let onSelectDocument: ([OutlineDocument]) -> Void
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
@@ -22,7 +24,7 @@ struct CollectionDocumentsOutline: View {
sortOption: SidebarSortOption,
refreshToken: Int,
selectedDocumentID: String?,
onSelectDocument: @escaping (OutlineDocument) -> Void
onSelectDocument: @escaping ([OutlineDocument]) -> Void
) {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption
@@ -47,6 +49,7 @@ struct CollectionDocumentsOutline: View {
DocumentNodeRow(
node: node,
depth: 0,
ancestors: [],
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
@@ -60,8 +63,10 @@ struct CollectionDocumentsOutline: View {
private struct DocumentNodeRow: View {
let node: DocumentNode
let depth: Int
/// Chain from root down to (not including) this node.
let ancestors: [OutlineDocument]
let selectedDocumentID: String?
let onSelectDocument: (OutlineDocument) -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
@State private var isExpanded = false
@@ -95,7 +100,7 @@ private struct DocumentNodeRow: View {
}
Button {
onSelectDocument(node.document)
onSelectDocument(ancestors + [node.document])
} label: {
HStack(spacing: 8) {
if let emoji = node.document.emoji {
@@ -129,6 +134,7 @@ private struct DocumentNodeRow: View {
DocumentNodeRow(
node: child,
depth: depth + 1,
ancestors: ancestors + [node.document],
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
@@ -13,16 +13,23 @@ struct CollectionOverviewView: View {
// only the resulting search state.
@State private var searchViewModel: DocumentTitleSearchViewModel
@Binding var searchQuery: String
let onOpenDocument: (OutlineDocument) -> Void
private var trimmedSearchQuery: String {
searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
init(apiClient: OutlineAPIClient, collection: OutlineCollection, searchQuery: Binding<String>) {
init(
apiClient: OutlineAPIClient,
collection: OutlineCollection,
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
_searchQuery = searchQuery
self.onOpenDocument = onOpenDocument
}
private var sortedDocuments: [OutlineDocument] {
@@ -127,9 +134,12 @@ struct CollectionOverviewView: View {
)
} else {
List(sortedDocuments) { document in
NavigationLink(value: document) {
Button {
onOpenDocument(document)
} label: {
DocumentRowView(document: document)
}
.buttonStyle(.plain)
}
}
}
@@ -149,7 +159,9 @@ struct CollectionOverviewView: View {
ContentUnavailableView.search(text: trimmedSearchQuery)
} else {
List(searchViewModel.results) { result in
NavigationLink(value: result.document) {
Button {
onOpenDocument(result.document)
} label: {
VStack(alignment: .leading, spacing: 4) {
DocumentRowView(document: result.document)
Text(result.context)
@@ -159,6 +171,7 @@ struct CollectionOverviewView: View {
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
}
}
@@ -9,7 +9,7 @@ struct CollectionTreeRow: View {
let isSelected: Bool
let selectedDocumentID: String?
let onToggle: () -> Void
let onSelectDocument: (OutlineDocument) -> Void
let onSelectDocument: ([OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
let onCollectionsChanged: () async -> Void
@@ -7,14 +7,14 @@ struct CollectionsTreeView: View {
@Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = []
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
init(
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?,
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
@@ -74,7 +74,7 @@ struct CollectionsTreeView: View {
isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID,
onToggle: { toggle(collection) },
onSelectDocument: { document in onSelectDocument(collection, document) },
onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection,
onCollectionsChanged: { await viewModel.load() }
)
@@ -5,6 +5,8 @@ import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@State private var selectedCollection: OutlineCollection?
/// The real navigation stack, root to leaf also the source of truth for
/// the toolbar breadcrumb, so the two can't drift out of sync.
@State private var documentPath: [OutlineDocument] = []
@State private var globalSearchQuery = ""
@State private var contextualSearchQuery = ""
@@ -26,19 +28,19 @@ struct ContentView_macOS: View {
} detail: {
detail
}
// Empty rather than `windowTitle`: with a real value, the native title
// rendered in the same toolbar row as the custom `.navigation` pill
// below, visibly duplicating it. `.windowToolbarStyle(showsTitle:
// false)` was tried to suppress just the native text, but it also
// suppressed the custom `.navigation` item itself an empty title
// sidesteps both problems (Window-menu entry is blank as a result).
// Empty rather than a real value: with one, the native title rendered
// in the same toolbar row as the custom `.navigation` pill below,
// visibly duplicating it. `.windowToolbarStyle(showsTitle: false)` was
// tried to suppress just the native text, but it also suppressed the
// custom `.navigation` item itself an empty title sidesteps both
// problems (Window-menu entry is blank as a result).
.navigationTitle("")
.toolbar {
// Per HIG: "sidebar-toggle/back controls appear at the far leading
// edge, followed by the view title" this is that title, not
// centered. It doesn't collide with the back button because they're
// mutually exclusive: whenever a document's pushed (back button
// visible), this shows the document's own title instead of falling
// visible), this shows the document hierarchy instead of falling
// back to the workspace badge.
ToolbarItem(placement: .navigation) {
leadingToolbarContent
@@ -63,22 +65,30 @@ struct ContentView_macOS: View {
Image(systemName: "magnifyingglass")
Text("Search")
}
} else if let currentDocument = documentPath.last, let selectedCollection {
// Back button (system-provided) collection document, so the
// hierarchy reads left to right instead of just the leaf title.
} else if !documentPath.isEmpty, let selectedCollection {
// Collection every ancestor (icon only) current document
// (icon + full title) ancestors stay icon-only so a deep
// chain doesn't blow out the toolbar width.
HStack(spacing: 6) {
CollectionRowView(collection: selectedCollection)
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
if let emoji = currentDocument.emoji {
if let emoji = document.emoji {
Text(emoji)
} else {
Image(systemName: "doc.text")
}
Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title)
if index == documentPath.count - 1 {
Text(document.title.isEmpty ? "Untitled" : document.title)
.lineLimit(1)
}
}
}
} else if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
@@ -102,7 +112,7 @@ struct ContentView_macOS: View {
apiClient: apiClient,
selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id,
onSelectDocument: selectDocument,
onSelectDocument: selectDocumentChain,
onSearchInCollection: searchInCollection
)
} else {
@@ -118,16 +128,29 @@ struct ContentView_macOS: View {
}
}
/// `documentPath = [document]` (replacing a same-size array in one step)
/// unreliably updates `NavigationStack` on macOS the stack can silently
/// keep showing the previous document. Popping to root and pushing again
/// as two separate state updates (so SwiftUI processes them as two render
/// passes) is the reliable version of the same operation.
private func selectDocument(_ collection: OutlineCollection, _ document: OutlineDocument) {
/// Sidebar clicks (which know the real ancestor chain via DocumentNode):
/// replaces the whole stack with `chain`, so the toolbar shows full
/// hierarchy immediately rather than just the leaf.
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
selectedCollection = collection
replaceDocumentPath(with: chain)
}
/// Flat-list / search-result clicks: no known ancestors, single-level push.
private func openDocument(_ document: OutlineDocument) {
replaceDocumentPath(with: [document])
}
/// Replacing `path` with a same-size array can silently fail to update
/// NavigationStack on macOS popping to empty and pushing again as two
/// separate state updates (so SwiftUI processes them as two render
/// passes) is the reliable version of the same operation. Appending
/// (e.g. opening a sub-document from within the reader) doesn't hit this
/// and can mutate `documentPath` directly.
private func replaceDocumentPath(with chain: [OutlineDocument]) {
documentPath = []
Task { @MainActor in
documentPath = [document]
documentPath = chain
}
}
@@ -139,12 +162,13 @@ struct ContentView_macOS: View {
NavigationStack(path: $documentPath) {
Group {
if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery)
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
} else if let selectedCollection {
CollectionOverviewView(
apiClient: apiClient,
collection: selectedCollection,
searchQuery: $contextualSearchQuery
searchQuery: $contextualSearchQuery,
onOpenDocument: openDocument
)
} else {
ContentUnavailableView(
@@ -155,7 +179,11 @@ struct ContentView_macOS: View {
}
}
.navigationDestination(for: OutlineDocument.self) { document in
DocumentReaderView(apiClient: apiClient, document: document)
DocumentReaderView(
apiClient: apiClient,
document: document,
onOpenChild: { child in documentPath.append(child) }
)
}
}
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
@@ -7,9 +7,13 @@ import OutlineKit
/// isn't wired up yet, but link clicks and text selection still work.
struct DocumentReaderView: View {
@State private var viewModel: DocumentReaderViewModel
/// Pushes a genuine new stack entry (not a reset+replace) the back
/// button then correctly returns to this document, not the collection.
let onOpenChild: (OutlineDocument) -> Void
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
init(apiClient: OutlineAPIClient, document: OutlineDocument, onOpenChild: @escaping (OutlineDocument) -> Void) {
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild
}
var body: some View {
@@ -36,6 +40,10 @@ struct DocumentReaderView: View {
configuration: .init(heightBehavior: .fitsContent),
isEditable: false
)
if !viewModel.children.isEmpty {
childrenSection
}
}
}
.padding()
@@ -43,5 +51,30 @@ struct DocumentReaderView: View {
// No `.navigationTitle` here either same reason as CollectionOverviewView.
.task { await viewModel.loadFullContent() }
}
private var childrenSection: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 4)
Text("Sub-documents")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(viewModel.children) { child in
Button {
onOpenChild(child)
} label: {
DocumentRowView(document: child)
}
.buttonStyle(.plain)
.padding(.vertical, 4)
if child.id != viewModel.children.last?.id {
Divider()
}
}
}
}
}
#endif
@@ -8,11 +8,12 @@ final class DocumentReaderViewModel {
var title: String
var emoji: String?
var text: String
var children: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
let documentId: String
private let apiClient: OutlineAPIClient
private let documentId: String
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
@@ -37,5 +38,12 @@ final class DocumentReaderViewModel {
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
}
}
@@ -24,7 +24,7 @@ final class DocumentsViewModel {
defer { isLoading = false }
do {
documents = try await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100)
documents = try await apiClient.listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: 0, limit: 100)
hasRemoteChanges = false
} catch {
errorMessage = "Couldn't load documents. Check your connection and try again."
@@ -33,7 +33,7 @@ final class DocumentsViewModel {
/// See CollectionsViewModel.checkForRemoteChanges same reasoning.
func checkForRemoteChanges() async {
guard let fresh = try? await apiClient.listDocuments(collectionId: collection.id, offset: 0, limit: 100) else {
guard let fresh = try? await apiClient.listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: 0, limit: 100) else {
return
}
if Self.fingerprint(fresh) != Self.fingerprint(documents) {
@@ -5,10 +5,12 @@ import OutlineKit
struct GlobalSearchResultsView: View {
@State private var viewModel: GlobalSearchViewModel
let query: String
let onOpenDocument: (OutlineDocument) -> Void
init(apiClient: OutlineAPIClient, query: String) {
init(apiClient: OutlineAPIClient, query: String, onOpenDocument: @escaping (OutlineDocument) -> Void) {
_viewModel = State(initialValue: GlobalSearchViewModel(apiClient: apiClient))
self.query = query
self.onOpenDocument = onOpenDocument
}
private var searchKey: String {
@@ -119,7 +121,9 @@ struct GlobalSearchResultsView: View {
ContentUnavailableView.search(text: query)
} else {
List(viewModel.results) { result in
NavigationLink(value: result.document) {
Button {
onOpenDocument(result.document)
} label: {
VStack(alignment: .leading, spacing: 4) {
DocumentRowView(document: result.document)
Text(result.context)
@@ -129,6 +133,7 @@ struct GlobalSearchResultsView: View {
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
}
}