Files
Outpost/Outpost/Features/Collections/ContentView_macOS.swift
T
Puranjay Savar Mattas ed9fd26c85 fix(sidebar): auto-refresh after creating a doc from the reader toolbar
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.
2026-08-14 16:50:09 +01:00

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