Merge pull request #1582 from manaflow-ai/task-browser-import-followups

fix: browser import profile follow-up regressions
This commit is contained in:
Lawrence Chen 2026-03-17 16:49:31 -07:00 committed by GitHub
commit 43d1fd419e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2116 additions and 193 deletions

View file

@ -805,6 +805,193 @@
}
}
},
"debug.menu.browserToolbarButtonSpacing": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Browser Toolbar Button Spacing"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザーツールバーのボタン間隔"
}
}
}
},
"debug.menu.browserProfilePopoverDebug": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Browser Profile Popover Debug…"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザープロファイルポップオーバーのデバッグ…"
}
}
}
},
"debug.windows.browserProfilePopover.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Browser Profile Popover Debug"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザープロファイルポップオーバーのデバッグ"
}
}
}
},
"debug.browserProfilePopover.heading": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Browser Profile Popover"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザープロファイルポップオーバー"
}
}
}
},
"debug.browserProfilePopover.note": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tune the profile popover padding live while comparing it against the browser toolbar menu."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザーツールバーのメニューと見比べながら、プロファイルポップオーバーの余白をライブで調整します。"
}
}
}
},
"debug.browserProfilePopover.group.padding": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Padding"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "余白"
}
}
}
},
"debug.browserProfilePopover.label.horizontal": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Horizontal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "水平"
}
}
}
},
"debug.browserProfilePopover.label.vertical": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Vertical"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "垂直"
}
}
}
},
"debug.browserProfilePopover.group.preview": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Preview"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "プレビュー"
}
}
}
},
"debug.browserProfilePopover.reset": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reset"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リセット"
}
}
}
},
"debug.browserProfilePopover.liveNote": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Changes apply live to the browser profile popover."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "変更はブラウザープロファイルポップオーバーにライブで反映されます。"
}
}
}
},
"debug.devBuildBanner.title": {
"extractionState": "manual",
"localizations": {
@ -4740,13 +4927,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Bookmarks, settings, and extensions import are not available yet."
"value": "Bookmarks, settings, and extensions are not available yet."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブックマーク、設定、拡張機能のインポートにはまだ対応していません。"
"value": "ブックマーク、設定、拡張機能はまだ利用できません。"
}
}
}
@ -4797,7 +4984,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザ: %@"
"value": "ブラウザ: %@"
}
}
}
@ -5029,13 +5216,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "cmux destination"
"value": "Destination"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux の保存先"
"value": "保存先"
}
}
}
@ -5080,13 +5267,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Imported cookies and history go into the selected cmux browser profile."
"value": "Imported data goes into the selected cmux profile."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "インポートしたCookieと履歴は、選択したcmuxブラウザープロファイルに保存されます。"
"value": "インポートしたデータは、選択した cmux プロファイルに保存されます。"
}
}
}
@ -5097,13 +5284,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "All selected source profiles will be merged into the chosen cmux browser profile."
"value": "All selected source profiles go into one cmux profile."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "選択した元プロファイルはすべて、選んだ cmux ブラウザープロファイルにまとめて取り込まれます。"
"value": "選択した元プロファイルは、1つの cmux プロファイルにまとめて取り込まれます。"
}
}
}
@ -5114,13 +5301,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Missing cmux profiles are created when import starts."
"value": "Missing cmux profiles are created on import."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "不足している cmux プロファイルは、インポート開始時に作成されます。"
"value": "不足している cmux プロファイルは、インポート時に作成されます。"
}
}
}
@ -5131,13 +5318,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Merge all into one cmux profile"
"value": "Merge into one"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "すべてを1つの cmux プロファイルにまとめる"
"value": "1つにまとめる"
}
}
}
@ -5148,13 +5335,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Keep profiles separate"
"value": "Separate profiles"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "プロファイルを分けたまま取り込む"
"value": "分けて取り込む"
}
}
}
@ -5233,13 +5420,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Limit to"
"value": "Domains"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "対象ドメイン"
"value": "ドメイン"
}
}
}
@ -5250,13 +5437,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Optional domains only (e.g. github.com, openai.com)"
"value": "Optional domains, comma-separated"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "任意のドメインのみ(例: github.com, openai.com"
"value": "任意のドメインをカンマ区切りで指定"
}
}
}
@ -5505,13 +5692,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Source"
"value": "Browser"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "インポート元"
"value": "ブラウザー"
}
}
}
@ -5539,13 +5726,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Source Profiles"
"value": "Profiles"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "プロファイル"
"value": "プロファイル"
}
}
}
@ -5556,13 +5743,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile."
"value": "Select one or more profiles."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "元プロファイルを1つ以上選択してください。3 / 3 で、分けたまま取り込むか、1つの cmux プロファイルにまとめるかを選べます。"
"value": "1つ以上のプロファイルを選択してください。"
}
}
}
@ -5607,13 +5794,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Step 3 of 3: Choose what to import from %@ and where to put it."
"value": "Step 3 of 3"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "3 / 3: %@ から何をインポートし、どこに保存するかを選択します。"
"value": "3 / 3"
}
}
}
@ -5624,13 +5811,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Step 1 of 3: Choose the browser to import from."
"value": "Step 1 of 3"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1 / 3: インポート元のブラウザーを選択します。"
"value": "1 / 3"
}
}
}
@ -5641,13 +5828,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Step 2 of 3: Choose source profiles from %@."
"value": "Step 2 of 3"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "2 / 3: %@ の元プロファイルを選択します。"
"value": "2 / 3"
}
}
}
@ -5669,6 +5856,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": {
@ -37978,7 +38284,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザから取り込む…"
"value": "ブラウザから取り込む…"
}
}
}
@ -50794,7 +51100,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザデータを取り込む"
"value": "ブラウザデータを取り込む"
}
}
}
@ -50924,7 +51230,7 @@
"ja": {
"stringUnit": {
"state": "translated",
"value": "ブラウザから取り込む"
"value": "ブラウザから取り込む"
}
}
}
@ -50963,6 +51269,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

@ -2301,6 +2301,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 {
@ -2309,6 +2327,25 @@ 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()
}
}
}
#endif
}

View file

@ -9144,6 +9144,7 @@ private final class FeedbackComposerMessageEditorView: NSView {
}
private enum SidebarHelpMenuAction {
case importBrowserData
case keyboardShortcuts
case docs
case changelog
@ -9714,6 +9715,12 @@ private struct SidebarHelpMenuButton: View {
accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts",
isExternalLink: false
)
helpOptionButton(
title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"),
action: .importBrowserData,
accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData",
isExternalLink: false
)
if docsURL != nil {
helpOptionButton(
title: String(localized: "about.docs", defaultValue: "Docs"),
@ -9818,6 +9825,11 @@ private struct SidebarHelpMenuButton: View {
private func perform(_ action: SidebarHelpMenuAction) {
switch action {
case .importBrowserData:
isPopoverPresented = false
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
case .keyboardShortcuts:
isPopoverPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {

View file

@ -212,6 +212,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 = .toolbarChip
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
@ -2391,11 +2496,41 @@ final class BrowserPanel: Panel, ObservableObject {
webView.onContextMenuOpenLinkInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
configureNavigationDelegateCallbacks()
webView.navigationDelegate = navigationDelegate
webView.uiDelegate = uiDelegate
setupObservers(for: webView)
}
private func configureNavigationDelegateCallbacks() {
guard let navigationDelegate else { return }
let boundWebViewInstanceID = webViewInstanceID
let boundHistoryStore = historyStore
navigationDelegate.didFinish = { [weak self] webView in
Task { @MainActor [weak self] in
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return }
boundHistoryStore.recordVisit(url: webView.url, title: webView.title)
self.refreshFavicon(from: webView)
self.applyBrowserThemeModeIfNeeded()
// Keep find-in-page open through load completion and refresh matches for the new DOM.
self.restoreFindStateAfterNavigation(replaySearch: true)
}
}
navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in
Task { @MainActor in
guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return }
// Clear stale title/favicon from the previous page so the tab
// shows the failed URL instead of the old page's branding.
self.pageTitle = failedURL.isEmpty ? "" : failedURL
self.faviconPNGData = nil
self.lastFaviconURLString = nil
// Keep find-in-page open and clear stale counters on failed loads.
self.restoreFindStateAfterNavigation(replaySearch: false)
}
}
}
private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool {
guard candidate === webView else { return false }
guard let instanceID else { return true }
@ -2438,30 +2573,6 @@ final class BrowserPanel: Panel, ObservableObject {
// Set up navigation delegate
let navDelegate = BrowserNavigationDelegate()
navDelegate.didFinish = { webView in
Task { @MainActor [weak self] in
self?.historyStore.recordVisit(url: webView.url, title: webView.title)
}
Task { @MainActor [weak self] in
guard let self, self.isCurrentWebView(webView) else { return }
self.refreshFavicon(from: webView)
self.applyBrowserThemeModeIfNeeded()
// Keep find-in-page open through load completion and refresh matches for the new DOM.
self.restoreFindStateAfterNavigation(replaySearch: true)
}
}
navDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in
Task { @MainActor in
guard let self, self.isCurrentWebView(failedWebView) else { return }
// Clear stale title/favicon from the previous page so the tab
// shows the failed URL instead of the old page's branding.
self.pageTitle = failedURL.isEmpty ? "" : failedURL
self.faviconPNGData = nil
self.lastFaviconURLString = nil
// Keep find-in-page open and clear stale counters on failed loads.
self.restoreFindStateAfterNavigation(replaySearch: false)
}
}
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
@ -7240,6 +7351,18 @@ struct BrowserImportStep3Presentation: Equatable {
}
}
struct BrowserImportSourceProfilesPresentation: Equatable {
let scrollHeight: CGFloat
let showsHelpText: Bool
init(profileCount: Int) {
let visibleRows = min(max(profileCount, 1), 5)
let contentHeight = CGFloat(visibleRows * 26 + 14)
scrollHeight = max(76, contentHeight)
showsHelpText = profileCount > 1
}
}
enum BrowserImportPlanResolver {
@MainActor
static func defaultPlan(
@ -8670,6 +8793,21 @@ final class BrowserDataImportCoordinator {
return wizard.runModal()
}
#if DEBUG
func debugMakeImportWizardWindow(
browsers: [InstalledBrowserCandidate],
destinationProfiles: [BrowserProfileDefinition]? = nil,
defaultDestinationProfileID: UUID? = nil
) -> NSWindow {
let wizard = ImportWizardWindowController(
browsers: browsers,
destinationProfiles: destinationProfiles,
defaultDestinationProfileID: defaultDestinationProfileID
)
return wizard.debugPanelWindow
}
#endif
#if DEBUG
private struct CapturedImportSelection: Encodable {
struct Entry: Encodable {
@ -8781,6 +8919,7 @@ final class BrowserDataImportCoordinator {
private let sourceProfilesEmptyLabel = NSTextField(wrappingLabelWithString: "")
private let sourceProfilesHelpLabel = NSTextField(labelWithString: "")
private let sourceProfilesScrollView = NSScrollView()
private var sourceProfilesScrollHeightConstraint: NSLayoutConstraint?
private let dataTypesContainer = NSStackView()
private let validationLabel = NSTextField(labelWithString: "")
private let destinationModeContainer = NSStackView()
@ -8790,6 +8929,7 @@ final class BrowserDataImportCoordinator {
private let mergeDestinationRow = NSStackView()
private let mergeDestinationPopup = NSPopUpButton(frame: .zero, pullsDown: false)
private let destinationHelpLabel = NSTextField(wrappingLabelWithString: "")
private let additionalDataNoteLabel = NSTextField(wrappingLabelWithString: "")
private let cookiesCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
private let historyCheckbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
@ -8815,7 +8955,7 @@ final class BrowserDataImportCoordinator {
?? fallbackDestinationProfileID
self.mergeDestinationProfileID = self.initialDestinationProfileID
self.panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 620, height: 420),
contentRect: NSRect(x: 0, y: 0, width: 560, height: 292),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
@ -8839,6 +8979,10 @@ final class BrowserDataImportCoordinator {
return selection
}
#if DEBUG
var debugPanelWindow: NSWindow { panel }
#endif
func windowWillClose(_ notification: Notification) {
finishModal(with: .cancel)
}
@ -8941,6 +9085,7 @@ final class BrowserDataImportCoordinator {
guard selectedSourceProfiles.count > 1 else { return }
destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne
rebuildStep3DestinationUI()
updatePanelSize()
}
@objc
@ -8963,6 +9108,13 @@ final class BrowserDataImportCoordinator {
validationLabel.isHidden = true
}
@objc
private func handleImportOptionChanged(_ sender: NSButton) {
validationLabel.isHidden = true
updateAdditionalDataNoteVisibility()
updatePanelSize()
}
private func setupUI() {
panel.title = String(
localized: "browser.import.title",
@ -8973,7 +9125,7 @@ final class BrowserDataImportCoordinator {
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
panel.standardWindowButton(.zoomButton)?.isHidden = true
let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 620, height: 420))
let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 560, height: 292))
contentView.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = contentView
@ -8983,9 +9135,9 @@ final class BrowserDataImportCoordinator {
defaultValue: "Import Browser Data"
)
)
titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .semibold)
titleLabel.font = NSFont.systemFont(ofSize: 22, weight: .semibold)
stepLabel.font = NSFont.systemFont(ofSize: 15, weight: .medium)
stepLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
stepLabel.textColor = .secondaryLabelColor
setupSourceContainer()
@ -8997,6 +9149,7 @@ final class BrowserDataImportCoordinator {
validationLabel.isHidden = true
validationLabel.lineBreakMode = .byWordWrapping
validationLabel.maximumNumberOfLines = 3
validationLabel.translatesAutoresizingMaskIntoConstraints = false
backButton.target = self
backButton.action = #selector(handleBack)
@ -9034,23 +9187,32 @@ final class BrowserDataImportCoordinator {
validationLabel,
])
contentStack.orientation = .vertical
contentStack.spacing = 10
contentStack.spacing = 8
contentStack.alignment = .leading
contentStack.translatesAutoresizingMaskIntoConstraints = false
sourceContainer.translatesAutoresizingMaskIntoConstraints = false
sourceProfilesContainer.translatesAutoresizingMaskIntoConstraints = false
dataTypesContainer.translatesAutoresizingMaskIntoConstraints = false
guard let panelContent = panel.contentView else { return }
panelContent.addSubview(contentStack)
panelContent.addSubview(buttonRow)
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 18),
contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20),
contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20),
contentStack.topAnchor.constraint(equalTo: panelContent.topAnchor, constant: 16),
contentStack.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 18),
contentStack.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -18),
buttonRow.topAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 14),
buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 20),
buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -20),
buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -16),
buttonRow.leadingAnchor.constraint(equalTo: panelContent.leadingAnchor, constant: 18),
buttonRow.trailingAnchor.constraint(equalTo: panelContent.trailingAnchor, constant: -18),
buttonRow.bottomAnchor.constraint(equalTo: panelContent.bottomAnchor, constant: -14),
sourceContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
sourceProfilesContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
dataTypesContainer.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
validationLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
])
}
@ -9066,23 +9228,27 @@ final class BrowserDataImportCoordinator {
labelWithString: String(localized: "browser.import.source", defaultValue: "Source")
)
sourceLabel.alignment = .right
sourceLabel.frame.size.width = 80
sourceLabel.frame.size.width = 64
sourcePopup.setContentHuggingPriority(.defaultLow, for: .horizontal)
sourcePopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let sourceRow = NSStackView(views: [sourceLabel, sourcePopup])
sourceRow.orientation = .horizontal
sourceRow.spacing = 8
sourceRow.alignment = .centerY
sourceRow.distribution = .fill
let detectedLabel = NSTextField(
wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers)
)
detectedLabel.font = NSFont.systemFont(ofSize: 12)
detectedLabel.font = NSFont.systemFont(ofSize: 11)
detectedLabel.textColor = .secondaryLabelColor
detectedLabel.maximumNumberOfLines = 2
detectedLabel.preferredMaxLayoutWidth = 500
sourceContainer.orientation = .vertical
sourceContainer.spacing = 10
sourceContainer.spacing = 8
sourceContainer.alignment = .leading
sourceContainer.addArrangedSubview(sourceRow)
sourceContainer.addArrangedSubview(detectedLabel)
@ -9095,17 +9261,17 @@ final class BrowserDataImportCoordinator {
defaultValue: "Source Profiles"
)
)
sourceProfilesTitle.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
sourceProfilesTitle.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
sourceProfilesList.orientation = .vertical
sourceProfilesList.spacing = 6
sourceProfilesList.alignment = .leading
sourceProfilesList.translatesAutoresizingMaskIntoConstraints = false
sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 13)
sourceProfilesEmptyLabel.font = NSFont.systemFont(ofSize: 12)
sourceProfilesEmptyLabel.textColor = .secondaryLabelColor
sourceProfilesEmptyLabel.maximumNumberOfLines = 0
sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 520
sourceProfilesEmptyLabel.preferredMaxLayoutWidth = 500
sourceProfilesDocumentView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
sourceProfilesDocumentView.translatesAutoresizingMaskIntoConstraints = false
@ -9124,23 +9290,29 @@ final class BrowserDataImportCoordinator {
sourceProfilesScrollView.documentView = sourceProfilesDocumentView
sourceProfilesScrollView.translatesAutoresizingMaskIntoConstraints = false
sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true
sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 180).isActive = true
sourceProfilesScrollHeightConstraint = sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 76)
sourceProfilesScrollHeightConstraint?.isActive = true
let sourceProfilesScrollWidthConstraint = sourceProfilesScrollView.widthAnchor.constraint(
equalTo: sourceProfilesContainer.widthAnchor
)
sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 12)
sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 11)
sourceProfilesHelpLabel.textColor = .secondaryLabelColor
sourceProfilesHelpLabel.maximumNumberOfLines = 2
sourceProfilesHelpLabel.lineBreakMode = .byWordWrapping
sourceProfilesHelpLabel.preferredMaxLayoutWidth = 500
sourceProfilesHelpLabel.stringValue = String(
localized: "browser.import.sourceProfiles.help",
defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one cmux profile."
)
sourceProfilesContainer.orientation = .vertical
sourceProfilesContainer.spacing = 10
sourceProfilesContainer.spacing = 8
sourceProfilesContainer.alignment = .leading
sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle)
sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView)
sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel)
sourceProfilesScrollWidthConstraint.isActive = true
sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical)
sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
}
@ -9161,6 +9333,12 @@ final class BrowserDataImportCoordinator {
localized: "browser.import.additionalData",
defaultValue: "Additional data (bookmarks, settings, extensions)"
)
cookiesCheckbox.target = self
cookiesCheckbox.action = #selector(handleImportOptionChanged(_:))
historyCheckbox.target = self
historyCheckbox.action = #selector(handleImportOptionChanged(_:))
additionalDataCheckbox.target = self
additionalDataCheckbox.action = #selector(handleImportOptionChanged(_:))
cookiesCheckbox.setAccessibilityIdentifier("BrowserImportCookiesCheckbox")
historyCheckbox.setAccessibilityIdentifier("BrowserImportHistoryCheckbox")
additionalDataCheckbox.setAccessibilityIdentifier("BrowserImportAdditionalDataCheckbox")
@ -9185,25 +9363,29 @@ final class BrowserDataImportCoordinator {
mergeDestinationPopup.target = self
mergeDestinationPopup.action = #selector(handleMergeDestinationChanged(_:))
mergeDestinationPopup.setContentHuggingPriority(.defaultLow, for: .horizontal)
mergeDestinationPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
separateDestinationRows.orientation = .vertical
separateDestinationRows.spacing = 8
separateDestinationRows.spacing = 6
separateDestinationRows.alignment = .leading
mergeDestinationRow.orientation = .horizontal
mergeDestinationRow.spacing = 8
mergeDestinationRow.spacing = 6
mergeDestinationRow.alignment = .centerY
destinationHelpLabel.font = NSFont.systemFont(ofSize: 12)
destinationHelpLabel.font = NSFont.systemFont(ofSize: 11)
destinationHelpLabel.textColor = .secondaryLabelColor
destinationHelpLabel.maximumNumberOfLines = 3
destinationHelpLabel.preferredMaxLayoutWidth = 540
destinationHelpLabel.maximumNumberOfLines = 2
destinationHelpLabel.preferredMaxLayoutWidth = 500
domainField.placeholderString = String(
localized: "browser.import.domain.placeholder",
defaultValue: "Optional domains only (e.g. github.com, openai.com)"
)
domainField.stringValue = ""
domainField.setContentHuggingPriority(.defaultLow, for: .horizontal)
domainField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let destinationTitleLabel = NSTextField(
labelWithString: String(
@ -9211,32 +9393,32 @@ final class BrowserDataImportCoordinator {
defaultValue: "cmux destination"
)
)
destinationTitleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
destinationTitleLabel.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
let domainLabel = NSTextField(
labelWithString: String(localized: "browser.import.domain", defaultValue: "Limit to")
)
domainLabel.alignment = .right
domainLabel.frame.size.width = 80
domainLabel.frame.size.width = 72
let domainRow = NSStackView(views: [domainLabel, domainField])
domainRow.orientation = .horizontal
domainRow.spacing = 8
domainRow.alignment = .centerY
domainRow.distribution = .fill
let noteLabel = NSTextField(
wrappingLabelWithString: String(
localized: "browser.import.additionalData.note",
defaultValue: "Bookmarks, settings, and extensions import are not available yet."
)
additionalDataNoteLabel.stringValue = String(
localized: "browser.import.additionalData.note",
defaultValue: "Bookmarks, settings, and extensions import are not available yet."
)
noteLabel.font = NSFont.systemFont(ofSize: 12)
noteLabel.textColor = .secondaryLabelColor
noteLabel.maximumNumberOfLines = 2
noteLabel.preferredMaxLayoutWidth = 540
additionalDataNoteLabel.font = NSFont.systemFont(ofSize: 11)
additionalDataNoteLabel.textColor = .secondaryLabelColor
additionalDataNoteLabel.maximumNumberOfLines = 2
additionalDataNoteLabel.preferredMaxLayoutWidth = 500
additionalDataNoteLabel.isHidden = true
dataTypesContainer.orientation = .vertical
dataTypesContainer.spacing = 8
dataTypesContainer.spacing = 6
dataTypesContainer.alignment = .leading
dataTypesContainer.addArrangedSubview(destinationTitleLabel)
dataTypesContainer.addArrangedSubview(destinationModeContainer)
@ -9246,13 +9428,14 @@ final class BrowserDataImportCoordinator {
dataTypesContainer.addArrangedSubview(cookiesCheckbox)
dataTypesContainer.addArrangedSubview(historyCheckbox)
dataTypesContainer.addArrangedSubview(additionalDataCheckbox)
dataTypesContainer.addArrangedSubview(additionalDataNoteLabel)
dataTypesContainer.addArrangedSubview(domainRow)
dataTypesContainer.addArrangedSubview(noteLabel)
}
private func configureInitialState() {
step = .source
refreshSourceProfilesList()
updateAdditionalDataNoteVisibility()
updateStepUI()
}
@ -9261,7 +9444,7 @@ final class BrowserDataImportCoordinator {
case .source:
stepLabel.stringValue = String(
localized: "browser.import.step.source",
defaultValue: "Step 1 of 3: Choose the browser to import from."
defaultValue: "Step 1 of 3"
)
sourceContainer.isHidden = false
sourceProfilesContainer.isHidden = true
@ -9271,11 +9454,8 @@ final class BrowserDataImportCoordinator {
primaryButton.title = String(localized: "browser.import.next", defaultValue: "Next")
case .sourceProfiles:
stepLabel.stringValue = String(
format: String(
localized: "browser.import.step.sourceProfiles",
defaultValue: "Step 2 of 3: Choose source profiles from %@."
),
selectedBrowser().displayName
localized: "browser.import.step.sourceProfiles",
defaultValue: "Step 2 of 3"
)
sourceContainer.isHidden = true
sourceProfilesContainer.isHidden = false
@ -9286,11 +9466,8 @@ final class BrowserDataImportCoordinator {
case .dataTypes:
rebuildStep3DestinationUI()
stepLabel.stringValue = String(
format: String(
localized: "browser.import.step.dataTypes",
defaultValue: "Step 3 of 3: Choose what to import from %@ and where to put it."
),
selectedBrowser().displayName
localized: "browser.import.step.dataTypes",
defaultValue: "Step 3 of 3"
)
sourceContainer.isHidden = true
sourceProfilesContainer.isHidden = true
@ -9302,6 +9479,7 @@ final class BrowserDataImportCoordinator {
defaultValue: "Start Import"
)
}
updatePanelSize()
}
private func selectedBrowser() -> InstalledBrowserCandidate {
@ -9328,6 +9506,7 @@ final class BrowserDataImportCoordinator {
browser.displayName
)
sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel)
updateSourceProfilesPresentation(for: browser)
return
}
@ -9343,6 +9522,8 @@ final class BrowserDataImportCoordinator {
sourceProfilesList.addArrangedSubview(checkbox)
sourceProfileCheckboxes.append(checkbox)
}
updateSourceProfilesPresentation(for: browser)
}
private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set<String> {
@ -9458,16 +9639,16 @@ final class BrowserDataImportCoordinator {
localized: "browser.import.destinationProfile.separateHelp",
defaultValue: "Missing cmux profiles are created when import starts."
)
destinationHelpLabel.isHidden = false
} else if plan.entries.count > 1 {
destinationHelpLabel.stringValue = String(
localized: "browser.import.destinationProfile.mergeHelp",
defaultValue: "All selected source profiles will be merged into the chosen cmux browser profile."
)
destinationHelpLabel.isHidden = false
} else {
destinationHelpLabel.stringValue = String(
localized: "browser.import.destinationProfile.help",
defaultValue: "Imported cookies and history go into the selected cmux browser profile."
)
destinationHelpLabel.stringValue = ""
destinationHelpLabel.isHidden = true
}
}
@ -9484,7 +9665,7 @@ final class BrowserDataImportCoordinator {
guard let sourceProfile = entry.sourceProfiles.first else { continue }
let sourceLabel = NSTextField(labelWithString: sourceProfile.displayName)
sourceLabel.alignment = .right
sourceLabel.frame.size.width = 140
sourceLabel.frame.size.width = 110
let popup = NSPopUpButton(frame: .zero, pullsDown: false)
popup.target = self
@ -9504,11 +9685,14 @@ final class BrowserDataImportCoordinator {
} else {
popup.selectItem(at: 0)
}
popup.setContentHuggingPriority(.defaultLow, for: .horizontal)
popup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let row = NSStackView(views: [sourceLabel, popup])
row.orientation = .horizontal
row.spacing = 8
row.spacing = 6
row.alignment = .centerY
row.distribution = .fill
separateDestinationRows.addArrangedSubview(row)
}
}
@ -9540,7 +9724,7 @@ final class BrowserDataImportCoordinator {
)
)
destinationLabel.alignment = .right
destinationLabel.frame.size.width = 140
destinationLabel.frame.size.width = 110
mergeDestinationRow.addArrangedSubview(destinationLabel)
mergeDestinationRow.addArrangedSubview(mergeDestinationPopup)
@ -9614,6 +9798,51 @@ final class BrowserDataImportCoordinator {
return base.isEmpty ? "profile-\(index)" : base
}
private func updateSourceProfilesPresentation(for browser: InstalledBrowserCandidate) {
let presentation = BrowserImportSourceProfilesPresentation(profileCount: browser.profiles.count)
sourceProfilesScrollHeightConstraint?.constant = presentation.scrollHeight
sourceProfilesHelpLabel.isHidden = !presentation.showsHelpText
}
private func updateAdditionalDataNoteVisibility() {
additionalDataNoteLabel.isHidden = additionalDataCheckbox.state != .on
}
private func updatePanelSize() {
let contentSize = preferredContentSize()
let targetFrame = panel.frameRect(forContentRect: NSRect(origin: .zero, size: contentSize))
guard panel.frame.size != targetFrame.size else { return }
if !panel.isVisible {
panel.setContentSize(contentSize)
return
}
var frame = panel.frame
frame.origin.x -= (targetFrame.width - frame.width) / 2
frame.origin.y -= (targetFrame.height - frame.height) / 2
frame.size = targetFrame.size
panel.setFrame(frame, display: true)
}
private func preferredContentSize() -> NSSize {
switch step {
case .source:
return NSSize(width: 560, height: 292)
case .sourceProfiles:
let presentation = BrowserImportSourceProfilesPresentation(profileCount: selectedBrowser().profiles.count)
let helpHeight: CGFloat = presentation.showsHelpText ? 24 : 0
let height = 214 + presentation.scrollHeight + helpHeight
return NSSize(width: 560, height: min(max(height, 292), 360))
case .dataTypes:
var height: CGFloat = currentExecutionPlan().mode == .separateProfiles ? 412 : 374
if additionalDataCheckbox.state == .on {
height += 24
}
return NSSize(width: 560, height: height)
}
}
private func finishModal(with response: NSApplication.ModalResponse) {
guard !didFinishModal else { return }
didFinishModal = true

View file

@ -110,6 +110,45 @@ enum BrowserDevToolsButtonDebugSettings {
}
}
enum BrowserToolbarAccessorySpacingDebugSettings {
static let key = "browserToolbarAccessorySpacing"
static let defaultSpacing = 2
static let supportedValues = [0, 2, 4, 6, 8]
static func resolved(_ rawValue: Int) -> Int {
supportedValues.contains(rawValue) ? rawValue : defaultSpacing
}
static func current(defaults: UserDefaults = .standard) -> Int {
resolved(defaults.object(forKey: key) as? Int ?? defaultSpacing)
}
}
enum BrowserProfilePopoverDebugSettings {
static let horizontalPaddingKey = "browserProfilePopoverHorizontalPadding"
static let verticalPaddingKey = "browserProfilePopoverVerticalPadding"
static let defaultHorizontalPadding = 12.0
static let defaultVerticalPadding = 10.0
static let horizontalPaddingRange = 8.0...20.0
static let verticalPaddingRange = 4.0...14.0
static func resolvedHorizontalPadding(_ rawValue: Double) -> Double {
horizontalPaddingRange.contains(rawValue) ? rawValue : defaultHorizontalPadding
}
static func resolvedVerticalPadding(_ rawValue: Double) -> Double {
verticalPaddingRange.contains(rawValue) ? rawValue : defaultVerticalPadding
}
static func currentHorizontalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedHorizontalPadding((defaults.object(forKey: horizontalPaddingKey) as? NSNumber)?.doubleValue ?? defaultHorizontalPadding)
}
static func currentVerticalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedVerticalPadding((defaults.object(forKey: verticalPaddingKey) as? NSNumber)?.doubleValue ?? defaultVerticalPadding)
}
}
struct OmnibarInlineCompletion: Equatable {
let typedText: String
let displayText: String
@ -249,7 +288,15 @@ struct BrowserPanelView: View {
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
private var browserProfilePopoverHorizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
private var browserProfilePopoverVerticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
@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 +314,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 +369,30 @@ 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 browserToolbarAccessorySpacing: CGFloat {
CGFloat(BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw))
}
private var browserProfilePopoverHorizontalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw))
}
private var browserProfilePopoverVerticalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw))
}
private var browserChromeBackground: Color {
Color(nsColor: browserChromeStyle.backgroundColor)
}
@ -346,6 +418,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 {
@ -451,6 +531,9 @@ struct BrowserPanelView: View {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing,
BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding,
BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
refreshBrowserChromeStyle()
@ -459,6 +542,22 @@ struct BrowserPanelView: View {
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
}
let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
if browserImportHintVariantRaw != resolvedHintVariant.rawValue {
browserImportHintVariantRaw = resolvedHintVariant.rawValue
}
let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing {
browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing
}
let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw)
if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding {
browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding
}
let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw)
if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding {
browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding
}
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
@ -613,9 +712,14 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
browserProfileButton
browserThemeModeButton
developerToolsButton
HStack(spacing: browserToolbarAccessorySpacing) {
if shouldShowToolbarImportHintChip {
browserImportHintToolbarChip
}
browserProfileButton
browserThemeModeButton
developerToolsButton
}
}
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
@ -776,6 +880,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"))
@ -819,6 +946,14 @@ struct BrowserPanelView: View {
}
.buttonStyle(.plain)
Button {
presentImportDialogFromProfileMenu()
} label: {
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
.font(.system(size: 12))
}
.buttonStyle(.plain)
if browserProfileStore.canRenameProfile(id: panel.profileID) {
Button {
isBrowserProfileMenuPresented = false
@ -830,7 +965,8 @@ struct BrowserPanelView: View {
.buttonStyle(.plain)
}
}
.padding(8)
.padding(.horizontal, browserProfilePopoverHorizontalPadding)
.padding(.vertical, browserProfilePopoverVerticalPadding)
.frame(minWidth: 208)
}
@ -1018,9 +1154,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 +1431,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 +1455,131 @@ 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)
.accessibilityIdentifier("BrowserImportHintImportButton")
}
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
// Let the popover fully dismiss before entering the modal import flow.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func presentImportDialogFromProfileMenu() {
isBrowserProfileMenuPresented = false
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func openBrowserImportSettings() {
isBrowserImportHintPopoverPresented = false
AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport)
}
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 }
@ -1518,8 +1765,9 @@ struct BrowserPanelView: View {
private func applyBrowserProfileSelection(_ profileID: UUID) {
isBrowserProfileMenuPresented = false
let didApply = panel.profileID == profileID || panel.switchToProfile(profileID)
guard didApply else { return }
owningWorkspace?.setPreferredBrowserProfileID(profileID)
_ = panel.switchToProfile(profileID)
}
private func presentCreateBrowserProfilePrompt() {

View file

@ -5303,7 +5303,8 @@ final class Workspace: Identifiable, ObservableObject {
return preferredProfileID
}
if let sourcePanelId,
let sourceBrowserPanel = browserPanel(for: sourcePanelId) {
let sourceBrowserPanel = browserPanel(for: sourcePanelId),
BrowserProfileStore.shared.profileDefinition(id: sourceBrowserPanel.profileID) != nil {
return sourceBrowserPanel.profileID
}
if let preferredBrowserProfileID,
@ -6644,7 +6645,6 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
@ -6668,6 +6668,7 @@ final class Workspace: Identifiable, ObservableObject {
panelTitles.removeValue(forKey: browserPanel.id)
return nil
}
setPreferredBrowserProfileID(browserPanel.profileID)
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
@ -6705,12 +6706,16 @@ final class Workspace: Identifiable, ObservableObject {
bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let sourcePanelId = effectiveSelectedPanelId(inPane: paneId)
let previousFocusedPanelId = focusedPanelId
let previousHostedView = focusedTerminalPanel?.hostedView
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID),
profileID: resolvedNewBrowserProfileID(
preferredProfileID: preferredProfileID,
sourcePanelId: sourcePanelId
),
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
proxyEndpoint: remoteProxyEndpoint,
@ -6719,7 +6724,6 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
guard let newTabId = bonsplitController.createTab(
title: browserPanel.displayTitle,
@ -6736,6 +6740,7 @@ final class Workspace: Identifiable, ObservableObject {
}
surfaceIdToPanelId[newTabId] = browserPanel.id
setPreferredBrowserProfileID(browserPanel.profileID)
// Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement.
if insertAtEnd {

View file

@ -28,6 +28,7 @@ struct cmuxApp: App {
@AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
@ -39,6 +40,10 @@ struct cmuxApp: App {
@AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
private var browserToolbarAccessorySpacing: Int {
BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
}
init() {
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
@ -337,6 +342,19 @@ struct cmuxApp: App {
DebugWindowControlsWindowController.shared.show()
}
Button("Browser Import Hint Debug…") {
BrowserImportHintDebugWindowController.shared.show()
}
Button(
String(
localized: "debug.menu.browserProfilePopoverDebug",
defaultValue: "Browser Profile Popover Debug…"
)
) {
BrowserProfilePopoverDebugWindowController.shared.show()
}
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.shared.show()
}
@ -361,6 +379,29 @@ struct cmuxApp: App {
}
}
Menu(
String(
localized: "debug.menu.browserToolbarButtonSpacing",
defaultValue: "Browser Toolbar Button Spacing"
)
) {
ForEach(BrowserToolbarAccessorySpacingDebugSettings.supportedValues, id: \.self) { spacing in
Button {
browserToolbarAccessorySpacingRaw = spacing
} label: {
if browserToolbarAccessorySpacing == spacing {
Label {
Text(verbatim: "\(spacing)")
} icon: {
Image(systemName: "checkmark")
}
} else {
Text(verbatim: "\(spacing)")
}
}
}
}
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
Toggle(
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
@ -588,7 +629,10 @@ struct cmuxApp: App {
}
Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) {
BrowserDataImportCoordinator.shared.presentImportDialog()
// Defer modal presentation until after AppKit finishes menu tracking.
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
}
splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) {
@ -1057,6 +1101,8 @@ struct cmuxApp: App {
}
private func openAllDebugWindows() {
BrowserImportHintDebugWindowController.shared.show()
BrowserProfilePopoverDebugWindowController.shared.show()
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
@ -1071,6 +1117,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
"cmux.browser-popup",
"cmux.settingsAboutTitlebarDebug",
"cmux.debugWindowControls",
"cmux.browserImportHintDebug",
"cmux.sidebarDebug",
"cmux.menubarDebug",
"cmux.backgroundDebug",
@ -1686,6 +1733,17 @@ private struct DebugWindowControlsView: View {
GroupBox("Open") {
VStack(alignment: .leading, spacing: 8) {
Button("Browser Import Hint Debug…") {
BrowserImportHintDebugWindowController.shared.show()
}
Button(
String(
localized: "debug.menu.browserProfilePopoverDebug",
defaultValue: "Browser Profile Popover Debug…"
)
) {
BrowserProfilePopoverDebugWindowController.shared.show()
}
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.shared.show()
}
@ -1699,6 +1757,8 @@ private struct DebugWindowControlsView: View {
MenuBarExtraDebugWindowController.shared.show()
}
Button("Open All Debug Windows") {
BrowserImportHintDebugWindowController.shared.show()
BrowserProfilePopoverDebugWindowController.shared.show()
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
@ -1902,6 +1962,411 @@ 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 final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BrowserProfilePopoverDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 340),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = String(
localized: "debug.windows.browserProfilePopover.title",
defaultValue: "Browser Profile Popover Debug"
)
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserProfilePopoverDebug")
window.center()
window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView())
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 BrowserProfilePopoverDebugView: View {
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
private var horizontalPaddingBinding: Binding<Double> {
Binding(
get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) },
set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) }
)
}
private var verticalPaddingBinding: Binding<Double> {
Binding(
get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) },
set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) }
)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(
String(
localized: "debug.browserProfilePopover.heading",
defaultValue: "Browser Profile Popover"
)
)
.font(.headline)
Text(
String(
localized: "debug.browserProfilePopover.note",
defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu."
)
)
.font(.caption)
.foregroundStyle(.secondary)
GroupBox(
String(
localized: "debug.browserProfilePopover.group.padding",
defaultValue: "Padding"
)
) {
VStack(alignment: .leading, spacing: 8) {
sliderRow(
String(
localized: "debug.browserProfilePopover.label.horizontal",
defaultValue: "Horizontal"
),
value: horizontalPaddingBinding,
range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange
)
sliderRow(
String(
localized: "debug.browserProfilePopover.label.vertical",
defaultValue: "Vertical"
),
value: verticalPaddingBinding,
range: BrowserProfilePopoverDebugSettings.verticalPaddingRange
)
}
.padding(.top, 2)
}
GroupBox(
String(
localized: "debug.browserProfilePopover.group.preview",
defaultValue: "Preview"
)
) {
profilePopoverPreview
.padding(.top, 2)
}
HStack(spacing: 12) {
Button(
String(
localized: "debug.browserProfilePopover.reset",
defaultValue: "Reset"
)
) {
horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
}
}
Text(
String(
localized: "debug.browserProfilePopover.liveNote",
defaultValue: "Changes apply live to the browser profile popover."
)
)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var profilePopoverPreview: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.frame(width: 12, alignment: .center)
Text(String(localized: "browser.profile.default", defaultValue: "Default"))
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.12))
)
}
Divider()
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
.font(.system(size: 12))
}
.padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw))
.padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw))
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.primary.opacity(0.08))
)
)
}
private func sliderRow(_ label: String, value: Binding<Double>, range: ClosedRange<Double>) -> some View {
HStack(spacing: 8) {
Text(label)
Slider(value: value, in: range, step: 1)
Text(String(format: "%.0f", value.wrappedValue))
.font(.caption)
.monospacedDigit()
.frame(width: 32, alignment: .trailing)
}
}
}
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") {
DispatchQueue.main.async {
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()
@ -2032,6 +2497,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
enum SettingsNavigationTarget: String {
case browser
case browserImport
case keyboardShortcuts
}
@ -3100,6 +3567,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()
@ -3146,6 +3616,7 @@ struct SettingsView: View {
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@ -3202,6 +3673,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 },
@ -3264,6 +3759,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
}
@ -4196,6 +4702,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"),
@ -4370,14 +4878,48 @@ 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()
refreshDetectedImportBrowsers()
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
refreshDetectedImportBrowsers()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityIdentifier("SettingsBrowserImportChooseButton")
Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {
refreshDetectedImportBrowsers()
@ -4385,7 +4927,24 @@ 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)
}
.id(SettingsNavigationTarget.browserImport)
.accessibilityIdentifier("SettingsBrowserImportSection")
.padding(.horizontal, 14)
.padding(.vertical, 10)
SettingsCardDivider()
@ -4529,6 +5088,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()
@ -4642,6 +5202,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

@ -2693,6 +2693,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(activateApplicationCallCount, 1)
}
func testPresentPreferencesWindowForwardsBrowserImportNavigationTarget() {
var receivedNavigationTarget: SettingsNavigationTarget?
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
navigationTarget: .browserImport,
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTarget = navigationTarget
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(receivedNavigationTarget, .browserImport)
XCTAssertEqual(activateApplicationCallCount, 1)
}
private func makeKeyDownEvent(
key: String,
modifiers: NSEvent.ModifierFlags,

View file

@ -127,6 +127,68 @@ final class BrowserImportMappingTests: XCTestCase {
XCTAssertTrue(presentation.showsSingleDestinationPicker)
}
func testSourceProfilesPresentationShrinksListForSmallProfileCounts() {
let presentation = BrowserImportSourceProfilesPresentation(profileCount: 2)
XCTAssertEqual(presentation.scrollHeight, 76)
XCTAssertTrue(presentation.showsHelpText)
}
func testSourceProfilesPresentationCapsListHeightAndHidesHelpForSingleProfile() {
let singleProfilePresentation = BrowserImportSourceProfilesPresentation(profileCount: 1)
let manyProfilesPresentation = BrowserImportSourceProfilesPresentation(profileCount: 9)
XCTAssertEqual(singleProfilePresentation.scrollHeight, 76)
XCTAssertFalse(singleProfilePresentation.showsHelpText)
XCTAssertEqual(manyProfilesPresentation.scrollHeight, 144)
XCTAssertTrue(manyProfilesPresentation.showsHelpText)
}
func testBrowserImportHintSettingsDefaultToToolbarChip() throws {
let suiteName = "BrowserImportHintDefaults-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let presentation = BrowserImportHintSettings.presentation(defaults: defaults)
XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip)
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)"
@ -222,6 +284,39 @@ final class BrowserImportMappingTests: XCTestCase {
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
}
@MainActor
func testImportWizardCanBeConstructedForSettingsChoosePath() {
let destinationProfiles = [
BrowserProfileDefinition(
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
displayName: "Default",
createdAt: .distantPast,
isBuiltInDefault: true
)
]
let browser = makeInstalledBrowserCandidate(
descriptorID: "google-chrome",
displayName: "Chrome",
profiles: [
makeSourceProfile(displayName: "Default", path: "/tmp/browser-import-chrome-default", isDefault: true),
makeSourceProfile(displayName: "Profile 1", path: "/tmp/browser-import-chrome-profile-1", isDefault: false),
]
)
let window = BrowserDataImportCoordinator.shared.debugMakeImportWizardWindow(
browsers: [browser],
destinationProfiles: destinationProfiles,
defaultDestinationProfileID: destinationProfiles[0].id
)
defer {
window.orderOut(nil)
window.close()
}
XCTAssertEqual(window.title, "Import Browser Data")
XCTAssertNotNil(window.contentView)
}
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
InstalledBrowserProfile(
displayName: displayName,
@ -229,4 +324,32 @@ final class BrowserImportMappingTests: XCTestCase {
isDefault: isDefault
)
}
private func makeInstalledBrowserCandidate(
descriptorID: String,
displayName: String,
profiles: [InstalledBrowserProfile]
) -> InstalledBrowserCandidate {
let descriptor = try! XCTUnwrap(InstalledBrowserDetector.allBrowserDescriptors.first(where: { $0.id == descriptorID }))
return InstalledBrowserCandidate(
descriptor: BrowserImportBrowserDescriptor(
id: descriptor.id,
displayName: displayName,
family: descriptor.family,
tier: descriptor.tier,
bundleIdentifiers: descriptor.bundleIdentifiers,
appNames: descriptor.appNames,
dataRootRelativePaths: descriptor.dataRootRelativePaths,
dataArtifactRelativePaths: descriptor.dataArtifactRelativePaths,
supportsDataOnlyDetection: descriptor.supportsDataOnlyDetection
),
resolvedFamily: descriptor.family,
homeDirectoryURL: URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true),
appURL: nil,
dataRootURL: URL(fileURLWithPath: "/tmp/browser-import-\(descriptorID)", isDirectory: true),
profiles: profiles,
detectionSignals: ["test"],
detectionScore: 1
)
}
}

View file

@ -65,6 +65,15 @@ private func drainMainQueue() {
XCTWaiter().wait(for: [expectation], timeout: 1.0)
}
@MainActor
private func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition {
try XCTUnwrap(
BrowserProfileStore.shared.createProfile(
named: "\(prefix)-\(UUID().uuidString)"
)
)
}
final class SplitShortcutTransientFocusGuardTests: XCTestCase {
func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
XCTAssertTrue(
@ -1461,6 +1470,56 @@ final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
)
}
func testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() {
let defaults = makeIsolatedDefaults()
defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
XCTAssertEqual(
BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
)
}
func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() {
let defaults = makeIsolatedDefaults()
defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key)
XCTAssertEqual(
BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults),
BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
)
}
func testBrowserProfilePopoverPaddingDefaultsWhenUnset() {
let defaults = makeIsolatedDefaults()
defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
XCTAssertEqual(
BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
)
XCTAssertEqual(
BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
BrowserProfilePopoverDebugSettings.defaultVerticalPadding
)
}
func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() {
let defaults = makeIsolatedDefaults()
defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey)
XCTAssertEqual(
BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults),
BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
)
XCTAssertEqual(
BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults),
BrowserProfilePopoverDebugSettings.defaultVerticalPadding
)
}
func testCopyPayloadUsesPersistedValues() {
let defaults = makeIsolatedDefaults()
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
@ -6362,6 +6421,129 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
}
}
@MainActor
final class WorkspaceBrowserProfileSelectionTests: XCTestCase {
private final class RejectingCreateTabDelegate: BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool {
false
}
}
private final class RejectingSplitPaneDelegate: BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool {
false
}
}
func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws {
let workspace = Workspace()
let profileA = try makeTemporaryBrowserProfile(named: "Alpha")
let profileB = try makeTemporaryBrowserProfile(named: "Beta")
let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
let browserA = try XCTUnwrap(
workspace.newBrowserSurface(
inPane: paneId,
focus: true,
preferredProfileID: profileA.id
)
)
_ = try XCTUnwrap(
workspace.newBrowserSplit(
from: browserA.id,
orientation: .horizontal,
preferredProfileID: profileB.id,
focus: true
)
)
XCTAssertEqual(
workspace.preferredBrowserProfileID,
profileB.id,
"Expected workspace preference to drift to the most recently created browser profile"
)
let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id))
workspace.bonsplitController.focusPane(paneId)
workspace.bonsplitController.selectTab(leftSurfaceId)
let created = try XCTUnwrap(
workspace.newBrowserSurface(
inPane: paneId,
focus: false
)
)
XCTAssertEqual(
created.profileID,
profileA.id,
"Expected new browser creation to inherit the selected browser profile from the target pane"
)
}
func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws {
let workspace = Workspace()
let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
_ = try XCTUnwrap(
workspace.newBrowserSurface(
inPane: paneId,
focus: false,
preferredProfileID: preferredProfile.id
)
)
XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
let rejectingDelegate = RejectingCreateTabDelegate()
workspace.bonsplitController.delegate = rejectingDelegate
let created = workspace.newBrowserSurface(
inPane: paneId,
focus: false,
preferredProfileID: unexpectedProfile.id
)
XCTAssertNil(created)
XCTAssertEqual(
workspace.preferredBrowserProfileID,
preferredProfile.id,
"Expected a failed browser creation to leave the workspace preferred profile unchanged"
)
}
func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws {
let workspace = Workspace()
let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred")
let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected")
let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId)
let browser = try XCTUnwrap(
workspace.newBrowserSurface(
inPane: paneId,
focus: true,
preferredProfileID: preferredProfile.id
)
)
XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id)
let rejectingDelegate = RejectingSplitPaneDelegate()
workspace.bonsplitController.delegate = rejectingDelegate
let created = workspace.newBrowserSplit(
from: browser.id,
orientation: .horizontal,
preferredProfileID: unexpectedProfile.id,
focus: false
)
XCTAssertNil(created)
XCTAssertEqual(
workspace.preferredBrowserProfileID,
preferredProfile.id,
"Expected a failed browser split to leave the workspace preferred profile unchanged"
)
}
}
@MainActor
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
func testUsesFocusedTerminalWhenTerminalIsFocused() {
@ -6419,6 +6601,52 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
}
}
@MainActor
final class BrowserPanelProfileIsolationTests: XCTestCase {
func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws {
let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched")
let defaultStore = BrowserHistoryStore.shared
let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id)
defaultStore.clearHistory()
alternateStore.clearHistory()
defer {
defaultStore.clearHistory()
alternateStore.clearHistory()
}
let panel = BrowserPanel(
workspaceId: UUID(),
profileID: BrowserProfileStore.shared.builtInDefaultProfileID
)
let staleWebView = panel.webView
let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate)
let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish"))
staleWebView.loadHTMLString(
"<html><head><title>Stale</title></head><body>stale</body></html>",
baseURL: staleURL
)
XCTAssertTrue(
panel.switchToProfile(alternateProfile.id),
"Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)"
)
defaultStore.clearHistory()
alternateStore.clearHistory()
staleDelegate.webView?(staleWebView, didFinish: nil)
drainMainQueue()
XCTAssertTrue(
defaultStore.entries.isEmpty,
"Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })"
)
XCTAssertTrue(
alternateStore.entries.isEmpty,
"Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })"
)
}
}
@MainActor
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {

View file

@ -2540,16 +2540,20 @@ final class BrowserInstallDetectorTests: XCTestCase {
return
}
XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"])
XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"]))
XCTAssertEqual(
safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(),
safari.profiles
.map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) }
.sorted(),
[
home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent("Library/Safari", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
isDirectory: true
).path(percentEncoded: false),
).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
].sorted()
)
}
@ -2560,7 +2564,12 @@ final class BrowserInstallDetectorTests: XCTestCase {
private func createFile(at url: URL, contents: Data) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
_ = FileManager.default.createFile(atPath: url.path, contents: contents)
guard FileManager.default.createFile(atPath: url.path, contents: contents) else {
throw CocoaError(
.fileWriteUnknown,
userInfo: [NSFilePathErrorKey: url.path]
)
}
}
}

View file

@ -184,7 +184,7 @@ final class SessionPersistenceTests: XCTestCase {
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")
let profileID = try XCTUnwrap(UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64"))
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
profileID: profileID,

View file

@ -1,6 +1,23 @@
import XCTest
import Foundation
private func browserImportPollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class BrowserImportProfilesUITests: XCTestCase {
private var capturePath = ""
@ -14,15 +31,14 @@ final class BrowserImportProfilesUITests: XCTestCase {
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
XCTAssertTrue(
app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0),
app.radioButtons["Separate profiles"].waitForExistence(timeout: 5.0),
"Expected Step 3 to show the separate-profiles default"
)
XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists)
XCTAssertTrue(app.radioButtons["Merge into one"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
@ -45,11 +61,10 @@ final class BrowserImportProfilesUITests: XCTestCase {
func testMergeModeCapturesSingleMergedDestination() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
let mergeRadio = app.radioButtons["Merge all into one cmux profile"]
let mergeRadio = app.radioButtons["Merge into one"]
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
mergeRadio.click()
@ -73,7 +88,6 @@ final class BrowserImportProfilesUITests: XCTestCase {
func testAdditionalDataSelectionCapturesEverythingScope() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
@ -98,6 +112,45 @@ 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()
let importSection = app.otherElements["SettingsBrowserImportSection"]
XCTAssertTrue(
importSection.waitForExistence(timeout: 5.0),
"Expected Browser Settings to scroll to the import section"
)
let chooseButton = app.buttons["SettingsBrowserImportChooseButton"]
XCTAssertTrue(
chooseButton.waitForExistence(timeout: 5.0),
"Expected Browser Settings to expose the import actions"
)
XCTAssertTrue(
browserImportPollUntil(timeout: 5.0) {
importSection.isHittable && chooseButton.isHittable
},
"Expected Browser Settings to scroll directly to the import controls"
)
}
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"
@ -105,55 +158,79 @@ final class BrowserImportProfilesUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch in the foreground for browser import UI tests"
)
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)
openImportWizardFromBlankImportHint(app)
return app
}
private func openImportWizard(_ app: XCUIApplication) {
let viewMenu = app.menuBars.menuBarItems["View"].firstMatch
XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist")
viewMenu.click()
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
}
let importItem = app.menuItems["Import From Browser…"].firstMatch
XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist")
importItem.click()
private func waitForImportWizard(_ app: XCUIApplication) {
let wizardOpened = browserImportPollUntil(timeout: 5.0) {
app.buttons["Next"].exists || app.windows["Import Browser Data"].exists
}
XCTAssertTrue(wizardOpened, "Expected the import wizard to open")
}
XCTAssertTrue(
app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0),
"Expected the import wizard to open"
)
private func waitForBlankImportHint(_ app: XCUIApplication) {
let hintOpened = browserImportPollUntil(timeout: 5.0) {
app.buttons["BrowserImportHintImportButton"].exists
}
XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear")
}
private func openImportWizardFromBlankImportHint(_ app: XCUIApplication) {
waitForBlankImportHint(app)
let importButton = app.buttons["BrowserImportHintImportButton"]
XCTAssertTrue(importButton.waitForExistence(timeout: 5.0))
importButton.click()
waitForImportWizard(app)
}
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
let deadline = Date().addingTimeInterval(timeout)
let url = URL(fileURLWithPath: capturePath)
while Date() < deadline {
let foundCapture = browserImportPollUntil(timeout: timeout) {
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return object
return !object.isEmpty
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
guard let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
if foundCapture,
let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return object
}
return object
return nil
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch()
let activated = browserImportPollUntil(timeout: activateTimeout) {
guard app.state != .runningForeground else {
return true
}
app.activate()
return app.state == .runningForeground
}
if !activated {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
}