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.
158 lines
6.4 KiB
Swift
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
|