diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 136fcea1..9cf30d6c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 26b1d4d4..cb6a838d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 38e908f5..8146541b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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) { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5a214652..fefa2253 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 { @@ -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 diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 596820de..136cb802 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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? @@ -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() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 045ca8dd..d8e75ea2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9e76784d..c0700045 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 = [ "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 { + Binding( + get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) }, + set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) } + ) + } + + private var verticalPaddingBinding: Binding { + 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, range: ClosedRange) -> 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 { + Binding( + get: { selectedVariant.rawValue }, + set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue } + ) + } + + private var showOnBlankTabsBinding: Binding { + Binding( + get: { showOnBlankTabs }, + set: { newValue in + showOnBlankTabs = newValue + if newValue { + isDismissed = false + } + } + ) + } + + private var presentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: selectedVariant, + showOnBlankTabs: showOnBlankTabs, + isDismissed: isDismissed + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Browser Import Hint") + .font(.headline) + + Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.") + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox("Variant") { + VStack(alignment: .leading, spacing: 10) { + Picker("Blank Tab Style", selection: variantSelection) { + ForEach(BrowserImportHintVariant.allCases) { variant in + Text(title(for: variant)).tag(variant.rawValue) + } + } + .pickerStyle(.menu) + + Text(description(for: selectedVariant)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + + GroupBox("State") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding) + Toggle("Pretend the user dismissed it", isOn: $isDismissed) + + Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + + GroupBox("Quick Actions") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button("Open Browser Settings") { + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + Button("Open Import Dialog") { + 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 { + Binding( + get: { showBrowserImportHintOnBlankTabs }, + set: { newValue in + showBrowserImportHintOnBlankTabs = newValue + if newValue { + isBrowserImportHintDismissed = false + } + } + ) + } + private var socketModeSelection: Binding { 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 diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index b9c25ae7..4c320f12 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -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, diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 1f6c662c..58ccf28e 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -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 + ) + } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5ec9aae7..71a728d2 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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( + "Stalestale", + 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() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 628f365c..5cca92b3 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -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] + ) + } } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 68119b7f..7d04db1d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -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, diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index eca6d360..d959de30 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -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 } }