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.
This commit is contained in:
2026-08-15 01:13:28 +01:00
parent 82ff3b49ff
commit a9db3e1412
@@ -40,14 +40,51 @@ struct ContentView_macOS: View {
} }
var body: some View { var body: some View {
NavigationSplitView { // A totally separate top-level branch, not content swapped inside
Group { // 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 { if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList( SettingsSidebarList(
selection: selectedSettingsSectionBinding, selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false } onDone: { navigation.isShowingSettings = false }
) )
} else { .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) { VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery) SidebarSearchField(text: $globalSearchQuery)
Divider() Divider()
@@ -61,19 +98,9 @@ struct ContentView_macOS: View {
sidebar sidebar
AccountFooter() AccountFooter()
} }
}
}
.navigationSplitViewColumnWidth(min: 220, ideal: 260) .navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: { } detail: {
detail detail
// NavigationSplitView on macOS doesn't reliably tear down the
// detail pane's previous content when swapping between very
// different subtrees (a NavigationStack with a document
// pushed vs. plain SettingsView) off an implicit branch
// alone an explicit identity change forces a real
// teardown/remount instead of it silently leaving the old
// document visible underneath.
.id(navigation.isShowingSettings)
} }
// Empty rather than a real value: with one, the native title rendered // Empty rather than a real value: with one, the native title rendered
// in the same toolbar row as the custom `.navigation` pill below, // in the same toolbar row as the custom `.navigation` pill below,
@@ -108,7 +135,7 @@ struct ContentView_macOS: View {
// `CollectionOverviewView`, so on Home it was a dead end: a // `CollectionOverviewView`, so on Home it was a dead end: a
// user could click it, type, and nothing would happen. The // user could click it, type, and nothing would happen. The
// sidebar's global search already covers "search everything." // sidebar's global search already covers "search everything."
if !navigation.isShowingSettings && !(isShowingHome && documentPath.isEmpty) { if !(isShowingHome && documentPath.isEmpty) {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
contextualSearchField contextualSearchField
} }
@@ -178,12 +205,7 @@ struct ContentView_macOS: View {
@ViewBuilder @ViewBuilder
private var leadingToolbarContent: some View { private var leadingToolbarContent: some View {
Group { Group {
if navigation.isShowingSettings { if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
} else if !trimmedGlobalQuery.isEmpty {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
Text("Search") Text("Search")
@@ -262,7 +284,6 @@ struct ContentView_macOS: View {
globalSearchQuery = "" globalSearchQuery = ""
selectedCollection = collection selectedCollection = collection
isContextualSearchExpanded = true isContextualSearchExpanded = true
navigation.isShowingSettings = false
Task { @MainActor in Task { @MainActor in
isContextualSearchFocused = true isContextualSearchFocused = true
} }
@@ -273,13 +294,11 @@ struct ContentView_macOS: View {
/// hierarchy immediately rather than just the leaf. /// hierarchy immediately rather than just the leaf.
private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) { private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) {
selectedCollection = collection selectedCollection = collection
navigation.isShowingSettings = false
replaceDocumentPath(with: chain) replaceDocumentPath(with: chain)
} }
/// Flat-list / search-result clicks: no known ancestors, single-level push. /// Flat-list / search-result clicks: no known ancestors, single-level push.
private func openDocument(_ document: OutlineDocument) { private func openDocument(_ document: OutlineDocument) {
navigation.isShowingSettings = false
replaceDocumentPath(with: [document]) replaceDocumentPath(with: [document])
} }
@@ -298,14 +317,7 @@ struct ContentView_macOS: View {
@ViewBuilder @ViewBuilder
private var detail: some View { private var detail: some View {
if navigation.isShowingSettings { if let apiClient = session.apiClient {
// Deliberately not a `NavigationStack` destination or a separate
// window it's swapped into the same detail pane Home/
// collections use, alongside the real sidebar (now showing
// `SettingsSidebarList` instead of the collections tree) rather
// than a page with its own nested mini sidebar.
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
} else if let apiClient = session.apiClient {
// A fresh NavigationStack per collection (or when entering/leaving // A fresh NavigationStack per collection (or when entering/leaving
// search), so switching either also clears any pushed document. // search), so switching either also clears any pushed document.
NavigationStack(path: $documentPath) { NavigationStack(path: $documentPath) {