redesign(settings): reuse the real sidebar instead of a mini one
Settings now swaps the actual app sidebar's content (search field, collections tree, account footer) for a section list, instead of SettingsView drawing its own nested sidebar inside the detail pane. Done, wherever it's triggered from, restores the collections tree and detail content exactly as they were. AppNavigation gains selectedSettingsSection (and the SettingsSection enum moves there, shared by the new list and the sidebar host) so the sidebar's list and the detail pane agree on which section is showing. Also deferred the profile menu's "Settings…" action by one runloop tick — setting isShowingSettings synchronously in the same call that dismisses the popover was very likely the source of the AppKit "CA commit" transaction warnings in the console.
This commit is contained in:
@@ -100,7 +100,12 @@ struct AccountFooter: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
menuItem("Profile…") { isShowingProfile = true }
|
menuItem("Profile…") { isShowingProfile = true }
|
||||||
menuItem("Settings…") { navigation.isShowingSettings = true }
|
// Deferred a tick: setting this synchronously in the same call
|
||||||
|
// that dismisses this popover collides two AppKit window/layer
|
||||||
|
// transactions in the same runloop turn (visible in the console
|
||||||
|
// as "Invalid attempt to open a new transaction during CA
|
||||||
|
// commit") — letting the popover's dismissal finish first avoids it.
|
||||||
|
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Swapped into the real sidebar's content slot (search field, collections
|
||||||
|
/// tree, account footer) while Settings is open — same sidebar, different
|
||||||
|
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||||
|
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||||
|
/// back.
|
||||||
|
struct SettingsSidebarList: View {
|
||||||
|
@Binding var selection: SettingsSection?
|
||||||
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Settings")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
List(SettingsSection.allCases, selection: $selection) { section in
|
||||||
|
Label(section.title, systemImage: section.icon)
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Done", action: onDone)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -2,49 +2,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
private enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
/// Settings *detail* content for one section — the section list itself now
|
||||||
case appearance, account, offlineSync, about
|
/// lives in `ContentView_macOS`'s real sidebar (swapped in over the
|
||||||
|
/// collections tree while `AppNavigation.isShowingSettings` is set, not a
|
||||||
var id: String { rawValue }
|
/// separate mini sidebar of its own), so this view only ever renders
|
||||||
|
/// whichever section is currently selected.
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .appearance: return "Appearance"
|
|
||||||
case .account: return "Account"
|
|
||||||
case .offlineSync: return "Offline & Sync"
|
|
||||||
case .about: return "About"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .appearance: return "paintbrush"
|
|
||||||
case .account: return "person.crop.circle"
|
|
||||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
|
||||||
case .about: return "info.circle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-page Settings — swapped into `ContentView_macOS`'s detail pane (see
|
|
||||||
/// `AppNavigation`), alongside the sidebar, not a separate popup window.
|
|
||||||
/// Replaces the old `Settings {}` scene / `PreferencesView` and folds in
|
|
||||||
/// what used to be the standalone "About Outpost" window's content too, so
|
|
||||||
/// everything about the app lives in one place.
|
|
||||||
///
|
|
||||||
/// Own section list + single-section detail (same shape as macOS System
|
|
||||||
/// Settings) rather than a page of stacked/gridded cards — cards of visibly
|
|
||||||
/// different heights never sit next to each other for comparison this way,
|
|
||||||
/// and the layout holds up at any window size or aspect ratio without
|
|
||||||
/// needing to reflow a grid.
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
let onDone: () -> Void
|
let section: SettingsSection
|
||||||
|
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
@State private var selectedSection: SettingsSection? = .appearance
|
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
@State private var storageSummary: CacheStorageSummary?
|
@State private var storageSummary: CacheStorageSummary?
|
||||||
@State private var pendingOperations: [PendingOperationSummary] = []
|
@State private var pendingOperations: [PendingOperationSummary] = []
|
||||||
@@ -54,48 +23,19 @@ struct SettingsView: View {
|
|||||||
@State private var lastFlushSummary: SyncFlushSummary?
|
@State private var lastFlushSummary: SyncFlushSummary?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
|
||||||
header
|
|
||||||
Divider()
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
sectionList
|
|
||||||
.frame(width: 190)
|
|
||||||
Divider()
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
sectionDetail(selectedSection ?? .appearance)
|
sectionDetail
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(.background)
|
.background(.background)
|
||||||
.task { await refreshSyncState() }
|
.task { await refreshSyncState() }
|
||||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
HStack {
|
|
||||||
Text("Settings")
|
|
||||||
.font(.title2.bold())
|
|
||||||
Spacer()
|
|
||||||
Button("Done", action: onDone)
|
|
||||||
.keyboardShortcut(.cancelAction)
|
|
||||||
}
|
|
||||||
.padding(20)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sectionList: some View {
|
|
||||||
List(SettingsSection.allCases, selection: $selectedSection) { section in
|
|
||||||
Label(section.title, systemImage: section.icon)
|
|
||||||
.tag(section)
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sectionDetail(_ section: SettingsSection) -> some View {
|
private var sectionDetail: some View {
|
||||||
switch section {
|
switch section {
|
||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .account: accountDetail
|
case .account: accountDetail
|
||||||
@@ -104,7 +44,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sectionHeader(_ section: SettingsSection) -> some View {
|
private var sectionHeader: some View {
|
||||||
Text(section.title)
|
Text(section.title)
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
}
|
}
|
||||||
@@ -113,7 +53,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var appearanceDetail: some View {
|
private var appearanceDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
sectionHeader(.appearance)
|
sectionHeader
|
||||||
Picker("Appearance", selection: $appearance) {
|
Picker("Appearance", selection: $appearance) {
|
||||||
ForEach(AppAppearance.allCases) { option in
|
ForEach(AppAppearance.allCases) { option in
|
||||||
Text(option.label).tag(option)
|
Text(option.label).tag(option)
|
||||||
@@ -129,7 +69,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var accountDetail: some View {
|
private var accountDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
sectionHeader(.account)
|
sectionHeader
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
labeledRow("Signed in as", session.userName ?? "—")
|
labeledRow("Signed in as", session.userName ?? "—")
|
||||||
if let email = session.userEmail {
|
if let email = session.userEmail {
|
||||||
@@ -151,7 +91,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var offlineSyncDetail: some View {
|
private var offlineSyncDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
sectionHeader(.offlineSync)
|
sectionHeader
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||||
@@ -312,7 +252,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var aboutDetail: some View {
|
private var aboutDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
sectionHeader(.about)
|
sectionHeader
|
||||||
AboutInfoView()
|
AboutInfoView()
|
||||||
.frame(maxWidth: 420, alignment: .leading)
|
.frame(maxWidth: 420, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,26 @@ struct ContentView_macOS: View {
|
|||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
|
Group {
|
||||||
|
if navigation.isShowingSettings {
|
||||||
|
SettingsSidebarList(
|
||||||
|
selection: selectedSettingsSectionBinding,
|
||||||
|
onDone: { navigation.isShowingSettings = false }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SidebarSearchField(text: $globalSearchQuery)
|
SidebarSearchField(text: $globalSearchQuery)
|
||||||
Divider()
|
Divider()
|
||||||
@@ -39,6 +57,8 @@ struct ContentView_macOS: View {
|
|||||||
sidebar
|
sidebar
|
||||||
AccountFooter()
|
AccountFooter()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||||
} detail: {
|
} detail: {
|
||||||
detail
|
detail
|
||||||
@@ -268,10 +288,11 @@ struct ContentView_macOS: View {
|
|||||||
private var detail: some View {
|
private var detail: some View {
|
||||||
if navigation.isShowingSettings {
|
if navigation.isShowingSettings {
|
||||||
// Deliberately not a `NavigationStack` destination or a separate
|
// Deliberately not a `NavigationStack` destination or a separate
|
||||||
// window — it's its own page swapped into the same detail pane
|
// window — it's swapped into the same detail pane Home/
|
||||||
// Home/collections use, so the sidebar (and the ability to just
|
// collections use, alongside the real sidebar (now showing
|
||||||
// click something else in it) stays available while it's open.
|
// `SettingsSidebarList` instead of the collections tree) rather
|
||||||
SettingsView(onDone: { navigation.isShowingSettings = false })
|
// than a page with its own nested mini sidebar.
|
||||||
|
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
||||||
} else if let apiClient = session.apiClient {
|
} 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.
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// Cross-cutting UI state that doesn't belong to any one screen — currently
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
/// just "is Settings showing." Lives at `RootView` and is read wherever
|
case appearance, account, offlineSync, about
|
||||||
/// something needs to open Settings (the profile menu) or render it (RootView
|
|
||||||
/// itself, as a full-window overlay rather than a separate popup window —
|
var id: String { rawValue }
|
||||||
/// `openSettings()`'s `Settings {}` scene doesn't offer that).
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .appearance: return "Appearance"
|
||||||
|
case .account: return "Account"
|
||||||
|
case .offlineSync: return "Offline & Sync"
|
||||||
|
case .about: return "About"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .appearance: return "paintbrush"
|
||||||
|
case .account: return "person.crop.circle"
|
||||||
|
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||||
|
case .about: return "info.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
|
||||||
|
/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach
|
||||||
|
/// it) and is read wherever something needs to open Settings (the profile
|
||||||
|
/// menu) or render it — as a swap of the *existing* sidebar/detail panes in
|
||||||
|
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
|
||||||
|
/// the sidebar.
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AppNavigation {
|
final class AppNavigation {
|
||||||
var isShowingSettings = false
|
var isShowingSettings = false
|
||||||
|
var selectedSettingsSection: SettingsSection? = .appearance
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user