Files
Outpost/Outpost/Features/Collections/ContentView_macOS.swift
T
Puranjay Savar Mattas a9db3e1412 fix(settings): make Settings its own top-level branch, not swapped content
The .id() fix on the detail pane wasn't enough — still reported as
the document staying visible with only the sidebar switching.
NavigationSplitView bridges to NSSplitViewController on macOS, and
apparently doesn't reliably replace already-mounted detail content
(a NavigationStack with real push history) for something unrelated
inside one persisting split view instance, identity hints or not.

Restructured so `navigation.isShowingSettings` picks between two
entirely separate NavigationSplitView instances (settingsContent /
mainContent) at the top of body, instead of branching on content
inside a single one. A different top-level view hierarchy is a
guaranteed full teardown of whatever AppKit was holding onto — no
split-view instance persists across the switch for it to get
confused about reusing.

Also dropped the now-redundant `isShowingSettings` guards scattered
through mainContent's toolbar/leadingToolbarContent — moot once
mainContent only ever renders while Settings isn't showing.
2026-08-15 01:13:28 +01:00

365 lines
16 KiB
Swift

#if os(macOS)
import SwiftUI
import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// The landing state — no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@State private var isShowingHome = true
@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 and
/// Home's "New Document" buttons) — every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
/// own (that's `@Bindable`'s job, and introducing one in `body` would
/// mean restructuring it away from a single implicit-return expression)
/// — a manually-built `Binding` over the same reference is simpler here.
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
Binding(
get: { navigation.selectedSettingsSection },
set: { navigation.selectedSettingsSection = $0 }
)
}
var body: some View {
// A totally separate top-level branch, not content swapped inside
// one persistent `NavigationSplitView` — that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList(
selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false }
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
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) {
Button {
goHome()
} label: {
Image(systemName: "house")
}
.help("Home")
}
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. Hidden on the Home landing page
// itself — `contextualSearchQuery` is only ever read by
// `CollectionOverviewView`, so on Home it was a dead end: a
// user could click it, type, and nothing would happen. The
// sidebar's global search already covers "search everything."
if !(isShowingHome && documentPath.isEmpty) {
ToolbarItem(placement: .primaryAction) {
contextualSearchField
}
}
}
// Any explicit collection pick — sidebar click, "Search in
// Collection" — means the user has navigated away from Home.
.onChange(of: selectedCollection) { _, newValue in
if newValue != nil {
isShowingHome = false
}
}
}
private func goHome() {
globalSearchQuery = ""
contextualSearchQuery = ""
isContextualSearchExpanded = false
selectedCollection = nil
isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: [])
}
@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 {
// Origin (collection, or Home if opened from there) → every
// ancestor (icon only) → current document (icon + full
// title) — ancestors stay icon-only so a deep chain doesn't
// blow out the toolbar width. Checked before `isShowingHome`
// since opening a document from Home still leaves that flag
// set — the pushed document should win either way.
HStack(spacing: 6) {
if let selectedCollection {
CollectionRowView(collection: selectedCollection)
} else {
Image(systemName: "house.fill")
}
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 isShowingHome {
HStack(spacing: 6) {
Image(systemName: "house.fill")
Text("Home")
}
} 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,
isShowingHome: isShowingHome,
onSelectHome: goHome,
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 isShowingHome {
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
} 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 ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
} else {
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
}
}
}
#endif