From 7c5e1ffa6a7cff5393763b585821b66585662fe3 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 19 Sep 2026 11:13:08 -0700 Subject: [PATCH] Reorganize Settings sidebar taxonomy --- Packages/macOS/CmuxSettingsUI/Package.swift | 3 + .../Navigation/SettingsTaxonomy.swift | 98 +++++ .../Resources/Localizable.xcstrings | 412 ++++++++++++++++++ .../Scene/SettingsWindowScene.swift | 51 ++- .../SettingsTaxonomyTests.swift | 58 +++ 5 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsTaxonomy.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings create mode 100644 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsTaxonomyTests.swift diff --git a/Packages/macOS/CmuxSettingsUI/Package.swift b/Packages/macOS/CmuxSettingsUI/Package.swift index 701b7f470db3..b41c82119770 100644 --- a/Packages/macOS/CmuxSettingsUI/Package.swift +++ b/Packages/macOS/CmuxSettingsUI/Package.swift @@ -25,6 +25,9 @@ let package = Package( "CMUXMobileCore", .product(name: "CmuxFoundation", package: "CmuxFoundation"), .product(name: "CmuxSettings", package: "CmuxSettings"), + ], + resources: [ + .process("Resources"), ] ), .testTarget( diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsTaxonomy.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsTaxonomy.swift new file mode 100644 index 000000000000..133037a71277 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsTaxonomy.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Presentation-only grouping for the Settings sidebar. +/// +/// The leaves stay as the existing ``SettingsSectionID`` values so +/// persisted selection, settings-open targets, search entry IDs, scroll +/// anchors, and section implementations keep their current identities. +enum SettingsTaxonomyGroup: String, CaseIterable, Identifiable, Sendable { + case general + case terminal + case workspace + case sidebarAndDock + case agentsAndAutomation + case browserAndFiles + case remoteAndDevices + case keyboardAndAdvanced + + /// Stable identity for SwiftUI section iteration. + var id: Self { self } + + /// Localized heading shown above this group in the default browse sidebar. + var title: String { + switch self { + case .general: + return String( + localized: "settings.taxonomy.general", + defaultValue: "General", + bundle: .module + ) + case .terminal: + // Reuse the existing app catalog entry for the shared label. + return SettingsSectionID.terminal.title + case .workspace: + return String( + localized: "settings.taxonomy.workspace", + defaultValue: "Workspace", + bundle: .module + ) + case .sidebarAndDock: + return String( + localized: "settings.taxonomy.sidebarAndDock", + defaultValue: "Sidebar & Dock", + bundle: .module + ) + case .agentsAndAutomation: + return String( + localized: "settings.taxonomy.agentsAndAutomation", + defaultValue: "Agents & Automation", + bundle: .module + ) + case .browserAndFiles: + return String( + localized: "settings.taxonomy.browserAndFiles", + defaultValue: "Browser & Files", + bundle: .module + ) + case .remoteAndDevices: + return String( + localized: "settings.taxonomy.remoteAndDevices", + defaultValue: "Remote & Devices", + bundle: .module + ) + case .keyboardAndAdvanced: + return String( + localized: "settings.taxonomy.keyboardAndAdvanced", + defaultValue: "Keyboard & Advanced", + bundle: .module + ) + } + } + + /// Existing navigation leaves shown under this presentation category. + var sections: [SettingsSectionID] { + switch self { + case .general: + return [.account, .app, .sleepyMode] + case .terminal: + return [.terminal, .textBox] + case .workspace: + return [.workspaceColors] + case .sidebarAndDock: + return [.sidebarAppearance, .customSidebars] + case .agentsAndAutomation: + return [.automation, .computerUse] + case .browserAndFiles: + return [.browser, .browserImport] + case .remoteAndDevices: + return [.mobile, .cloudMachines, .networking] + case .keyboardAndAdvanced: + return [.globalHotkey, .keyboardShortcuts, .betaFeatures, .settingsJSON, .reset] + } + } + + /// All existing navigation leaves in the order used by grouped browsing. + static var sectionsInDisplayOrder: [SettingsSectionID] { + allCases.flatMap(\.sections) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..ca11166a09ec --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -0,0 +1,412 @@ +{ + "sourceLanguage": "en", + "strings": { + "settings.taxonomy.general": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes generales" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Général" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일반" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "常规" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + } + } + }, + "settings.taxonomy.workspace": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + } + } + }, + "settings.taxonomy.sidebarAndDock": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشريط الجانبي وDock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste & Dock" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar & Dock" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Barra lateral y Dock" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Barre latérale et Dock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーと Dock" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 및 Dock" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏与 Dock" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄與 Dock" + } + } + } + }, + "settings.taxonomy.agentsAndAutomation": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوكلاء والأتمتة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Agenten & Automatisierung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agents & Automation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agentes y automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Agents et automatisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントと自動化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 및 자동화" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理与自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式與自動化" + } + } + } + }, + "settings.taxonomy.browserAndFiles": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المتصفح والملفات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser & Dateien" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser & Files" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Navegador y archivos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Navigateur et fichiers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザとファイル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 및 파일" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器与文件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器與檔案" + } + } + } + }, + "settings.taxonomy.remoteAndDevices": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأجهزة والاتصال عن بُعد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote & Geräte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote & Devices" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Remoto y dispositivos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À distance et appareils" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートとデバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 및 기기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程与设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端與裝置" + } + } + } + }, + "settings.taxonomy.keyboardAndAdvanced": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لوحة المفاتيح والإعدادات المتقدمة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastatur & Erweitertes" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keyboard & Advanced" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Teclado y opciones avanzadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clavier et options avancées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボードと詳細設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키보드 및 고급" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘与高级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鍵盤與進階" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 9add975216b5..30ff1c7ccb6c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -246,6 +246,7 @@ public struct SettingsWindowRoot: View { } } + /// Shows grouped browse categories until search is active, then preserves the flat ranked result list. @ViewBuilder private var sidebar: some View { List(selection: sidebarSelectionBinding) { @@ -253,14 +254,26 @@ public struct SettingsWindowRoot: View { if matches.isEmpty { Text(String(localized: "settings.search.noResults", defaultValue: "No Results")) .foregroundStyle(.secondary) - } else { + } else if isSearching { + // Search stays flat and relevance-ranked. Taxonomy only + // reorganizes the default browse view, so existing setting + // hit IDs, row anchors, and deep-link selection semantics + // remain unchanged while a query is active. ForEach(matches) { entry in - SettingsSidebarEntryRow( - title: entry.title, - symbolName: entry.symbolName, - subtitle: subtitle(for: entry) - ) - .tag(entry.id) + sidebarEntryRow(entry) + } + } else { + ForEach(SettingsTaxonomyGroup.allCases) { group in + let groupEntries = taxonomyEntries(for: group, from: matches) + if !groupEntries.isEmpty { + Section { + ForEach(groupEntries) { entry in + sidebarEntryRow(entry) + } + } header: { + Text(group.title) + } + } } } } @@ -270,6 +283,30 @@ public struct SettingsWindowRoot: View { .navigationSplitViewColumnWidth(210) } + /// Renders one existing search-index entry as a selectable sidebar leaf. + @ViewBuilder + private func sidebarEntryRow(_ entry: SettingsSearchIndex.Entry) -> some View { + SettingsSidebarEntryRow( + title: entry.title, + symbolName: entry.symbolName, + subtitle: subtitle(for: entry) + ) + .tag(entry.id) + } + + /// Returns the existing section entries in taxonomy order without + /// changing their ids or targets. Runtime visibility filtering happens + /// before this step, so unavailable leaves simply disappear from their + /// group while the remaining destinations keep their stable identities. + private func taxonomyEntries( + for group: SettingsTaxonomyGroup, + from entries: [SettingsSearchIndex.Entry] + ) -> [SettingsSearchIndex.Entry] { + group.sections.compactMap { section in + entries.first { $0.id == sectionEntryID(for: section) } + } + } + func sidebarEntries(matching query: String) -> [SettingsSearchIndex.Entry] { searchIndex.match(query) } /// Legacy `SettingsSearchEntry` populates `subtitle` with the diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsTaxonomyTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsTaxonomyTests.swift new file mode 100644 index 000000000000..302408b8342b --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsTaxonomyTests.swift @@ -0,0 +1,58 @@ +import CmuxSettings +import Testing +@testable import CmuxSettingsUI + +@Suite("Settings taxonomy") +struct SettingsTaxonomyTests { + /// Guards against duplicate or missing destinations when taxonomy changes. + @Test func everyNavigationLeafAppearsExactlyOnce() { + let sections = SettingsTaxonomyGroup.sectionsInDisplayOrder + + #expect(sections.count == SettingsSectionID.allCases.count) + #expect(Set(sections).count == sections.count) + #expect(Set(sections) == Set(SettingsSectionID.allCases)) + } + + /// Locks the intended concept-to-leaf mapping for the browse sidebar. + @Test func groupsFollowTheBrowseTaxonomy() { + #expect(SettingsTaxonomyGroup.general.sections == [.account, .app, .sleepyMode]) + #expect(SettingsTaxonomyGroup.terminal.sections == [.terminal, .textBox]) + #expect(SettingsTaxonomyGroup.workspace.sections == [.workspaceColors]) + #expect(SettingsTaxonomyGroup.sidebarAndDock.sections == [.sidebarAppearance, .customSidebars]) + #expect(SettingsTaxonomyGroup.agentsAndAutomation.sections == [.automation, .computerUse]) + #expect(SettingsTaxonomyGroup.browserAndFiles.sections == [.browser, .browserImport]) + #expect(SettingsTaxonomyGroup.remoteAndDevices.sections == [.mobile, .cloudMachines, .networking]) + #expect( + SettingsTaxonomyGroup.keyboardAndAdvanced.sections + == [.globalHotkey, .keyboardShortcuts, .betaFeatures, .settingsJSON, .reset] + ) + } + + /// Verifies grouping leaves the search/deep-link section identities intact. + @Test func taxonomyKeepsExistingSectionSearchIdentities() throws { + let index = SettingsSearchIndex(catalog: SettingCatalog(), curatedEntries: []) + let sectionEntries = index.match("") + let entriesByID = Dictionary(uniqueKeysWithValues: sectionEntries.map { ($0.id, $0) }) + + // Search keeps its original declaration order and stable IDs. + // Taxonomy is only the empty-query browse presentation. + #expect( + sectionEntries.map(\.id) + == SettingsSectionID.allCases.map { "section:\($0.rawValue)" } + ) + + for section in SettingsTaxonomyGroup.sectionsInDisplayOrder { + let entryID = "section:\(section.rawValue)" + let entry = try #require(entriesByID[entryID]) + #expect(entry.anchorID == entryID) + #expect(entry.kind == .section) + } + } + + /// Ensures every visible taxonomy header resolves to nonempty localized copy. + @Test func everyGroupHasALocalizedTitle() { + for group in SettingsTaxonomyGroup.allCases { + #expect(!group.title.isEmpty) + } + } +}