Files
Outpost/Outpost/Features/Collections/ContentView_macOS.swift
T
Puranjay Savar Mattas 6dd632bb97 feat(collections): add collections and documents browsing
macOS: expandable/collapsible sidebar tree (collections.list, with
nested documents.list per expanded collection) driving a detail pane.
iOS: flat collection list pushing to a document list. Both share row
views and per-platform ContentView selectors, matching the AuthView
pattern.
2026-08-14 00:24:07 +01:00

150 lines
6.1 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@State private var selectedCollection: OutlineCollection?
@State private var documentPath: [OutlineDocument] = []
@State private var globalSearchQuery = ""
@State private var contextualSearchQuery = ""
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
sidebar
AccountFooter()
}
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
detail
}
.navigationTitle(session.teamName ?? "Outpost")
.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
// back to the workspace badge.
ToolbarItem(placement: .navigation) {
leadingToolbarContent
}
}
// Attached at this level (not inside the detail's NavigationStack) so
// it reliably renders in the shared window toolbar instead of a
// per-screen one that can get lost when nested more deeply.
.searchable(
text: $contextualSearchQuery,
placement: .toolbar,
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
)
}
@ViewBuilder
private var leadingToolbarContent: some View {
Group {
if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) {
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.
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")
}
Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title)
.lineLimit(1)
}
} else if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
HStack(spacing: 8) {
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
Text(session.teamName ?? "Outpost")
.lineLimit(1)
}
}
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
@ViewBuilder
private var sidebar: some View {
if let apiClient = session.apiClient {
CollectionsTreeView(
apiClient: apiClient,
selectedCollection: $selectedCollection,
onSelectDocument: selectDocument
)
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
/// `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) {
selectedCollection = collection
documentPath = []
Task { @MainActor in
documentPath = [document]
}
}
@ViewBuilder
private var detail: some View {
if let apiClient = session.apiClient {
// A fresh NavigationStack per collection (or when entering/leaving
// search), so switching either also clears any pushed document.
NavigationStack(path: $documentPath) {
Group {
if !trimmedGlobalQuery.isEmpty {
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery)
} else if let selectedCollection {
CollectionOverviewView(
apiClient: apiClient,
collection: selectedCollection,
searchQuery: $contextualSearchQuery
)
} else {
ContentUnavailableView(
"No Collection Selected",
systemImage: "sidebar.left",
description: Text("Choose a collection to see its documents.")
)
}
}
.navigationDestination(for: OutlineDocument.self) { document in
DocumentReaderView(apiClient: apiClient, document: document)
}
}
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
}
#endif