From b9de0f044642eeea10b0e76fa78dfd05676e9227 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:01:50 -0700 Subject: [PATCH] Add browser import hint debug variants --- Resources/Localizable.xcstrings | 187 +++++++++++ Sources/AppDelegate.swift | 32 ++ Sources/Panels/BrowserPanel.swift | 105 ++++++ Sources/Panels/BrowserPanelView.swift | 194 +++++++++-- Sources/cmuxApp.swift | 307 +++++++++++++++++- cmuxTests/BrowserImportMappingTests.swift | 44 +++ .../BrowserImportProfilesUITests.swift | 45 +++ 7 files changed, 892 insertions(+), 22 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 257a60d5..0f8f83df 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5669,6 +5669,125 @@ } } }, + "browser.import.hint.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Hint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヒントを隠す" + } + } + } + }, + "browser.import.hint.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート…" + } + } + } + }, + "browser.import.hint.settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザー設定" + } + } + } + }, + "browser.import.hint.settingsFootnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can always find this in Settings > Browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あとでいつでも「設定 > ブラウザー」で見つけられます。" + } + } + } + }, + "browser.import.hint.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.hint.toolbar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート" + } + } + } + }, + "browser.import.hint.toolbar.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, "browser.import.validation.scope": { "extractionState": "manual", "localizations": { @@ -50827,6 +50946,74 @@ } } }, + "settings.browser.import.hint.note.hidden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The blank-tab import hint is hidden. Turn it back on here any time." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空タブのインポート案内は非表示です。ここでいつでも再表示できます。" + } + } + } + }, + "settings.browser.import.hint.note.settingsOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank tabs are currently using Settings only mode from the debug window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在、空タブはデバッグウィンドウの「設定のみ」モードになっています。" + } + } + } + }, + "settings.browser.import.hint.note.visible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank browser tabs can show this import suggestion. Hide or re-enable it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにこのインポート案内を表示できます。ここで非表示や再表示を切り替えられます。" + } + } + } + }, + "settings.browser.import.hint.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show import hint on blank browser tabs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにインポート案内を表示" + } + } + } + }, "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b84d7c57..7b5abd04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2306,6 +2306,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. // If there are no windows shortly after launch, force-create one so XCUITest can proceed. if isRunningUnderXCTest { + if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] { + UserDefaults.standard.set( + BrowserImportHintSettings.variant(for: rawVariant).rawValue, + forKey: BrowserImportHintSettings.variantKey + ) + } + if let rawShow = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self else { return } if NSApp.windows.isEmpty { @@ -2314,6 +2332,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser + ) + } + } if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { BrowserDataImportCoordinator.shared.presentImportDialog() diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9e2e5504..dc943d31 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -198,6 +198,111 @@ enum BrowserThemeSettings { } } +enum BrowserImportHintVariant: String, CaseIterable, Identifiable { + case inlineStrip + case floatingCard + case toolbarChip + case settingsOnly + + var id: String { rawValue } +} + +enum BrowserImportHintBlankTabPlacement: Equatable { + case hidden + case inlineStrip + case floatingCard + case toolbarChip +} + +enum BrowserImportHintSettingsStatus: Equatable { + case visible + case hidden + case settingsOnly +} + +struct BrowserImportHintPresentation: Equatable { + let blankTabPlacement: BrowserImportHintBlankTabPlacement + let settingsStatus: BrowserImportHintSettingsStatus + + init( + variant: BrowserImportHintVariant, + showOnBlankTabs: Bool, + isDismissed: Bool + ) { + if variant == .settingsOnly { + blankTabPlacement = .hidden + settingsStatus = .settingsOnly + return + } + + if !showOnBlankTabs || isDismissed { + blankTabPlacement = .hidden + settingsStatus = .hidden + return + } + + switch variant { + case .inlineStrip: + blankTabPlacement = .inlineStrip + case .floatingCard: + blankTabPlacement = .floatingCard + case .toolbarChip: + blankTabPlacement = .toolbarChip + case .settingsOnly: + blankTabPlacement = .hidden + } + settingsStatus = .visible + } +} + +enum BrowserImportHintSettings { + static let variantKey = "browserImportHintVariant" + static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" + static let dismissedKey = "browserImportHintDismissed" + static let defaultVariant: BrowserImportHintVariant = .inlineStrip + static let defaultShowOnBlankTabs = true + static let defaultDismissed = false + + static func variant(for rawValue: String?) -> BrowserImportHintVariant { + guard let rawValue, let variant = BrowserImportHintVariant(rawValue: rawValue) else { + return defaultVariant + } + return variant + } + + static func variant(defaults: UserDefaults = .standard) -> BrowserImportHintVariant { + variant(for: defaults.string(forKey: variantKey)) + } + + static func showOnBlankTabs(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showOnBlankTabsKey) == nil { + return defaultShowOnBlankTabs + } + return defaults.bool(forKey: showOnBlankTabsKey) + } + + static func isDismissed(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: dismissedKey) == nil { + return defaultDismissed + } + return defaults.bool(forKey: dismissedKey) + } + + static func presentation(defaults: UserDefaults = .standard) -> BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: variant(defaults: defaults), + showOnBlankTabs: showOnBlankTabs(defaults: defaults), + isDismissed: isDismissed(defaults: defaults) + ) + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.set(defaultVariant.rawValue, forKey: variantKey) + defaults.set(defaultShowOnBlankTabs, forKey: showOnBlankTabsKey) + defaults.set(defaultDismissed, forKey: dismissedKey) + } +} + struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { let id: UUID var displayName: String diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f0b16dc1..4f1bcc62 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -250,6 +250,9 @@ struct BrowserPanelView: View { @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) private var toggleBrowserDeveloperToolsShortcutData = Data() @State private var suggestionTask: Task? @@ -267,6 +270,7 @@ struct BrowserPanelView: View { @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 + @State private var isBrowserImportHintPopoverPresented = false @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @@ -321,6 +325,18 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + private var browserChromeBackground: Color { Color(nsColor: browserChromeStyle.backgroundColor) } @@ -346,6 +362,14 @@ struct BrowserPanelView: View { return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" } + private var browserImportHintSummary: String { + InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers) + } + + private var shouldShowToolbarImportHintChip: Bool { + shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip + } + private var owningWorkspace: Workspace? { guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId) else { @@ -459,6 +483,10 @@ struct BrowserPanelView: View { if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue } + let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + if browserImportHintVariantRaw != resolvedHintVariant.rawValue { + browserImportHintVariantRaw = resolvedHintVariant.rawValue + } panel.refreshAppearanceDrivenColors() panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() @@ -613,6 +641,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + if shouldShowToolbarImportHintChip { + browserImportHintToolbarChip + } browserProfileButton browserThemeModeButton developerToolsButton @@ -776,6 +807,29 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserThemeModeButton") } + private var browserImportHintToolbarChip: some View { + Button(action: { + isBrowserImportHintPopoverPresented.toggle() + }) { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.down.on.square") + .font(.system(size: 10, weight: .medium)) + Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(devToolsColorOption.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) { + browserImportHintPopover + } + .safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data")) + .accessibilityIdentifier("BrowserImportHintToolbarChip") + } + private var browserProfilePopover: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) @@ -1018,9 +1072,16 @@ struct BrowserPanelView: View { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } + .overlay(alignment: .topLeading) { + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .inlineStrip { + emptyBrowserStateInlineStrip + } + } .overlay { - if shouldShowEmptyStateImportOverlay { - emptyBrowserStateOverlay + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .floatingCard { + emptyBrowserStateCardOverlay } } } @@ -1288,28 +1349,11 @@ struct BrowserPanelView: View { #endif } - private var emptyBrowserStateOverlay: some View { + private var emptyBrowserStateCardOverlay: some View { VStack { Spacer(minLength: 22) - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } - .buttonStyle(.bordered) - .controlSize(.small) - } + browserImportHintBody .padding(12) .frame(maxWidth: 360, alignment: .leading) .background( @@ -1329,10 +1373,118 @@ struct BrowserPanelView: View { .padding(.horizontal, 18) } + private var emptyBrowserStateInlineStrip: some View { + VStack(alignment: .leading, spacing: 0) { + browserImportHintBody + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: 520, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.84)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.35), + lineWidth: 1 + ) + ) + .shadow(color: Color.black.opacity(0.05), radius: 6, y: 2) + + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .padding(.top, 14) + } + + private var browserImportHintPopover: some View { + browserImportHintBody + .padding(12) + .frame(width: 300, alignment: .leading) + } + + private var browserImportHintBody: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportHintSummary) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + browserImportHintPrimaryButton + browserImportHintSettingsButton + browserImportHintDismissButton + } + + VStack(alignment: .leading, spacing: 8) { + browserImportHintPrimaryButton + HStack(spacing: 10) { + browserImportHintSettingsButton + browserImportHintDismissButton + } + } + } + } + .accessibilityElement(children: .contain) + } + + private var browserImportHintPrimaryButton: some View { + Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) { + presentImportDialogFromHint() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + private var browserImportHintSettingsButton: some View { + Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) { + openBrowserImportSettings() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintSettingsButton") + } + + private var browserImportHintDismissButton: some View { + Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) { + dismissBrowserImportHint() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintDismissButton") + } + private var shouldShowEmptyStateImportOverlay: Bool { !panel.shouldRenderWebView && isWebViewBlank() } + private func presentImportDialogFromHint() { + isBrowserImportHintPopoverPresented = false + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + + private func openBrowserImportSettings() { + isBrowserImportHintPopoverPresented = false + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + + private func dismissBrowserImportHint() { + showBrowserImportHintOnBlankTabs = false + isBrowserImportHintDismissed = true + isBrowserImportHintPopoverPresented = false + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d58503a2..15ceaa47 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -337,6 +337,10 @@ struct cmuxApp: App { DebugWindowControlsWindowController.shared.show() } + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } + Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1060,6 +1064,7 @@ struct cmuxApp: App { } private func openAllDebugWindows() { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1074,6 +1079,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.browser-popup", "cmux.settingsAboutTitlebarDebug", "cmux.debugWindowControls", + "cmux.browserImportHintDebug", "cmux.sidebarDebug", "cmux.menubarDebug", "cmux.backgroundDebug", @@ -1689,6 +1695,9 @@ private struct DebugWindowControlsView: View { GroupBox("Open") { VStack(alignment: .leading, spacing: 8) { + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1702,6 +1711,7 @@ private struct DebugWindowControlsView: View { MenuBarExtraDebugWindowController.shared.show() } Button("Open All Debug Windows") { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1905,6 +1915,210 @@ private struct DebugWindowControlsView: View { } } +private final class BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BrowserImportHintDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 420), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Browser Import Hint Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug") + window.center() + window.contentView = NSHostingView(rootView: BrowserImportHintDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BrowserImportHintDebugView: View { + @AppStorage(BrowserImportHintSettings.variantKey) + private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) + private var showOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) + private var isDismissed = BrowserImportHintSettings.defaultDismissed + + private var selectedVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: variantRaw) + } + + private var variantSelection: Binding { + Binding( + get: { selectedVariant.rawValue }, + set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue } + ) + } + + private var showOnBlankTabsBinding: Binding { + Binding( + get: { showOnBlankTabs }, + set: { newValue in + showOnBlankTabs = newValue + if newValue { + isDismissed = false + } + } + ) + } + + private var presentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: selectedVariant, + showOnBlankTabs: showOnBlankTabs, + isDismissed: isDismissed + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Browser Import Hint") + .font(.headline) + + Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.") + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox("Variant") { + VStack(alignment: .leading, spacing: 10) { + Picker("Blank Tab Style", selection: variantSelection) { + ForEach(BrowserImportHintVariant.allCases) { variant in + Text(title(for: variant)).tag(variant.rawValue) + } + } + .pickerStyle(.menu) + + Text(description(for: selectedVariant)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + + GroupBox("State") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding) + Toggle("Pretend the user dismissed it", isOn: $isDismissed) + + Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + + GroupBox("Quick Actions") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button("Open Browser Settings") { + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + Button("Open Import Dialog") { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } + + Button("Reset Hint Debug State") { + BrowserImportHintSettings.reset() + } + } + .padding(.top, 2) + } + + GroupBox("Ideas") { + VStack(alignment: .leading, spacing: 6) { + Text("Inline strip: default candidate, visible but quieter than the old floating card.") + Text("Floating card: strongest nudge, useful when we want more explanation.") + Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.") + Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.") + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func title(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + case .settingsOnly: + return "Settings Only" + } + } + + private func description(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Shows a thin hint bar at the top of blank browser tabs." + case .floatingCard: + return "Shows the fuller callout card inside blank browser tabs." + case .toolbarChip: + return "Moves the hint into a small toolbar chip beside the browser controls." + case .settingsOnly: + return "Hides the blank-tab hint and leaves Browser settings as the only home." + } + } + + private func placementTitle(_ placement: BrowserImportHintBlankTabPlacement) -> String { + switch placement { + case .hidden: + return "Hidden" + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + } + } + + private func settingsStatusTitle(_ status: BrowserImportHintSettingsStatus) -> String { + switch status { + case .visible: + return "Visible" + case .hidden: + return "Hidden" + case .settingsOnly: + return "Settings Only" + } + } +} + private final class AboutWindowController: NSWindowController, NSWindowDelegate { static let shared = AboutWindowController() @@ -2035,6 +2249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } enum SettingsNavigationTarget: String { + case browser case keyboardShortcuts } @@ -3103,6 +3318,9 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @@ -3204,6 +3422,30 @@ struct SettingsView: View { ) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + + private var browserImportHintVisibilityBinding: Binding { + Binding( + get: { showBrowserImportHintOnBlankTabs }, + set: { newValue in + showBrowserImportHintOnBlankTabs = newValue + if newValue { + isBrowserImportHintDismissed = false + } + } + ) + } + private var socketModeSelection: Binding { Binding( get: { socketControlMode }, @@ -3266,6 +3508,17 @@ struct SettingsView: View { InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) } + private var browserImportHintSettingsNote: String { + switch browserImportHintPresentation.settingsStatus { + case .visible: + return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") + case .hidden: + return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") + case .settingsOnly: + return String(localized: "settings.browser.import.hint.note.settingsOnly", defaultValue: "Blank tabs are currently using Settings only mode from the debug window.") + } + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -4187,6 +4440,8 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) + .id(SettingsNavigationTarget.browser) + .accessibilityIdentifier("SettingsBrowserSection") SettingsCard { SettingsPickerRow( String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), @@ -4361,7 +4616,38 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser")) + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } + HStack(spacing: 8) { Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { BrowserDataImportCoordinator.shared.presentImportDialog() @@ -4376,7 +4662,22 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) } + .accessibilityIdentifier("SettingsBrowserImportActions") + + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: browserImportHintVisibilityBinding + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") + + Text(browserImportHintSettingsNote) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 14) + .padding(.vertical, 10) SettingsCardDivider() @@ -4520,6 +4821,7 @@ struct SettingsView: View { BrowserHistoryStore.shared.loadIfNeeded() notificationStore.refreshAuthorizationStatus() browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist refreshDetectedImportBrowsers() @@ -4633,6 +4935,9 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 2f122921..6eed3932 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -144,6 +144,50 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(manyProfilesPresentation.showsHelpText) } + func testBrowserImportHintPresentationDefaultsToInlineStrip() { + let presentation = BrowserImportHintPresentation( + variant: .inlineStrip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .inlineStrip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationHidesBlankTabHintWhenDismissed() { + let presentation = BrowserImportHintPresentation( + variant: .floatingCard, + showOnBlankTabs: true, + isDismissed: true + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .hidden) + } + + func testBrowserImportHintPresentationUsesToolbarChipWhenEnabled() { + let presentation = BrowserImportHintPresentation( + variant: .toolbarChip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationSettingsOnlyVariantStaysInSettings() { + let presentation = BrowserImportHintPresentation( + variant: .settingsOnly, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .settingsOnly) + } + @MainActor func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index c8d95f08..ab30b3e1 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -112,6 +112,32 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } + func testBlankBrowserImportHintCanOpenBrowserSettings() { + let app = launchAppForBlankImportHint() + + let settingsButton = app.buttons["BrowserImportHintSettingsButton"] + XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) + settingsButton.click() + + XCTAssertTrue( + app.otherElements["SettingsBrowserSection"].waitForExistence(timeout: 5.0), + "Expected Browser Settings to open from the blank-tab import hint" + ) + } + + func testBlankBrowserImportHintCanBeDismissed() { + let app = launchAppForBlankImportHint() + + let dismissButton = app.buttons["BrowserImportHintDismissButton"] + XCTAssertTrue(dismissButton.waitForExistence(timeout: 5.0)) + dismissButton.click() + + XCTAssertTrue( + browserImportPollUntil(timeout: 2.0) { !dismissButton.exists }, + "Expected the blank-tab import hint to disappear after dismissal" + ) + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -125,6 +151,18 @@ final class BrowserImportProfilesUITests: XCTestCase { return app } + private func launchAppForBlankImportHint() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" + launchAndActivate(app) + waitForBlankImportHint(app) + return app + } + private func waitForImportWizard(_ app: XCUIApplication) { let wizardOpened = browserImportPollUntil(timeout: 5.0) { app.buttons["Next"].exists || app.windows["Import Browser Data"].exists @@ -132,6 +170,13 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(wizardOpened, "Expected the import wizard to open") } + private func waitForBlankImportHint(_ app: XCUIApplication) { + let hintOpened = browserImportPollUntil(timeout: 5.0) { + app.buttons["BrowserImportHintDismissButton"].exists + } + XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear") + } + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { let url = URL(fileURLWithPath: capturePath) let foundCapture = browserImportPollUntil(timeout: timeout) {