Files
Outpost/Outpost/Features/Collections/CollectionsTreeView.swift
T
Puranjay Savar Mattas 1096967645 feat(sidebar): add a Home row above the collections list
Home was only reachable via the toolbar house icon — added a pinned
row at the top of the sidebar (matching a collection row's style,
highlighted when active) so it's a first-class nav target alongside
collections rather than toolbar-only.
2026-08-14 16:52:34 +01:00

158 lines
6.4 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct CollectionsTreeView: View {
@State private var viewModel: CollectionsViewModel
@Binding private var selectedCollection: OutlineCollection?
let selectedDocumentID: String?
@State private var expandedCollectionIDs: Set<String> = []
/// Bumped from `ContentView_macOS` whenever a document is created
/// somewhere with no direct handle on the sidebar row it belongs
/// under — see the identical parameter on `CollectionDocumentsOutline`.
let externalRefreshToken: Int
let isShowingHome: Bool
let onSelectHome: () -> Void
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
let onSearchInCollection: (OutlineCollection) -> Void
init(
apiClient: OutlineAPIClient,
selectedCollection: Binding<OutlineCollection?>,
selectedDocumentID: String?,
externalRefreshToken: Int,
isShowingHome: Bool,
onSelectHome: @escaping () -> Void,
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
onSearchInCollection: @escaping (OutlineCollection) -> Void
) {
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
_selectedCollection = selectedCollection
self.selectedDocumentID = selectedDocumentID
self.externalRefreshToken = externalRefreshToken
self.isShowingHome = isShowingHome
self.onSelectHome = onSelectHome
self.onSelectDocument = onSelectDocument
self.onSearchInCollection = onSearchInCollection
}
var body: some View {
VStack(spacing: 0) {
if viewModel.hasRemoteChanges {
RemoteChangesBanner {
Task { await viewModel.load() }
}
}
homeRow
content
}
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(45))
guard !Task.isCancelled else { break }
await viewModel.checkForRemoteChanges()
}
}
}
/// Pinned above the collections list, not inside the scroll region —
/// Home isn't a collection, so it doesn't belong in `viewModel.collections`
/// or compete with them for scroll space.
private var homeRow: some View {
Button(action: onSelectHome) {
HStack(spacing: 6) {
Image(systemName: "house.fill")
.font(.callout)
.foregroundStyle(.secondary)
Text("Home")
.font(.body)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.background(
isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear,
in: RoundedRectangle(cornerRadius: 6)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
.padding(.top, 4)
}
@ViewBuilder
private var content: some View {
Group {
if viewModel.isLoading && viewModel.collections.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty {
ContentUnavailableView {
Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.load() }
}
}
} else if viewModel.collections.isEmpty {
ContentUnavailableView(
"No Collections",
systemImage: "folder",
description: Text("Collections you have access to will appear here.")
)
} else {
// Plain `ScrollView`/`LazyVStack`, not `List`: on macOS, a
// `List` row's context menu wins over any `.contextMenu`
// nested deeper inside that row's content (right-clicking a
// document row inside an expanded `CollectionTreeRow` showed
// the collection's menu instead of the document's) — every
// row here already does its own selection highlighting, so
// `List` wasn't buying anything but that bug.
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
ForEach(viewModel.collections) { collection in
CollectionTreeRow(
apiClient: viewModel.apiClient,
collection: collection,
isExpanded: expandedCollectionIDs.contains(collection.id),
isSelected: selectedCollection?.id == collection.id,
selectedDocumentID: selectedDocumentID,
externalRefreshToken: externalRefreshToken,
onToggle: { toggle(collection) },
onSelectDocument: { chain in onSelectDocument(collection, chain) },
onSearchInCollection: onSearchInCollection,
onCollectionsChanged: { await viewModel.load() }
)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
}
.task {
await viewModel.load()
}
// 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) {
selectedCollection = collection
if expandedCollectionIDs.contains(collection.id) {
expandedCollectionIDs.remove(collection.id)
} else {
expandedCollectionIDs.insert(collection.id)
}
}
}
#endif