diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1ec895ee065a..ccd0ccdb47c1 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -97,6 +97,8 @@ A50019A0 /* SettingsNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019A1 /* SettingsNavigation.swift */; }; A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; }; A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; }; + B37A00000000000000000001 /* BetaFeaturesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000002 /* BetaFeaturesSettingsView.swift */; }; + B37A00000000000000000007 /* SettingsCardNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000008 /* SettingsCardNote.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; C0DE35010000000000000001 /* SidebarScrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35010000000000000002 /* SidebarScrim.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; @@ -105,6 +107,7 @@ D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */; }; + B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610008EA5E3744DDBE05C8E2 /* InternalTabDragConfiguration.swift */; }; @@ -125,6 +128,7 @@ 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; 2F0C06000000000000000002 /* MainWindowVisibilityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */; }; 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C05000000000000000001 /* MainWindowFocusController.swift */; }; + B37A00000000000000000005 /* MainWindowFocusTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000006 /* MainWindowFocusTypes.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; D7AB00000000000000000013 /* TabManager+DetachedWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000014 /* TabManager+DetachedWorkspace.swift */; }; @@ -343,6 +347,7 @@ FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; + B37A00000000000000000009 /* FileExplorerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000A /* FileExplorerState.swift */; }; FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; FE001103 /* FileExplorerSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001003 /* FileExplorerSearchController.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; @@ -351,6 +356,7 @@ D7AB0000000000000000000F /* AppDelegateMoveTabToNewWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000010 /* AppDelegateMoveTabToNewWorkspaceTests.swift */; }; FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; + B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A00000000000000000004 /* RightSidebarMode+Availability.swift */; }; FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; FE003104 /* SessionIndexStore+CodexSQL.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003004 /* SessionIndexStore+CodexSQL.swift */; }; FE003105 /* RovoDevIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003005 /* RovoDevIndex.swift */; }; @@ -460,6 +466,7 @@ D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketWriteTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; + B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStateModePersistenceTests.swift; sourceTree = ""; }; 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthSettingsStore.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; 17C9F5BA0DD14EDC8C3E5002 /* MainWindowVisibilityControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowVisibilityControllerTests.swift; sourceTree = ""; }; @@ -485,6 +492,8 @@ B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeGeometryReporting.swift; sourceTree = ""; }; B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChromeMetrics.swift; sourceTree = ""; }; B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeStyle.swift; sourceTree = ""; }; + B37A00000000000000000004 /* RightSidebarMode+Availability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarMode+Availability.swift"; sourceTree = ""; }; + B37A00000000000000000006 /* MainWindowFocusTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowFocusTypes.swift; sourceTree = ""; }; A11EAB000000000000000001 /* AppearanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettings.swift; sourceTree = ""; }; A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalAppearance.swift; sourceTree = ""; }; A11EAD000000000000000001 /* WorkspaceAppearanceResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceAppearanceResolution.swift; sourceTree = ""; }; @@ -499,6 +508,8 @@ A50019A1 /* SettingsNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigation.swift; sourceTree = ""; }; A50019B1 /* SettingsSearchAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchAliases.swift; sourceTree = ""; }; A50019B3 /* SettingsSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndexTests.swift; sourceTree = ""; }; + B37A00000000000000000002 /* BetaFeaturesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetaFeaturesSettingsView.swift; sourceTree = ""; }; + B37A00000000000000000008 /* SettingsCardNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCardNote.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; @@ -759,6 +770,7 @@ FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; + B37A0000000000000000000A /* FileExplorerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerState.swift; sourceTree = ""; }; FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; FE001003 /* FileExplorerSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerSearchController.swift; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; @@ -943,6 +955,8 @@ C0DE34020000000000000004 /* CmuxHelpResource.swift */, A50019A1 /* SettingsNavigation.swift */, A50019B1 /* SettingsSearchAliases.swift */, + B37A00000000000000000002 /* BetaFeaturesSettingsView.swift */, + B37A00000000000000000008 /* SettingsCardNote.swift */, A5001012 /* ContentView.swift */, C0DE35010000000000000002 /* SidebarScrim.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, @@ -1040,6 +1054,7 @@ D7AB00000000000000000002 /* AppDelegate+MoveTabToNewWorkspace.swift */, 2907A0022907A0022907A002 /* AppDelegate+RecoverableMainWindowRoutes.swift */, C10D00020000000000000002 /* CloudVMActionLauncher.swift */, + B37A00000000000000000006 /* MainWindowFocusTypes.swift */, 2F0C05000000000000000001 /* MainWindowFocusController.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -1100,6 +1115,7 @@ A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxActionTrust.swift */, FE001001 /* FileExplorerStore.swift */, + B37A0000000000000000000A /* FileExplorerState.swift */, FE001003 /* FileExplorerSearchController.swift */, FE001002 /* FileExplorerView.swift */, FE003001 /* SessionIndexStore.swift */, @@ -1111,6 +1127,7 @@ B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */, B7F9A605B7F9A605B7F9A605 /* RightSidebarChromeStyle.swift */, B7F9A603B7F9A603B7F9A603 /* WindowChromeMetrics.swift */, + B37A00000000000000000004 /* RightSidebarMode+Availability.swift */, FE003003 /* RightSidebarPanelView.swift */, D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */, D0C0D0C0D0C0D0C0D0C0D004 /* DockEmptyView.swift */, @@ -1219,6 +1236,7 @@ A11EAA000000000000000001 /* AppearanceSettingsTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */, + B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, @@ -1507,6 +1525,8 @@ C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */, A50019A0 /* SettingsNavigation.swift in Sources */, A50019B0 /* SettingsSearchAliases.swift in Sources */, + B37A00000000000000000001 /* BetaFeaturesSettingsView.swift in Sources */, + B37A00000000000000000007 /* SettingsCardNote.swift in Sources */, A5001002 /* ContentView.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, @@ -1603,6 +1623,7 @@ D7AB00000000000000000001 /* AppDelegate+MoveTabToNewWorkspace.swift in Sources */, 2907A0012907A0012907A001 /* AppDelegate+RecoverableMainWindowRoutes.swift in Sources */, C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */, + B37A00000000000000000005 /* MainWindowFocusTypes.swift in Sources */, 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, @@ -1663,6 +1684,7 @@ A5001652 /* CmuxConfigExecutor.swift in Sources */, A5001654 /* CmuxActionTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, + B37A00000000000000000009 /* FileExplorerState.swift in Sources */, FE001103 /* FileExplorerSearchController.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, FE003101 /* SessionIndexStore.swift in Sources */, @@ -1674,6 +1696,7 @@ B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B7F9A602B7F9A602B7F9A602 /* WindowChromeMetrics.swift in Sources */, + B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, FE003103 /* RightSidebarPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */, @@ -1819,6 +1842,7 @@ A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, + B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, C2B6A97D1F2E4C71A8B9D001 /* BrowserOmnibarPerformanceSupportTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fc40b0287364..899ac886f992 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -64327,6 +64327,13 @@ } } }, + "settings.betaFeatures.dock": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Dock" } }, "ja": { "stringUnit": { "state": "translated", "value": "Dock" } } } }, + "settings.betaFeatures.dock.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Hides Dock from the right sidebar until you enable it here." } }, "ja": { "stringUnit": { "state": "translated", "value": "ここで有効にするまで、Dock を右サイドバーから隠します。" } } } }, + "settings.betaFeatures.dock.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Shows Dock in the right sidebar mode switcher for custom terminal controls." } }, "ja": { "stringUnit": { "state": "translated", "value": "Dock を右サイドバーのモード切り替えに表示し、カスタム端末コントロールを使えるようにします。" } } } }, + "settings.betaFeatures.feed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Feed" } }, "ja": { "stringUnit": { "state": "translated", "value": "Feed" } } } }, + "settings.betaFeatures.feed.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Hides Feed from the right sidebar until you enable it here." } }, "ja": { "stringUnit": { "state": "translated", "value": "ここで有効にするまで、Feed を右サイドバーから隠します。" } } } }, + "settings.betaFeatures.feed.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Shows Feed in the right sidebar mode switcher for inline agent decisions." } }, "ja": { "stringUnit": { "state": "translated", "value": "Feed を右サイドバーのモード切り替えに表示し、エージェントの判断をインラインで扱えるようにします。" } } } }, + "settings.betaFeatures.warning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "These features are unstable and may change or break. Enable them only when you are testing them." } }, "ja": { "stringUnit": { "state": "translated", "value": "これらの機能は不安定で、変更されたり壊れたりする可能性があります。テストする場合だけ有効にしてください。" } } } }, "settings.browser.enabled": { "extractionState": "manual", "localizations": { @@ -68186,6 +68193,7 @@ } } }, + "settings.section.betaFeatures": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Beta Features" } }, "ja": { "stringUnit": { "state": "translated", "value": "ベータ機能" } } } }, "settings.section.browser": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index b332ccd82399..f3e3b996495d 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -109,6 +109,24 @@ enum TerminalScrollBarSettings { } } +enum RightSidebarBetaFeatureSettings { + static let feedEnabledKey = "rightSidebar.beta.feed.enabled" + static let dockEnabledKey = "rightSidebar.beta.dock.enabled" + + static let defaultFeedEnabled = false + static let defaultDockEnabled = false + + nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled } + return defaults.bool(forKey: feedEnabledKey) + } + + nonisolated static func isDockEnabled(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: dockEnabledKey) != nil else { return defaultDockEnabled } + return defaults.bool(forKey: dockEnabledKey) + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" diff --git a/Sources/BetaFeaturesSettingsView.swift b/Sources/BetaFeaturesSettingsView.swift new file mode 100644 index 000000000000..c051f1706878 --- /dev/null +++ b/Sources/BetaFeaturesSettingsView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct BetaFeaturesSettingsView: View { + @Binding var feedEnabled: Bool + @Binding var dockEnabled: Bool + + private var feedSubtitle: String { + if feedEnabled { + return String( + localized: "settings.betaFeatures.feed.subtitleOn", + defaultValue: "Shows Feed in the right sidebar mode switcher for inline agent decisions." + ) + } + return String( + localized: "settings.betaFeatures.feed.subtitleOff", + defaultValue: "Hides Feed from the right sidebar until you enable it here." + ) + } + + private var dockSubtitle: String { + if dockEnabled { + return String( + localized: "settings.betaFeatures.dock.subtitleOn", + defaultValue: "Shows Dock in the right sidebar mode switcher for custom terminal controls." + ) + } + return String( + localized: "settings.betaFeatures.dock.subtitleOff", + defaultValue: "Hides Dock from the right sidebar until you enable it here." + ) + } + + var body: some View { + SettingsSectionHeader(title: String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features")) + .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .betaFeatures)) + SettingsCard { + BetaFeaturesWarningNote( + String( + localized: "settings.betaFeatures.warning", + defaultValue: "These features are unstable and may change or break. Enable them only when you are testing them." + ) + ) + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), + subtitle: feedSubtitle, + searchAnchorID: SettingsSearchIndex.settingID(for: .betaFeatures, idSuffix: "feed") + ) { + Toggle("", isOn: $feedEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBetaFeedToggle") + .accessibilityLabel( + String(localized: "settings.betaFeatures.feed", defaultValue: "Feed") + ) + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), + subtitle: dockSubtitle, + searchAnchorID: SettingsSearchIndex.settingID(for: .betaFeatures, idSuffix: "dock") + ) { + Toggle("", isOn: $dockEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBetaDockToggle") + .accessibilityLabel( + String(localized: "settings.betaFeatures.dock", defaultValue: "Dock") + ) + } + } + } +} + +private struct BetaFeaturesWarningNote: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index 07f53bf2a895..21190b652e95 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -1,4 +1,4 @@ -import Foundation +import AppKit extension ContentView { static func commandPaletteShortcutAction(forCommandID commandId: String) -> KeyboardShortcutSettings.Action? { @@ -83,7 +83,7 @@ extension ContentView { { _ in value } } - return RightSidebarMode.allCases.map { mode in + return RightSidebarMode.availableModes().map { mode in CommandPaletteCommandContribution( commandId: Self.commandPaletteRightSidebarModeCommandID(mode), title: constant(mode.shortcutAction.label), @@ -108,10 +108,27 @@ extension ContentView { } } + func handleCommandPaletteRightSidebarMode(_ mode: RightSidebarMode, observedWindow: NSWindow?) { + guard mode.isAvailable() else { + NSSound.beep() + return + } + if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: mode, + focusFirstItem: true, + preferredWindow: observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + ) != true { + fileExplorerState.setVisible(true) + if fileExplorerState.mode != mode { + fileExplorerState.mode = mode + } + } + } + private static func commandPaletteRightSidebarModeShortcutAction( forCommandID commandID: String ) -> KeyboardShortcutSettings.Action? { - RightSidebarMode.allCases.first { mode in + RightSidebarMode.availableModes().first { mode in Self.commandPaletteRightSidebarModeCommandID(mode) == commandID }?.shortcutAction } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 15ad8cb4d03d..d55cbcdabeaa 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7882,16 +7882,7 @@ struct ContentView: View { } for mode in RightSidebarMode.allCases { registry.register(commandId: Self.commandPaletteRightSidebarModeCommandID(mode)) { - if AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: mode, - focusFirstItem: true, - preferredWindow: observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - ) != true { - fileExplorerState.setVisible(true) - if fileExplorerState.mode != mode { - fileExplorerState.mode = mode - } - } + handleCommandPaletteRightSidebarMode(mode, observedWindow: observedWindow) } } registry.register(commandId: "palette.toggleMatchTerminalBackground") { diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 0ec9e7f21c9d..8bba56248ee1 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -110,7 +110,7 @@ struct FeedPanelView: View { private var controlBarContent: some View { HStack(spacing: 6) { - ForEach(Filter.allCases) { f in + ForEach([Filter.actionable]) { f in FeedSecondaryFilterButton( filter: f, isSelected: filter == f diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift new file mode 100644 index 000000000000..615762420bbc --- /dev/null +++ b/Sources/FileExplorerState.swift @@ -0,0 +1,94 @@ +import AppKit +import SwiftUI + +// MARK: - State (visibility toggle) + +final class FileExplorerState: ObservableObject { + private static let modeKey = "rightSidebar.mode" + + @Published var isVisible: Bool { + didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } + } + @Published var width: CGFloat { + didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } + } + + /// Proportion of sidebar height allocated to the tab list (0.0-1.0). + /// The file explorer gets the remaining space below. + @Published var dividerPosition: CGFloat { + didSet { UserDefaults.standard.set(Double(dividerPosition), forKey: "fileExplorer.dividerPosition") } + } + + /// Whether hidden files (dotfiles) are shown in the tree. + @Published var showHiddenFiles: Bool { + didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } + } + + @Published private var storedMode: RightSidebarMode + + /// Active mode for the right sidebar (file tree, search, sessions, or enabled beta modes). + var mode: RightSidebarMode { + get { storedMode } + set { setMode(newValue) } + } + + init() { + let defaults = UserDefaults.standard + self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") + let storedWidth = defaults.double(forKey: "fileExplorer.width") + self.width = storedWidth > 0 ? CGFloat(storedWidth) : 220 + let storedPosition = defaults.double(forKey: "fileExplorer.dividerPosition") + self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 + let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") + self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") + let storedMode = RightSidebarMode(rawValue: defaults.string(forKey: Self.modeKey) ?? "") ?? .files + self.storedMode = Self.availableMode(storedMode, defaults: defaults) + defaults.set(self.storedMode.rawValue, forKey: Self.modeKey) + } + + func refreshModeAvailability(defaults: UserDefaults = .standard) { + setMode(storedMode, defaults: defaults) + } + + func toggle() { + setVisible(!isVisible) + } + + func setVisible(_ nextValue: Bool) { + guard isVisible != nextValue else { return } + + // Suppress both SwiftUI transactions and AppKit/Core Animation implicit layout changes. + NSAnimationContext.beginGrouping() + CATransaction.begin() + defer { + CATransaction.commit() + NSAnimationContext.endGrouping() + } + + NSAnimationContext.current.duration = 0 + NSAnimationContext.current.allowsImplicitAnimation = false + CATransaction.setDisableActions(true) + + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + isVisible = nextValue + } + } + + private func setMode(_ mode: RightSidebarMode, defaults: UserDefaults = .standard) { + let nextMode = Self.availableMode(mode, defaults: defaults) + guard storedMode != nextMode else { + if defaults.string(forKey: Self.modeKey) != nextMode.rawValue { + defaults.set(nextMode.rawValue, forKey: Self.modeKey) + } + return + } + storedMode = nextMode + defaults.set(nextMode.rawValue, forKey: Self.modeKey) + } + + private static func availableMode(_ mode: RightSidebarMode, defaults: UserDefaults) -> RightSidebarMode { + mode.isAvailable(defaults: defaults) ? mode : .files + } +} diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 8e339b490d83..0b80e54a1b0b 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -419,82 +419,6 @@ enum FileExplorerError: LocalizedError { } } -// MARK: - State (visibility toggle) - -final class FileExplorerState: ObservableObject { - private static let modeKey = "rightSidebar.mode" - private static let feedDockMigrationKey = "rightSidebar.feedDockMigrationApplied" - - @Published var isVisible: Bool { - didSet { UserDefaults.standard.set(isVisible, forKey: "fileExplorer.isVisible") } - } - @Published var width: CGFloat { - didSet { UserDefaults.standard.set(Double(width), forKey: "fileExplorer.width") } - } - - /// Proportion of sidebar height allocated to the tab list (0.0-1.0). - /// The file explorer gets the remaining space below. - @Published var dividerPosition: CGFloat { - didSet { UserDefaults.standard.set(Double(dividerPosition), forKey: "fileExplorer.dividerPosition") } - } - - /// Whether hidden files (dotfiles) are shown in the tree. - @Published var showHiddenFiles: Bool { - didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } - } - - /// Active mode for the right sidebar (file tree or session index). - @Published var mode: RightSidebarMode { - didSet { UserDefaults.standard.set(mode.rawValue, forKey: Self.modeKey) } - } - - init() { - let defaults = UserDefaults.standard - self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") - let storedWidth = defaults.double(forKey: "fileExplorer.width") - self.width = storedWidth > 0 ? CGFloat(storedWidth) : 220 - let storedPosition = defaults.double(forKey: "fileExplorer.dividerPosition") - self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 - let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") - self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") - let storedMode = defaults.string(forKey: Self.modeKey) ?? RightSidebarMode.files.rawValue - if storedMode == RightSidebarMode.feed.rawValue, - defaults.bool(forKey: Self.feedDockMigrationKey) == false { - self.mode = .dock - defaults.set(RightSidebarMode.dock.rawValue, forKey: Self.modeKey) - defaults.set(true, forKey: Self.feedDockMigrationKey) - } else { - self.mode = RightSidebarMode(rawValue: storedMode) ?? .files - } - } - - func toggle() { - setVisible(!isVisible) - } - - func setVisible(_ nextValue: Bool) { - guard isVisible != nextValue else { return } - - // Suppress both SwiftUI transactions and AppKit/Core Animation implicit layout changes. - NSAnimationContext.beginGrouping() - CATransaction.begin() - defer { - CATransaction.commit() - NSAnimationContext.endGrouping() - } - - NSAnimationContext.current.duration = 0 - NSAnimationContext.current.allowsImplicitAnimation = false - CATransaction.setDisableActions(true) - - var transaction = Transaction(animation: nil) - transaction.disablesAnimations = true - withTransaction(transaction) { - isVisible = nextValue - } - } -} - // MARK: - Store /// All access must happen on the main thread. Properties are not marked @MainActor diff --git a/Sources/MainWindowFocusController.swift b/Sources/MainWindowFocusController.swift index ad187dcb3704..191b686690c6 100644 --- a/Sources/MainWindowFocusController.swift +++ b/Sources/MainWindowFocusController.swift @@ -1,33 +1,4 @@ import AppKit -import Foundation - -struct FeedFocusSnapshot: Equatable { - var selectedItemId: UUID? - var isKeyboardActive: Bool - - init(selectedItemId: UUID? = nil, isKeyboardActive: Bool = false) { - self.selectedItemId = selectedItemId - self.isKeyboardActive = isKeyboardActive - } -} - -protocol FeedKeyboardFocusResponder: AnyObject {} - -enum MainWindowKeyboardFocusIntent: Equatable { - case mainPanel(workspaceId: UUID, panelId: UUID) - case rightSidebar(mode: RightSidebarMode) -} - -enum MainWindowFocusToggleDestination: Equatable { - case terminal - case rightSidebar -} - -enum MainWindowFindShortcutTarget: Equatable { - case mainPanelFind - case rightSidebarFileSearch - case none -} @MainActor final class MainWindowFocusController { @@ -409,7 +380,12 @@ final class MainWindowFocusController { @discardableResult func focusRightSidebar(mode requestedMode: RightSidebarMode? = nil, focusFirstItem: Bool = true) -> Bool { guard let state = fileExplorerState else { return false } - let mode = requestedMode ?? rememberedRightSidebarMode ?? state.mode + let desiredMode = requestedMode ?? rememberedRightSidebarMode ?? state.mode + guard desiredMode.isAvailable() else { + guard requestedMode == nil else { return false } + return focusRightSidebar(mode: .files, focusFirstItem: focusFirstItem) + } + let mode = desiredMode let target = rightSidebarFocusTarget(mode: mode, focusFirstItem: focusFirstItem) return focusRightSidebar(mode: mode, target: target, terminalYieldReason: "rightSidebarFocus") } @@ -421,6 +397,7 @@ final class MainWindowFocusController { terminalYieldReason: String = "rightSidebarFocus" ) -> Bool { guard let state = fileExplorerState else { return false } + guard mode.isAvailable() else { return false } rememberedRightSidebarMode = mode beginRightSidebarFocusRequest(mode: mode, target: target) intent = .rightSidebar(mode: mode) diff --git a/Sources/MainWindowFocusTypes.swift b/Sources/MainWindowFocusTypes.swift new file mode 100644 index 000000000000..926303dd6fd5 --- /dev/null +++ b/Sources/MainWindowFocusTypes.swift @@ -0,0 +1,29 @@ +import Foundation + +struct FeedFocusSnapshot: Equatable { + var selectedItemId: UUID? + var isKeyboardActive: Bool + + init(selectedItemId: UUID? = nil, isKeyboardActive: Bool = false) { + self.selectedItemId = selectedItemId + self.isKeyboardActive = isKeyboardActive + } +} + +protocol FeedKeyboardFocusResponder: AnyObject {} + +enum MainWindowKeyboardFocusIntent: Equatable { + case mainPanel(workspaceId: UUID, panelId: UUID) + case rightSidebar(mode: RightSidebarMode) +} + +enum MainWindowFocusToggleDestination: Equatable { + case terminal + case rightSidebar +} + +enum MainWindowFindShortcutTarget: Equatable { + case mainPanelFind + case rightSidebarFileSearch + case none +} diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift new file mode 100644 index 000000000000..5409f6dff0da --- /dev/null +++ b/Sources/RightSidebarMode+Availability.swift @@ -0,0 +1,32 @@ +import Foundation + +extension RightSidebarMode { + static func availableModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { + availableModes( + feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), + dockEnabled: RightSidebarBetaFeatureSettings.isDockEnabled(defaults: defaults) + ) + } + + static func availableModes(feedEnabled: Bool, dockEnabled: Bool) -> [RightSidebarMode] { + allCases.filter { $0.isAvailable(feedEnabled: feedEnabled, dockEnabled: dockEnabled) } + } + + func isAvailable(defaults: UserDefaults = .standard) -> Bool { + isAvailable( + feedEnabled: RightSidebarBetaFeatureSettings.isFeedEnabled(defaults: defaults), + dockEnabled: RightSidebarBetaFeatureSettings.isDockEnabled(defaults: defaults) + ) + } + + func isAvailable(feedEnabled: Bool, dockEnabled: Bool) -> Bool { + switch self { + case .files, .find, .sessions: + return true + case .feed: + return feedEnabled + case .dock: + return dockEnabled + } + } +} diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index d444848d567f..917dc00bd737 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -60,10 +60,12 @@ extension RightSidebarMode { if KeyboardShortcutSettings.shortcut(for: .switchRightSidebarToSessions).matches(event: event) { return .sessions } - if KeyboardShortcutSettings.shortcut(for: .switchRightSidebarToFeed).matches(event: event) { + if KeyboardShortcutSettings.shortcut(for: .switchRightSidebarToFeed).matches(event: event), + RightSidebarMode.feed.isAvailable() { return .feed } - if KeyboardShortcutSettings.shortcut(for: .switchRightSidebarToDock).matches(event: event) { + if KeyboardShortcutSettings.shortcut(for: .switchRightSidebarToDock).matches(event: event), + RightSidebarMode.dock.isAvailable() { return .dock } return nil @@ -156,6 +158,10 @@ struct RightSidebarPanelView: View { @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(RightSidebarBetaFeatureSettings.feedEnabledKey) + private var feedEnabled = RightSidebarBetaFeatureSettings.defaultFeedEnabled + @AppStorage(RightSidebarBetaFeatureSettings.dockEnabledKey) + private var dockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled // Re-reading the observable store inside modeBar causes SwiftUI to // track the pending count so the badge updates live when hooks push @@ -164,6 +170,10 @@ struct RightSidebarPanelView: View { FeedCoordinator.shared.store?.pending.count ?? 0 } + private var availableModes: [RightSidebarMode] { + RightSidebarMode.availableModes(feedEnabled: feedEnabled, dockEnabled: dockEnabled) + } + var body: some View { ZStack(alignment: .topLeading) { VStack(spacing: 0) { @@ -192,20 +202,25 @@ struct RightSidebarPanelView: View { .onAppear { modeShortcutHintMonitor.start() focusShortcutHintMonitor.start() + fileExplorerState.refreshModeAvailability() } .onDisappear { modeShortcutHintMonitor.stop() focusShortcutHintMonitor.stop() } - .onChange(of: fileExplorerState.mode) { _, mode in if mode != .dock { dockStore.deactivate() } } + .onChange(of: fileExplorerState.mode) { _, mode in + if mode != .dock { dockStore.deactivate() } + } .onChange(of: fileExplorerState.isVisible) { _, visible in if !visible { dockStore.deactivate() } } + .onChange(of: feedEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } + .onChange(of: dockEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } } private var modeBar: some View { let _ = keyboardShortcutSettingsObserver.revision let showsModeShortcutHints = alwaysShowShortcutHints || modeShortcutHintMonitor.isModifierPressed return HStack(spacing: 4) { - ForEach(RightSidebarMode.allCases, id: \.rawValue) { mode in + ForEach(availableModes, id: \.rawValue) { mode in ModeBarButton( mode: mode, isSelected: fileExplorerState.mode == mode, @@ -290,16 +305,28 @@ struct RightSidebarPanelView: View { } private func selectMode(_ mode: RightSidebarMode) { - if fileExplorerState.mode != mode { - fileExplorerState.mode = mode - } - if mode == .sessions { + fileExplorerState.mode = mode + if fileExplorerState.mode == .sessions { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) if sessionIndexStore.entries.isEmpty { sessionIndexStore.reload() } } } + + private func refreshModeAvailabilityAndFocusIfNeeded() { + let previousMode = fileExplorerState.mode + fileExplorerState.refreshModeAvailability() + guard previousMode != fileExplorerState.mode, + fileExplorerState.isVisible, + let window = NSApp.keyWindow ?? NSApp.mainWindow + else { return } + _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: fileExplorerState.mode, + focusFirstItem: false, + preferredWindow: window + ) + } } private struct RightSidebarKeyboardFocusBridge: NSViewRepresentable { diff --git a/Sources/SettingsCardNote.swift b/Sources/SettingsCardNote.swift new file mode 100644 index 000000000000..732e1f4dd33f --- /dev/null +++ b/Sources/SettingsCardNote.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct SettingsCardNote: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 1290e0f959a0..c92de00075e5 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -5,6 +5,7 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { case app case terminal case sidebarAppearance + case betaFeatures case automation case browser case browserImport @@ -28,6 +29,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors") case .sidebarAppearance: return String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar") + case .betaFeatures: + return String(localized: "settings.section.betaFeatures", defaultValue: "Beta Features") case .automation: return String(localized: "settings.section.automation", defaultValue: "Automation") case .browser: @@ -57,6 +60,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "paintpalette" case .sidebarAppearance: return "sidebar.left" + case .betaFeatures: + return "exclamationmark.triangle" case .automation: return "wand.and.sparkles" case .browser: @@ -86,6 +91,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "\(title) palette tabs" case .sidebarAppearance: return "\(title) sidebar details branches badges material terminal background" + case .betaFeatures: + return "\(title) beta experimental unstable feed dock right sidebar" case .automation: return "\(title) socket integrations hooks ports claude cursor gemini" case .browser: @@ -319,6 +326,8 @@ enum SettingsSearchIndex { setting(.sidebarAppearance, "show-log", String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), "status message"), setting(.sidebarAppearance, "show-progress", String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), "progress bar"), setting(.sidebarAppearance, "show-metadata", String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), "report meta status block"), + setting(.betaFeatures, "feed", String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), "feed right sidebar agent decisions permissions questions"), + setting(.betaFeatures, "dock", String(localized: "settings.betaFeatures.dock", defaultValue: "Dock"), "dock right sidebar terminal controls tui"), setting(.automation, "socket-mode", String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), "unix socket api access password auth"), setting(.automation, "socket-password", String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), "socket auth credential"), setting(.automation, "claude-code", String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), "agent hooks notifications"), @@ -361,6 +370,8 @@ enum SettingsSearchIndex { ) private static let settingsPathAnchorIDs: [String: String] = [ + "rightSidebar.beta.feed.enabled": settingID(for: .betaFeatures, idSuffix: "feed"), + "rightSidebar.beta.dock.enabled": settingID(for: .betaFeatures, idSuffix: "dock"), "app.language": settingID(for: .app, idSuffix: "language"), "app.appearance": settingID(for: .app, idSuffix: "appearance"), "app.appIcon": settingID(for: .app, idSuffix: "app-icon"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index fad0e70039f9..340e8dee81a9 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -9,6 +9,8 @@ enum SettingsSearchAliasIndex { return localized("settings.search.alias.section.terminal", defaultValue: "shell scrollback scrollbar scroll bar ghostty tty pty") case .sidebarAppearance: return localized("settings.search.alias.section.sidebarAppearance", defaultValue: "sidebar left rail navigation details branches badges material terminal background") + case .betaFeatures: + return localized("settings.search.alias.section.betaFeatures", defaultValue: "beta experimental unstable preview feed dock right sidebar") case .automation: return localized("settings.search.alias.section.automation", defaultValue: "api cli control socket mcp agents hooks ports") case .browser: @@ -75,6 +77,8 @@ enum SettingsSearchAliasIndex { "sidebarAppearance:show-log": localized("settings.search.alias.setting.app.show-log", defaultValue: "sidebar.showLog log status latest message imperative"), "sidebarAppearance:show-progress": localized("settings.search.alias.setting.app.show-progress", defaultValue: "sidebar.showProgress progress bar percent status set_progress"), "sidebarAppearance:show-metadata": localized("settings.search.alias.setting.app.show-metadata", defaultValue: "sidebar.showCustomMetadata metadata meta report_meta status custom block"), + "betaFeatures:feed": localized("settings.search.alias.setting.betaFeatures.feed", defaultValue: "feed right sidebar agent decisions permissions questions approval beta unstable"), + "betaFeatures:dock": localized("settings.search.alias.setting.betaFeatures.dock", defaultValue: "dock right sidebar terminal controls tui beta unstable"), "automation:socket-mode": localized("settings.search.alias.setting.automation.socket-mode", defaultValue: "automation.socketControlMode api socket unix domain control server auth allow password disabled"), "automation:socket-password": localized("settings.search.alias.setting.automation.socket-password", defaultValue: "automation.socketPassword auth token credential secret password access key"), "automation:claude-code": localized("settings.search.alias.setting.automation.claude-code", defaultValue: "automation.claudeCodeIntegration claude code hooks agent integration status notifications"), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ebfab5c6022b..941d3fa288c6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5270,6 +5270,10 @@ struct SettingsView: View { @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false + @AppStorage(RightSidebarBetaFeatureSettings.feedEnabledKey) + private var rightSidebarFeedEnabled = RightSidebarBetaFeatureSettings.defaultFeedEnabled + @AppStorage(RightSidebarBetaFeatureSettings.dockEnabledKey) + private var rightSidebarDockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled @ObservedObject private var notificationStore = TerminalNotificationStore.shared @ObservedObject private var authManager = AuthManager.shared @@ -6453,6 +6457,11 @@ struct SettingsView: View { .disabled(sidebarHideAllDetails) } + BetaFeaturesSettingsView( + feedEnabled: $rightSidebarFeedEnabled, + dockEnabled: $rightSidebarDockEnabled + ) + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) .settingsSearchAnchor(SettingsSearchIndex.sectionID(for: .automation)) SettingsCard { @@ -7363,6 +7372,8 @@ struct SettingsView: View { browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed + rightSidebarFeedEnabled = RightSidebarBetaFeatureSettings.defaultFeedEnabled + rightSidebarDockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @@ -7490,7 +7501,7 @@ struct SettingsView: View { } } -private struct SettingsSectionHeader: View { +struct SettingsSectionHeader: View { let title: String var body: some View { @@ -7580,7 +7591,7 @@ private struct AuthSettingsRow: View { } } -private struct SettingsCard: View { +struct SettingsCard: View { @ViewBuilder let content: Content init(@ViewBuilder content: () -> Content) { @@ -7602,7 +7613,7 @@ private struct SettingsCard: View { } } -private struct SettingsCardRow: View { +struct SettingsCardRow: View { let configurationReview: SettingsConfigurationReview let title: String let subtitle: String? @@ -7722,7 +7733,7 @@ extension SettingsPickerRow where ExtraTrailing == EmptyView { } } -private enum SettingsConfigurationReview: Equatable { +enum SettingsConfigurationReview: Equatable { case settingsFile([String]) case settingsOnly case action @@ -7760,7 +7771,7 @@ private extension View { } } -private struct SettingsCardDivider: View { +struct SettingsCardDivider: View { var body: some View { Rectangle() .fill(Color(nsColor: NSColor.separatorColor).opacity(0.5)) @@ -7768,23 +7779,6 @@ private struct SettingsCardDivider: View { } } -private struct SettingsCardNote: View { - let text: String - - init(_ text: String) { - self.text = text - } - - var body: some View { - Text(text) - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct ThemeWindowThumbnail: View { let isDark: Bool diff --git a/cmuxTests/FileExplorerStateModePersistenceTests.swift b/cmuxTests/FileExplorerStateModePersistenceTests.swift new file mode 100644 index 000000000000..91f79ca27b7b --- /dev/null +++ b/cmuxTests/FileExplorerStateModePersistenceTests.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class FileExplorerStateModePersistenceTests: XCTestCase { + private let modeKey = "rightSidebar.mode" + private let feedEnabledKey = RightSidebarBetaFeatureSettings.feedEnabledKey + private let dockEnabledKey = RightSidebarBetaFeatureSettings.dockEnabledKey + + func testDisabledFeedStoredModeFallsBackToFiles() { + withSavedRightSidebarModeDefaults { + let defaults = UserDefaults.standard + defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) + defaults.set(false, forKey: feedEnabledKey) + + let state = FileExplorerState() + + XCTAssertEqual(state.mode, .files) + XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + } + } + + func testEnabledFeedStoredModeSurvives() { + withSavedRightSidebarModeDefaults { + let defaults = UserDefaults.standard + defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) + defaults.set(true, forKey: feedEnabledKey) + + let state = FileExplorerState() + + XCTAssertEqual(state.mode, .feed) + XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.feed.rawValue) + } + } + + func testModeSetterClampsUnavailableBetaModes() { + withSavedRightSidebarModeDefaults { + let defaults = UserDefaults.standard + defaults.set(false, forKey: feedEnabledKey) + defaults.set(false, forKey: dockEnabledKey) + let state = FileExplorerState() + + state.mode = .feed + XCTAssertEqual(state.mode, .files) + XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + + defaults.set(true, forKey: dockEnabledKey) + state.mode = .dock + XCTAssertEqual(state.mode, .dock) + XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.dock.rawValue) + + defaults.set(false, forKey: dockEnabledKey) + state.refreshModeAvailability() + XCTAssertEqual(state.mode, .files) + XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.files.rawValue) + } + } + + private func withSavedRightSidebarModeDefaults(_ body: () -> Void) { + let defaults = UserDefaults.standard + let previousMode = defaults.object(forKey: modeKey) + let previousFeedEnabled = defaults.object(forKey: feedEnabledKey) + let previousDockEnabled = defaults.object(forKey: dockEnabledKey) + defer { + restore(previousMode, forKey: modeKey) + restore(previousFeedEnabled, forKey: feedEnabledKey) + restore(previousDockEnabled, forKey: dockEnabledKey) + } + body() + } + + private func restore(_ value: Any?, forKey key: String) { + let defaults = UserDefaults.standard + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index 915914937c03..48ca7be63ebc 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -8,41 +8,74 @@ import XCTest #endif final class RightSidebarCommandPaletteTests: XCTestCase { - func testCommandPaletteIncludesActionForEachRightSidebarMode() throws { - let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() - let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) - let context = ContentView.CommandPaletteContextSnapshot() - - for mode in RightSidebarMode.allCases { - let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) - let contribution = try XCTUnwrap( - contributionsByID[commandID], - "Expected command palette contribution for \(mode.rawValue)" - ) - - XCTAssertEqual(contribution.title(context), mode.shortcutAction.label) - XCTAssertEqual( - contribution.subtitle(context), - String(localized: "command.rightSidebarMode.subtitle", defaultValue: "Right Sidebar") - ) - XCTAssertTrue(contribution.keywords.contains("right")) - XCTAssertTrue(contribution.keywords.contains("sidebar")) - XCTAssertTrue(contribution.keywords.contains(mode.rawValue)) - XCTAssertTrue(contribution.when(context)) - XCTAssertTrue(contribution.enablement(context)) - } + func testCommandPaletteIncludesDefaultRightSidebarModes() throws { + try withSavedBetaFeatureDefaults { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() + let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) + let context = ContentView.CommandPaletteContextSnapshot() + + for mode in RightSidebarMode.availableModes() { + let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) + let contribution = try XCTUnwrap( + contributionsByID[commandID], + "Expected command palette contribution for \(mode.rawValue)" + ) + + XCTAssertEqual(contribution.title(context), mode.shortcutAction.label) + XCTAssertEqual( + contribution.subtitle(context), + String(localized: "command.rightSidebarMode.subtitle", defaultValue: "Right Sidebar") + ) + XCTAssertTrue(contribution.keywords.contains("right")) + XCTAssertTrue(contribution.keywords.contains("sidebar")) + XCTAssertTrue(contribution.keywords.contains(mode.rawValue)) + XCTAssertTrue(contribution.when(context)) + XCTAssertTrue(contribution.enablement(context)) + } - XCTAssertEqual(contributions.count, RightSidebarMode.allCases.count) + XCTAssertEqual(contributions.count, 3) + XCTAssertNil(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.feed)]) + XCTAssertNil(contributionsByID[ContentView.commandPaletteRightSidebarModeCommandID(.dock)]) + } } func testCommandPaletteRightSidebarActionsUseModeShortcutActions() { - for mode in RightSidebarMode.allCases { - XCTAssertEqual( - ContentView.commandPaletteShortcutAction( - forCommandID: ContentView.commandPaletteRightSidebarModeCommandID(mode) - ), - mode.shortcutAction - ) + withSavedBetaFeatureDefaults { + let defaults = UserDefaults.standard + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + + for mode in RightSidebarMode.allCases { + XCTAssertEqual( + ContentView.commandPaletteShortcutAction( + forCommandID: ContentView.commandPaletteRightSidebarModeCommandID(mode) + ), + mode.shortcutAction + ) + } + } + } + + private func withSavedBetaFeatureDefaults(_ body: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let previousFeed = defaults.object(forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + let previousDock = defaults.object(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + defer { + restore(previousFeed, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + restore(previousDock, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + } + try body() + } + + private func restore(_ value: Any?, forKey key: String) { + let defaults = UserDefaults.standard + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) } } } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 85a8dc003e30..f528f8899462 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1350,59 +1350,6 @@ final class MainWindowFocusControllerRightSidebarHideTests: XCTestCase { } } -final class FileExplorerStateModePersistenceTests: XCTestCase { - private let modeKey = "rightSidebar.mode" - private let migrationKey = "rightSidebar.feedDockMigrationApplied" - - func testLegacyFeedStoredModeMigratesToDock() { - withSavedRightSidebarModeDefaults { - let defaults = UserDefaults.standard - defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) - defaults.removeObject(forKey: migrationKey) - - let state = FileExplorerState() - - XCTAssertEqual(state.mode, .dock) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.dock.rawValue) - XCTAssertTrue(defaults.bool(forKey: migrationKey)) - } - } - - func testFeedStoredModeSurvivesAfterDockMigration() { - withSavedRightSidebarModeDefaults { - let defaults = UserDefaults.standard - defaults.set(RightSidebarMode.feed.rawValue, forKey: modeKey) - defaults.set(true, forKey: migrationKey) - - let state = FileExplorerState() - - XCTAssertEqual(state.mode, .feed) - XCTAssertEqual(defaults.string(forKey: modeKey), RightSidebarMode.feed.rawValue) - } - } - - private func withSavedRightSidebarModeDefaults(_ body: () -> Void) { - let defaults = UserDefaults.standard - let previousMode = defaults.object(forKey: modeKey) - let previousMigration = defaults.object(forKey: migrationKey) - defer { - restore(previousMode, forKey: modeKey) - restore(previousMigration, forKey: migrationKey) - } - body() - } - - private func restore(_ value: Any?, forKey key: String) { - let defaults = UserDefaults.standard - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } -} - - final class ShortcutHintDebugSettingsTests: XCTestCase { func testClampKeepsValuesWithinSupportedRange() { XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0) diff --git a/cmuxUITests/FeedSidebarUITests.swift b/cmuxUITests/FeedSidebarUITests.swift index 3f033ad67525..f539da88244a 100644 --- a/cmuxUITests/FeedSidebarUITests.swift +++ b/cmuxUITests/FeedSidebarUITests.swift @@ -15,6 +15,7 @@ final class FeedSidebarUITests: XCTestCase { private var dockConfigPath = "" private var requestId = "" private let modeKey = "socketControlMode" + private let dockBetaFeatureKey = "rightSidebar.beta.dock.enabled" private let launchTag = "ui-tests-feed-sidebar" override func setUp() { @@ -39,6 +40,7 @@ final class FeedSidebarUITests: XCTestCase { let app = XCUIApplication() app.launchArguments += [ "-\(modeKey)", "allowAll", + "-\(dockBetaFeatureKey)", "YES", "-AppleLanguages", "(en)", "-AppleLocale", "en_US" ] diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 29155a3ceb51..ab57716eb1c5 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -13,6 +13,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_SHOW_RIGHT_SIDEBAR"] = "1" app.launchArguments += ["-workspacePresentationMode", "minimal"] + app.launchArguments += ["-rightSidebar.beta.feed.enabled", "YES"] let options = XCTExpectedFailure.Options() options.isStrict = false XCTExpectFailure("App activation may fail on headless CI runners", options: options) { @@ -76,18 +77,15 @@ final class RightSidebarChromeHeightUITests: XCTestCase { let feedControlHeightKeys = [ "rightSidebarSecondaryControl_feed_actionableHeight", - "rightSidebarSecondaryControl_feed_activityHeight", ] guard let feedGeometry = waitForJSONNumbers(feedControlHeightKeys, greaterThan: 1, atPath: dataPath, timeout: 5), let feedSecondaryBarHeight = Double(feedGeometry["rightSidebarSecondaryBarHeight"] ?? ""), - let actionableControlHeight = Double(feedGeometry["rightSidebarSecondaryControl_feed_actionableHeight"] ?? ""), - let activityControlHeight = Double(feedGeometry["rightSidebarSecondaryControl_feed_activityHeight"] ?? "") else { + let actionableControlHeight = Double(feedGeometry["rightSidebarSecondaryControl_feed_actionableHeight"] ?? "") else { XCTFail("Timed out waiting for feed secondary bar geometry. data=\(loadJSON(atPath: dataPath) ?? [:])") return } XCTAssertEqual(feedSecondaryBarHeight, modeBarHeight, accuracy: 0.5, "Expected feed secondary bar to match the mode bar. geometry=\(feedGeometry)") XCTAssertEqual(actionableControlHeight, modeControlHeight, accuracy: 0.5, "Expected Feed Actionable pill to match mode button height. geometry=\(feedGeometry)") - XCTAssertEqual(activityControlHeight, modeControlHeight, accuracy: 0.5, "Expected Feed Activity pill to match mode button height. geometry=\(feedGeometry)") } private func waitForJSONNumbers(_ keys: [String], greaterThan threshold: Double, atPath path: String, timeout: TimeInterval) -> [String: String]? {