Add browser import hint debug variants

This commit is contained in:
Lawrence Chen 2026-03-17 03:01:50 -07:00
parent f97716939a
commit b9de0f0446
No known key found for this signature in database
7 changed files with 892 additions and 22 deletions

View file

@ -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": {

View file

@ -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()

View file

@ -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

View file

@ -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<Void, Never>?
@ -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 }

View file

@ -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<String> = [
"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<String> {
Binding(
get: { selectedVariant.rawValue },
set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue }
)
}
private var showOnBlankTabsBinding: Binding<Bool> {
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<Bool> {
Binding(
get: { showBrowserImportHintOnBlankTabs },
set: { newValue in
showBrowserImportHintOnBlankTabs = newValue
if newValue {
isBrowserImportHintDismissed = false
}
}
)
}
private var socketModeSelection: Binding<String> {
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

View file

@ -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)"

View file

@ -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) {