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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user