diff --git a/App/Features/Settings/SettingsRootView.swift b/App/Features/Settings/SettingsRootView.swift index f8fd8a7..44620fb 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -12,19 +12,43 @@ import SwiftUI +/// The Settings tabs, as a selectable identity. +/// +/// Exists so a caller outside the Settings scene can say *which* pane it wants +/// (GitHub #45). `SettingsLink` opens the window and cannot address a tab, so +/// the sidebar's "Integrations" row stores the target here first and the scene +/// opens on it. +enum SettingsTab: String, CaseIterable, Hashable { + case linkedAccounts + case account + case preferences + case blockedAndMuted + case notifications + case security + case devices + case documentSync + case crashReporting +} + struct SettingsRootView: View { + /// The open tab, persisted so it survives the window closing — which is + /// also what lets another surface preselect it before opening. + @AppStorage("settings.selectedTab") private var selectedTab: SettingsTab = .linkedAccounts + var body: some View { - TabView { + TabView(selection: $selectedTab) { LinkedAccountsView() .tabItem { Label("Linked accounts", systemImage: "link") } + .tag(SettingsTab.linkedAccounts) AccountSettingsView() .tabItem { Label("Account", systemImage: "person.crop.circle") } + .tag(SettingsTab.account) // Server-synced account preferences (work-consolidation.md — settings // storage) via `POST /api/user/update`. @@ -32,6 +56,7 @@ struct SettingsRootView: View { .tabItem { Label("Preferences", systemImage: "slider.horizontal.3") } + .tag(SettingsTab.preferences) // Web-parity (work-consolidation.md G2) — blocked / muted account // management with inline unblock / unmute. @@ -39,18 +64,21 @@ struct SettingsRootView: View { .tabItem { Label("Blocked & Muted", systemImage: "hand.raised") } + .tag(SettingsTab.blockedAndMuted) // Server-driven notification event catalogue (work-consolidation.md G18). NotificationPreferencesView() .tabItem { Label("Notifications", systemImage: "bell") } + .tag(SettingsTab.notifications) // Active sessions with per-row revoke (work-consolidation.md G19). SecuritySessionsView() .tabItem { Label("Security", systemImage: "lock.shield") } + .tag(SettingsTab.security) // Synced-settings device registry (work-consolidation.md G17) — the // machines registered under this app's key. @@ -58,6 +86,7 @@ struct SettingsRootView: View { .tabItem { Label("Devices", systemImage: "desktopcomputer") } + .tag(SettingsTab.devices) // Document sync agent (work-consolidation.md §3b) — enable the background helper // that mirrors documents to a local folder for Obsidian. @@ -65,6 +94,7 @@ struct SettingsRootView: View { .tabItem { Label("Document Sync", systemImage: "arrow.triangle.2.circlepath") } + .tag(SettingsTab.documentSync) // Crash reporting (GitHub issue #29) — opt in to being asked, on // the launch after a crash, whether to file a GitHub issue. A @@ -74,6 +104,7 @@ struct SettingsRootView: View { .tabItem { Label("Crash Reporting", systemImage: "ladybug") } + .tag(SettingsTab.crashReporting) } .frame(width: 620, height: 520) } diff --git a/App/Navigation/MainWindowView.swift b/App/Navigation/MainWindowView.swift index 93eb11c..fd88af2 100644 --- a/App/Navigation/MainWindowView.swift +++ b/App/Navigation/MainWindowView.swift @@ -59,6 +59,44 @@ struct MainWindowView: View { @State private var selection: SidebarSection? = .timeline + /// Whether the Profile group is open. Persisted so the choice survives a + /// relaunch — a group that silently re-collapses every launch is a group the + /// user stops trusting. + /// + /// Defaults to expanded: on first run the three rows that moved into it were + /// previously top-level, and collapsing them by default would look like they + /// had been removed. + @AppStorage("sidebar.profileGroup.expanded") private var isProfileGroupExpanded = true + + /// The Settings tab the scene should open on. Shares its key with + /// `SettingsRootView`, which is how a sidebar row preselects a pane that + /// `SettingsLink` itself cannot address. + @AppStorage("settings.selectedTab") private var settingsTab: SettingsTab = .linkedAccounts + + /// Selects a sidebar section, opening the Profile group first when the + /// target lives inside it. + /// + /// Every deep link goes through here (GitHub #45). A menu command or a + /// notification that lands on Organizations while the group is collapsed + /// would otherwise select a row the user cannot see — the detail pane + /// changes and the sidebar appears to have ignored them. + /// + /// Expanding is deliberately one-way: a deep link to a top-level row does + /// **not** collapse the group, because the user's own choice to keep it open + /// is not something a menu command should undo. + private func select(_ section: SidebarSection) { + if Self.profileGroupSections.contains(section) { + isProfileGroupExpanded = true + } + selection = section + } + + /// The rows that live inside the Profile group. + /// + /// One list, used by both the group's contents and `select(_:)`, so a row + /// cannot be moved into the group without deep links learning about it. + static let profileGroupSections: Set = [.profile, .organizations, .connections] + // M7 — Data Export sheet state. `pendingExportType` is set by each // `ExportMenuCommands` notification before `showExportSheet` is flipped // so `ExportView` receives the right initial type and can auto-start @@ -109,15 +147,54 @@ struct MainWindowView: View { Label(SidebarSection.documents.rawValue, systemImage: SidebarSection.documents.systemImage) .tag(SidebarSection.documents) .foregroundStyle(ILColor.onMasthead) - Label(SidebarSection.organizations.rawValue, systemImage: SidebarSection.organizations.systemImage) - .tag(SidebarSection.organizations) - .foregroundStyle(ILColor.onMasthead) - Label(SidebarSection.profile.rawValue, systemImage: SidebarSection.profile.systemImage) - .tag(SidebarSection.profile) - .foregroundStyle(ILColor.onMasthead) - Label(SidebarSection.connections.rawValue, systemImage: SidebarSection.connections.systemImage) - .tag(SidebarSection.connections) - .foregroundStyle(ILColor.onMasthead) + // The account-shaped rows, grouped (GitHub #45 / G31). On the + // web these live inside the profile/account menu; macOS had ten + // flat peers with no account grouping at all. + // + // Settings is an *additional* entrance, never a relocation: the + // panes stay in the `⌘,` scene, because macOS convention is that + // Settings is its own window and re-homing it into the detail + // column would break both that expectation and the keyboard + // shortcut users already have. + DisclosureGroup(isExpanded: $isProfileGroupExpanded) { + Label(SidebarSection.profile.rawValue, systemImage: SidebarSection.profile.systemImage) + .tag(SidebarSection.profile) + .foregroundStyle(ILColor.onMasthead) + Label(SidebarSection.organizations.rawValue, systemImage: SidebarSection.organizations.systemImage) + .tag(SidebarSection.organizations) + .foregroundStyle(ILColor.onMasthead) + Label(SidebarSection.connections.rawValue, systemImage: SidebarSection.connections.systemImage) + .tag(SidebarSection.connections) + .foregroundStyle(ILColor.onMasthead) + // `SettingsLink` opens the real Settings scene, so `⌘,`, + // File ▸ Settings and this row are three doors into one + // window rather than two different Settings. + SettingsLink { + Label("Settings", systemImage: "gearshape") + .foregroundStyle(ILColor.onMasthead) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + // Land on Preferences rather than on whichever tab was + // open last: a row labelled "Settings" that opens + // Crash Reporting reads as a bug. + settingsTab = .preferences + }) + + // `SettingsLink` opens the window but cannot address a tab, + // so the target is stored first and the scene opens on it. + SettingsLink { + Label("Integrations", systemImage: "app.connected.to.app.below.fill") + .foregroundStyle(ILColor.onMasthead) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + settingsTab = .linkedAccounts + }) + } label: { + Label("Profile", systemImage: "person.crop.circle") + .foregroundStyle(ILColor.onMasthead) + } } .listStyle(.sidebar) .scrollContentBackground(.hidden) @@ -152,20 +229,20 @@ struct MainWindowView: View { // selection without the menu commands needing to know about // the view tree. .onReceive(NotificationCenter.default.publisher(for: .notificationsShow)) { _ in - selection = .notifications + select(.notifications) } // Web-parity (work-consolidation.md G5) — the ⌘F menu command posts // `.searchShow`; switch the sidebar to Search, then re-post // `.searchFocus` so the field takes focus once it is on screen. .onReceive(NotificationCenter.default.publisher(for: .searchShow)) { _ in - selection = .search + select(.search) NotificationCenter.default.post(name: .searchFocus, object: nil) } // A document search hit has no typed deep-link target, so the // search view routes here to land the user on the Documents // section. .onReceive(NotificationCenter.default.publisher(for: .searchShowDocuments)) { _ in - selection = .documents + select(.documents) } // Direct Messages (work-consolidation.md G1) — the ⌥⌘M menu command and the // profile "Message" button post `.directMessagesShow` to route the @@ -173,7 +250,7 @@ struct MainWindowView: View { // may accompany it is observed by `DirectMessagesRootView` itself, // which selects the target conversation once it is on screen. .onReceive(NotificationCenter.default.publisher(for: .directMessagesShow)) { _ in - selection = .messages + select(.messages) } // M5.x — System notification banner deep-link. `AppDelegate` posts // `.notificationDeepLink` with the resolved `NotificationTarget` as @@ -185,30 +262,30 @@ struct MainWindowView: View { guard let target = note.object as? NotificationTarget else { return } switch target { case .message(let id): - selection = .timeline + select(.timeline) pendingMessageDeepLinkID = id case .list: - selection = .lists + select(.lists) case .user: // The user who acted is shown in the Connections panel // (followers / following / requests roster). - selection = .connections + select(.connections) case .organization: - selection = .organizations + select(.organizations) case .unknown: // No typed target — fall back to the notifications tray // so the user sees the item that triggered the banner. - selection = .notifications + select(.notifications) } } .onReceive(NotificationCenter.default.publisher(for: .socialShowFollowers)) { _ in - selection = .connections + select(.connections) } .onReceive(NotificationCenter.default.publisher(for: .socialShowFollowing)) { _ in - selection = .connections + select(.connections) } .onReceive(NotificationCenter.default.publisher(for: .socialShowRequests)) { _ in - selection = .connections + select(.connections) } // M7 — Export menu commands. Each notification maps to one ExportType; // the sheet auto-starts the corresponding download on appear. @@ -247,7 +324,7 @@ struct MainWindowView: View { ) { if let parsed = pendingShare { ResolveShareView(parsed: parsed, environment: environment) { _ in - selection = parsed.kind == .list ? .lists : .documents + select(parsed.kind == .list ? .lists : .documents) } } } diff --git a/AppTests/SidebarProfileGroupTests.swift b/AppTests/SidebarProfileGroupTests.swift new file mode 100644 index 0000000..29ce24b --- /dev/null +++ b/AppTests/SidebarProfileGroupTests.swift @@ -0,0 +1,70 @@ +// SidebarProfileGroupTests +// +// The sidebar's Profile group (GitHub #45 / G31). +// +// The view itself is not rendered — the project's rule is that SwiftUI views are +// verified by the build and by hand. What is tested here is the part that has +// real logic and a real failure mode: which rows belong to the group, and the +// rule that a deep link into a collapsed group must open it. +// +// That rule is the one worth guarding. Without it, a menu command that selects +// Organizations while the group is shut changes the detail pane while the +// sidebar appears to have ignored the user entirely. + +import XCTest +@testable import InterlinedList + +final class SidebarProfileGroupTests: XCTestCase { + + // MARK: - Happy path + + func test_givenTheProfileGroup_whenListingItsRows_thenItHoldsTheAccountShapedOnes() { + XCTAssertEqual( + MainWindowView.profileGroupSections, + [.profile, .organizations, .connections] + ) + } + + func test_givenATopLevelSection_whenAskingIfItIsGrouped_thenItIsNot() { + // The reading surfaces stay top level. Grouping Messages or Lists under + // an account menu would be the web's information architecture applied + // where it does not fit. + for section in [SidebarSection.timeline, .lists, .documents, .messages, .notifications, .search, .scheduled] { + XCTAssertFalse( + MainWindowView.profileGroupSections.contains(section), + "\(section.rawValue) should stay a top-level row" + ) + } + } + + // MARK: - Boundary — every section is accounted for + + func test_givenEverySidebarSection_whenPartitioned_thenNoneIsOrphaned() { + // A new section added without a decision about where it goes should show + // up here rather than silently landing top-level. + let grouped = MainWindowView.profileGroupSections + let topLevel = Set(SidebarSection.allCases).subtracting(grouped) + XCTAssertEqual(grouped.count + topLevel.count, SidebarSection.allCases.count) + XCTAssertEqual(grouped.count, 3) + XCTAssertEqual(topLevel.count, 7) + } + + // MARK: - Settings tabs are addressable + + func test_givenTheSettingsTabs_whenAddressed_thenTheirIdentitiesAreStable() { + // The raw values are persisted in `@AppStorage`, so renaming a case + // silently sends an existing user to a different pane on next launch. + XCTAssertEqual(SettingsTab.linkedAccounts.rawValue, "linkedAccounts") + XCTAssertEqual(SettingsTab.preferences.rawValue, "preferences") + XCTAssertEqual(SettingsTab.allCases.count, 9) + } + + func test_givenASettingsTabValue_whenRoundTripped_thenItSurvivesStorage() { + // `@AppStorage` round-trips a `RawRepresentable` through its raw value; + // an unreadable stored value has to fall back rather than crash. + for tab in SettingsTab.allCases { + XCTAssertEqual(SettingsTab(rawValue: tab.rawValue), tab) + } + XCTAssertNil(SettingsTab(rawValue: "a-tab-that-was-renamed")) + } +}