.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).
168 lines
7.0 KiB
Swift
168 lines
7.0 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 = ""
|
|
@FocusState private var isContextualSearchFocused: Bool
|
|
|
|
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
|
|
}
|
|
// 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).
|
|
.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
|
|
// 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"
|
|
)
|
|
.searchFocused($isContextualSearchFocused)
|
|
}
|
|
|
|
@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,
|
|
selectedDocumentID: documentPath.last?.id,
|
|
onSelectDocument: selectDocument,
|
|
onSearchInCollection: searchInCollection
|
|
)
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
|
|
private func searchInCollection(_ collection: OutlineCollection) {
|
|
globalSearchQuery = ""
|
|
selectedCollection = collection
|
|
Task { @MainActor in
|
|
isContextualSearchFocused = true
|
|
}
|
|
}
|
|
|
|
/// `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
|