fix(collections): show collection name in toolbar, highlight open doc

.navigationTitle set to a real value duplicated the custom toolbar
pill (macOS renders both in the same row); the previous attempt to
suppress just the native text via .windowToolbarStyle(showsTitle:
false) also suppressed the custom .navigation item itself. Empty
title sidesteps both.

Also threads the open document's id down through the sidebar tree so
its row highlights too, not just the containing collection — and
auto-expands whichever collection contains it when the document was
opened from outside the sidebar (detail pane, global search).
This commit is contained in:
2026-08-14 01:16:40 +01:00
parent 952a9620a9
commit a0643090b3
4 changed files with 62 additions and 20 deletions
@@ -9,6 +9,7 @@ struct CollectionDocumentsOutline: View {
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
let sortOption: SidebarSortOption let sortOption: SidebarSortOption
let refreshToken: Int let refreshToken: Int
let selectedDocumentID: String?
let onSelectDocument: (OutlineDocument) -> Void let onSelectDocument: (OutlineDocument) -> Void
private var tree: [DocumentNode] { private var tree: [DocumentNode] {
@@ -20,11 +21,13 @@ struct CollectionDocumentsOutline: View {
collection: OutlineCollection, collection: OutlineCollection,
sortOption: SidebarSortOption, sortOption: SidebarSortOption,
refreshToken: Int, refreshToken: Int,
selectedDocumentID: String?,
onSelectDocument: @escaping (OutlineDocument) -> Void onSelectDocument: @escaping (OutlineDocument) -> Void
) { ) {
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
self.sortOption = sortOption self.sortOption = sortOption
self.refreshToken = refreshToken self.refreshToken = refreshToken
self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
} }
@@ -41,7 +44,12 @@ struct CollectionDocumentsOutline: View {
.padding(.vertical, 6) .padding(.vertical, 6)
} else { } else {
ForEach(tree) { node in ForEach(tree) { node in
DocumentNodeRow(node: node, depth: 0, onSelectDocument: onSelectDocument) DocumentNodeRow(
node: node,
depth: 0,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
} }
} }
} }
@@ -52,10 +60,22 @@ struct CollectionDocumentsOutline: View {
private struct DocumentNodeRow: View { private struct DocumentNodeRow: View {
let node: DocumentNode let node: DocumentNode
let depth: Int let depth: Int
let selectedDocumentID: String?
let onSelectDocument: (OutlineDocument) -> Void let onSelectDocument: (OutlineDocument) -> Void
@State private var isExpanded = false @State private var isExpanded = false
private var isSelected: Bool {
node.id == selectedDocumentID
}
private func containsSelected(_ node: DocumentNode) -> Bool {
guard let selectedDocumentID else { return false }
return node.children.contains {
$0.id == selectedDocumentID || containsSelected($0)
}
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -97,14 +117,33 @@ private struct DocumentNodeRow: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 4)
.padding(.leading, CGFloat(depth) * 16) .padding(.leading, CGFloat(depth) * 16)
.background(
isSelected ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
if isExpanded { if isExpanded {
ForEach(node.children) { child in ForEach(node.children) { child in
DocumentNodeRow(node: child, depth: depth + 1, onSelectDocument: onSelectDocument) DocumentNodeRow(
node: child,
depth: depth + 1,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument
)
} }
} }
} }
// Reveals the selected document if it's nested under this node
// both on first appearance and whenever the selection changes but
// never auto-collapses on the way out, so manual expand/collapse
// elsewhere in the tree isn't fought.
.task(id: selectedDocumentID) {
if containsSelected(node) {
isExpanded = true
}
}
} }
} }
#endif #endif
@@ -7,6 +7,7 @@ struct CollectionTreeRow: View {
let collection: OutlineCollection let collection: OutlineCollection
let isExpanded: Bool let isExpanded: Bool
let isSelected: Bool let isSelected: Bool
let selectedDocumentID: String?
let onToggle: () -> Void let onToggle: () -> Void
let onSelectDocument: (OutlineDocument) -> Void let onSelectDocument: (OutlineDocument) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -52,6 +53,7 @@ struct CollectionTreeRow: View {
collection: collection, collection: collection,
sortOption: sortOption, sortOption: sortOption,
refreshToken: documentsRefreshToken, refreshToken: documentsRefreshToken,
selectedDocumentID: selectedDocumentID,
onSelectDocument: onSelectDocument onSelectDocument: onSelectDocument
) )
.padding(.leading, 18) .padding(.leading, 18)
@@ -5,6 +5,7 @@ import OutlineKit
struct CollectionsTreeView: View { struct CollectionsTreeView: View {
@State private var viewModel: CollectionsViewModel @State private var viewModel: CollectionsViewModel
@Binding private var selectedCollection: OutlineCollection? @Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = [] @State private var expandedCollectionIDs: Set<String> = []
let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void let onSelectDocument: (OutlineCollection, OutlineDocument) -> Void
let onSearchInCollection: (OutlineCollection) -> Void let onSearchInCollection: (OutlineCollection) -> Void
@@ -12,11 +13,13 @@ struct CollectionsTreeView: View {
init( init(
apiClient: OutlineAPIClient, apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>, selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?,
onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void, onSelectDocument: @escaping (OutlineCollection, OutlineDocument) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void onSearchInCollection: @escaping (OutlineCollection) -> Void
) { ) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection _selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID
self.onSelectDocument = onSelectDocument self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection self.onSearchInCollection = onSearchInCollection
} }
@@ -50,6 +53,7 @@ struct CollectionsTreeView: View {
collection: collection, collection: collection,
isExpanded: expandedCollectionIDs.contains(collection.id), isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id, isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID,
onToggle: { toggle(collection) }, onToggle: { toggle(collection) },
onSelectDocument: { document in onSelectDocument(collection, document) }, onSelectDocument: { document in onSelectDocument(collection, document) },
onSearchInCollection: onSearchInCollection, onSearchInCollection: onSearchInCollection,
@@ -69,6 +73,13 @@ struct CollectionsTreeView: View {
selectedCollection = first selectedCollection = first
} }
} }
// A document opened from outside the sidebar (detail pane's list,
// global search) wouldn't otherwise expand its collection here, so
// the highlighted row would stay hidden.
.task(id: selectedDocumentID) {
guard selectedDocumentID != nil, let selectedCollection else { return }
expandedCollectionIDs.insert(selectedCollection.id)
}
} }
private func toggle(_ collection: OutlineCollection) { private func toggle(_ collection: OutlineCollection) {
@@ -14,20 +14,6 @@ struct ContentView_macOS: View {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
} }
/// Mirrors leadingToolbarContent's priority (search > document > collection
/// > workspace) so the actual window title and the custom pill agree.
private var windowTitle: String {
if !trimmedGlobalQuery.isEmpty {
return "Search"
} else if let currentDocument = documentPath.last {
return currentDocument.title.isEmpty ? "Untitled" : currentDocument.title
} else if let selectedCollection {
return selectedCollection.name
} else {
return session.teamName ?? "Outpost"
}
}
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -40,10 +26,13 @@ struct ContentView_macOS: View {
} detail: { } detail: {
detail detail
} }
// Was hardcoded to the team name and never updated the actual macOS // Empty rather than `windowTitle`: with a real value, the native title
// window title (separate from the leadingToolbarContent pill below) // rendered in the same toolbar row as the custom `.navigation` pill
// stayed on the workspace name even with a collection/document open. // below, visibly duplicating it. `.windowToolbarStyle(showsTitle:
.navigationTitle(windowTitle) // 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 { .toolbar {
// Per HIG: "sidebar-toggle/back controls appear at the far leading // Per HIG: "sidebar-toggle/back controls appear at the far leading
// edge, followed by the view title" this is that title, not // edge, followed by the view title" this is that title, not
@@ -112,6 +101,7 @@ struct ContentView_macOS: View {
CollectionsTreeView( CollectionsTreeView(
apiClient: apiClient, apiClient: apiClient,
selectedCollection: $selectedCollection, selectedCollection: $selectedCollection,
selectedDocumentID: documentPath.last?.id,
onSelectDocument: selectDocument, onSelectDocument: selectDocument,
onSearchInCollection: searchInCollection onSearchInCollection: searchInCollection
) )