The reader's inline "New Document" button (createChildDocument()) only navigated to the new document — no handle on the sidebar row it landed under, so the tree stayed stale until the periodic remote-changes poll surfaced the reload banner. Right-click "New Document" on a collection already refreshed correctly (bumps its own documentsRefreshToken) and is untouched. Threads a documentsChangedToken from ContentView_macOS down through CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline as externalRefreshToken; every expanded row reloads itself when it bumps, since the reader doesn't know which row (if any) corresponds to where the new document landed.
250 lines
10 KiB
Swift
250 lines
10 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
struct ContentView_macOS: View {
|
|
@Environment(SessionStore.self) private var session
|
|
@State private var selectedCollection: OutlineCollection?
|
|
/// The real navigation stack, root to leaf — also the source of truth for
|
|
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
|
@State private var documentPath: [OutlineDocument] = []
|
|
@State private var globalSearchQuery = ""
|
|
@State private var contextualSearchQuery = ""
|
|
@State private var isContextualSearchExpanded = false
|
|
@FocusState private var isContextualSearchFocused: Bool
|
|
/// Bumped whenever a document is created from somewhere with no direct
|
|
/// handle on the sidebar row it belongs under (the reader toolbar's
|
|
/// "New Document" button) — every expanded sidebar row reloads itself
|
|
/// in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
|
@State private var documentsChangedToken = 0
|
|
|
|
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 a real value: with one, 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 hierarchy instead of falling
|
|
// back to the workspace badge.
|
|
ToolbarItem(placement: .navigation) {
|
|
leadingToolbarContent
|
|
}
|
|
// Custom instead of `.searchable`: that modifier always renders a
|
|
// full-width field, but this is meant to sit alongside the other
|
|
// per-document toolbar buttons as a plain icon that only expands
|
|
// into a field once clicked.
|
|
ToolbarItem(placement: .primaryAction) {
|
|
contextualSearchField
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var contextualSearchField: some View {
|
|
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
TextField(
|
|
selectedCollection.map { "Search \($0.name)" } ?? "Search",
|
|
text: $contextualSearchQuery
|
|
)
|
|
.textFieldStyle(.plain)
|
|
.focused($isContextualSearchFocused)
|
|
.frame(width: 180)
|
|
|
|
if !contextualSearchQuery.isEmpty {
|
|
Button {
|
|
contextualSearchQuery = ""
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
.onChange(of: isContextualSearchFocused) { _, focused in
|
|
if !focused && contextualSearchQuery.isEmpty {
|
|
isContextualSearchExpanded = false
|
|
}
|
|
}
|
|
} else {
|
|
Button {
|
|
isContextualSearchExpanded = true
|
|
Task { @MainActor in isContextualSearchFocused = true }
|
|
} label: {
|
|
Image(systemName: "magnifyingglass")
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var leadingToolbarContent: some View {
|
|
Group {
|
|
if !trimmedGlobalQuery.isEmpty {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
Text("Search")
|
|
}
|
|
} else if !documentPath.isEmpty, let selectedCollection {
|
|
// Collection → every ancestor (icon only) → current document
|
|
// (icon + full title) — ancestors stay icon-only so a deep
|
|
// chain doesn't blow out the toolbar width.
|
|
HStack(spacing: 6) {
|
|
CollectionRowView(collection: selectedCollection)
|
|
|
|
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
|
|
if let emoji = document.emoji {
|
|
Text(emoji)
|
|
} else {
|
|
Image(systemName: "doc.text")
|
|
}
|
|
|
|
if index == documentPath.count - 1 {
|
|
Text(document.title.isEmpty ? "Untitled" : document.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,
|
|
externalRefreshToken: documentsChangedToken,
|
|
onSelectDocument: selectDocumentChain,
|
|
onSearchInCollection: searchInCollection
|
|
)
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
|
|
private func searchInCollection(_ collection: OutlineCollection) {
|
|
globalSearchQuery = ""
|
|
selectedCollection = collection
|
|
isContextualSearchExpanded = true
|
|
Task { @MainActor in
|
|
isContextualSearchFocused = true
|
|
}
|
|
}
|
|
|
|
/// Sidebar clicks (which know the real ancestor chain via DocumentNode):
|
|
/// replaces the whole stack with `chain`, so the toolbar shows full
|
|
/// hierarchy immediately rather than just the leaf.
|
|
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
|
|
selectedCollection = collection
|
|
replaceDocumentPath(with: chain)
|
|
}
|
|
|
|
/// Flat-list / search-result clicks: no known ancestors, single-level push.
|
|
private func openDocument(_ document: OutlineDocument) {
|
|
replaceDocumentPath(with: [document])
|
|
}
|
|
|
|
/// Replacing `path` with a same-size array can silently fail to update
|
|
/// NavigationStack on macOS — popping to empty and pushing again as two
|
|
/// separate state updates (so SwiftUI processes them as two render
|
|
/// passes) is the reliable version of the same operation. Appending
|
|
/// (e.g. opening a sub-document from within the reader) doesn't hit this
|
|
/// and can mutate `documentPath` directly.
|
|
private func replaceDocumentPath(with chain: [OutlineDocument]) {
|
|
documentPath = []
|
|
Task { @MainActor in
|
|
documentPath = chain
|
|
}
|
|
}
|
|
|
|
@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, onOpenDocument: openDocument)
|
|
} else if let selectedCollection {
|
|
CollectionOverviewView(
|
|
apiClient: apiClient,
|
|
collection: selectedCollection,
|
|
searchQuery: $contextualSearchQuery,
|
|
onOpenDocument: openDocument
|
|
)
|
|
} 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,
|
|
onOpenChild: { child in documentPath.append(child) },
|
|
onDeleted: {
|
|
if !documentPath.isEmpty {
|
|
documentPath.removeLast()
|
|
}
|
|
},
|
|
onDocumentCreated: { documentsChangedToken += 1 }
|
|
)
|
|
}
|
|
}
|
|
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
}
|
|
#endif
|