From 4b23713c2b05fc1ab01f1d647b29002de93151d8 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 01:59:36 +0100 Subject: [PATCH] feat(collections): show full document hierarchy in the toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../CollectionDocumentsOutline.swift | 14 ++- .../Collections/CollectionOverviewView.swift | 19 +++- .../Collections/CollectionTreeRow.swift | 2 +- .../Collections/CollectionsTreeView.swift | 6 +- .../Collections/ContentView_macOS.swift | 88 ++++++++++++------- .../Collections/DocumentReaderView.swift | 35 +++++++- .../Collections/DocumentReaderViewModel.swift | 10 ++- .../Collections/DocumentsViewModel.swift | 4 +- .../Search/GlobalSearchResultsView.swift | 9 +- 9 files changed, 140 insertions(+), 47 deletions(-) diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 652cec7..3e3a013 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -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 ) diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index 6b16aea..d23e014 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -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) { + init( + apiClient: OutlineAPIClient, + collection: OutlineCollection, + searchQuery: Binding, + 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) } } } diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 46478cc..953a360 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift index d7d4451..afc4455 100644 --- a/Outpost/Features/Collections/CollectionsTreeView.swift +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -7,14 +7,14 @@ struct CollectionsTreeView: View { @Binding private var selectedCollection: OutlineCollection? let selectedDocumentID: String? @State private var expandedCollectionIDs: Set = [] - let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void + let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void let onSearchInCollection: (OutlineCollection) -> Void init( apiClient: OutlineAPIClient, selectedCollection: Binding, 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() } ) diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index ec5c636..dd361de 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -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,21 +65,29 @@ 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) - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - if let emoji = currentDocument.emoji { - Text(emoji) - } else { - Image(systemName: "doc.text") + + ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + + if let emoji = document.emoji { + Text(emoji) + } else { + Image(systemName: "doc.text") + } + + if index == documentPath.count - 1 { + Text(document.title.isEmpty ? "Untitled" : document.title) + .lineLimit(1) + } } - Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title) - .lineLimit(1) } } else if let selectedCollection { CollectionRowView(collection: selectedCollection) @@ -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") diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 3a7fd31..89fc1af 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -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 diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index a92b37a..37a0fe5 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -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 + )) ?? [] } } diff --git a/Outpost/Features/Collections/DocumentsViewModel.swift b/Outpost/Features/Collections/DocumentsViewModel.swift index 02825c5..0c5181d 100644 --- a/Outpost/Features/Collections/DocumentsViewModel.swift +++ b/Outpost/Features/Collections/DocumentsViewModel.swift @@ -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) { diff --git a/Outpost/Features/Search/GlobalSearchResultsView.swift b/Outpost/Features/Search/GlobalSearchResultsView.swift index 790e0a7..5780e4e 100644 --- a/Outpost/Features/Search/GlobalSearchResultsView.swift +++ b/Outpost/Features/Search/GlobalSearchResultsView.swift @@ -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) } } }