From e2ddf9214ceb126ade119c293677463cac0450f5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:12:31 -0700 Subject: [PATCH 01/12] test: cover browser profile follow-up regressions --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c787a69a..75f32d3b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6263,6 +6263,99 @@ 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 func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) + } + + func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { + let workspace = Workspace() + let profileA = try makeProfile(named: "Alpha") + let profileB = try makeProfile(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 makeProfile(named: "Preferred") + let unexpectedProfile = try makeProfile(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" + ) + } +} + @MainActor final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { func testUsesFocusedTerminalWhenTerminalIsFocused() { @@ -6320,6 +6413,52 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { } } +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) + } + + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeProfile(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()) + 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)) + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store" + ) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { From fdde470dcfc36568ecf6cebe20f8a1c79402609a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:50:43 -0700 Subject: [PATCH 02/12] fix: address browser profile review follow-ups --- Resources/Localizable.xcstrings | 8 +-- Sources/Panels/BrowserPanel.swift | 54 ++++++++++--------- Sources/Panels/BrowserPanelView.swift | 3 +- Sources/Workspace.swift | 10 ++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 +++++++++++++++++-- cmuxTests/GhosttyConfigTests.swift | 18 ++++--- cmuxTests/SessionPersistenceTests.swift | 2 +- .../BrowserImportProfilesUITests.swift | 7 +-- 8 files changed, 107 insertions(+), 49 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0396f6e9..68b84487 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4797,7 +4797,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザ: %@" + "value": "ブラウザー: %@" } } } @@ -37876,7 +37876,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む…" + "value": "ブラウザーから取り込む…" } } } @@ -50658,7 +50658,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザデータを取り込む" + "value": "ブラウザーデータを取り込む" } } } @@ -50788,7 +50788,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ブラウザから取り込む" + "value": "ブラウザーから取り込む" } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7af68022..c7a17f4d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2354,11 +2354,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 } @@ -2389,30 +2419,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) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 596820de..f0b16dc1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1518,8 +1518,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 ae6831e8..25a7173d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2326,7 +2326,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( @@ -2350,6 +2349,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 @@ -2386,16 +2386,19 @@ final class Workspace: Identifiable, ObservableObject { bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) let browserPanel = BrowserPanel( workspaceId: id, - profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID), + profileID: resolvedNewBrowserProfileID( + preferredProfileID: preferredProfileID, + sourcePanelId: sourcePanelId + ), initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle - setPreferredBrowserProfileID(browserPanel.profileID) guard let newTabId = bonsplitController.createTab( title: browserPanel.displayTitle, @@ -2412,6 +2415,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/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 75f32d3b..67f8cadf 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6271,6 +6271,12 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { } } + private final class RejectingSplitPaneDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + false + } + } + private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { try XCTUnwrap( BrowserProfileStore.shared.createProfile( @@ -6354,6 +6360,38 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { "Expected a failed browser creation to leave the workspace preferred profile unchanged" ) } + + func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeProfile(named: "Preferred") + let unexpectedProfile = try makeProfile(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 @@ -6434,7 +6472,10 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { alternateStore.clearHistory() } - let panel = BrowserPanel(workspaceId: UUID()) + 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")) @@ -6443,18 +6484,23 @@ final class BrowserPanelProfileIsolationTests: XCTestCase { baseURL: staleURL ) - XCTAssertTrue(panel.switchToProfile(alternateProfile.id)) + 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" + "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" + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" ) } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index cab7d0f7..9ac2a8f7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2004,16 +2004,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() ) } @@ -2024,7 +2028,9 @@ 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) + } } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 2b72a440..6f5c7b1d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -150,7 +150,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..cc28d425 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -138,12 +138,7 @@ final class BrowserImportProfilesUITests: XCTestCase { } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - - guard let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return object + return nil } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { From aac8a41ba237a04d94db5275f23aae0375861a6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 00:50:02 -0700 Subject: [PATCH 03/12] Fix browser import follow-up review comments --- Sources/Workspace.swift | 3 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 39 ++++++++----------- cmuxTests/GhosttyConfigTests.swift | 5 ++- .../BrowserImportProfilesUITests.swift | 20 ++++++++++ 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 25a7173d..a14cbf69 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1388,7 +1388,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, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 67f8cadf..6ce551a0 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( @@ -6277,18 +6286,10 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { } } - private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) - } - func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { let workspace = Workspace() - let profileA = try makeProfile(named: "Alpha") - let profileB = try makeProfile(named: "Beta") + 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( @@ -6332,8 +6333,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { let workspace = Workspace() - let preferredProfile = try makeProfile(named: "Preferred") - let unexpectedProfile = try makeProfile(named: "Unexpected") + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) _ = try XCTUnwrap( @@ -6363,8 +6364,8 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { let workspace = Workspace() - let preferredProfile = try makeProfile(named: "Preferred") - let unexpectedProfile = try makeProfile(named: "Unexpected") + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) let browser = try XCTUnwrap( @@ -6453,16 +6454,8 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { @MainActor final class BrowserPanelProfileIsolationTests: XCTestCase { - private func makeProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) - } - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeProfile(named: "Switched") + let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") let defaultStore = BrowserHistoryStore.shared let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) defaultStore.clearHistory() diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9ac2a8f7..4ceb61d1 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2029,7 +2029,10 @@ final class BrowserInstallDetectorTests: XCTestCase { private func createFile(at url: URL, contents: Data) throws { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) guard FileManager.default.createFile(atPath: url.path, contents: contents) else { - throw CocoaError(.fileWriteUnknown) + throw CocoaError( + .fileWriteUnknown, + userInfo: [NSFilePathErrorKey: url.path] + ) } } } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index cc28d425..feb55471 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -98,6 +98,22 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } + func testWaitForCapturedSelectionReadsCaptureWrittenAtTimeoutBoundary() throws { + let payload: [String: Any] = [ + "mode": "boundary-write", + "entries": [] + ] + let payloadData = try JSONSerialization.data(withJSONObject: payload) + let captureURL = URL(fileURLWithPath: capturePath) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.19) { + try? payloadData.write(to: captureURL) + } + + let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 0.2)) + XCTAssertEqual(capture["mode"] as? String, "boundary-write") + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -138,6 +154,10 @@ final class BrowserImportProfilesUITests: XCTestCase { } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return object + } return nil } From ffcd3fdfaa84b4dc5b33e4d7ff7e8573fa1f2390 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 01:51:57 -0700 Subject: [PATCH 04/12] Tighten browser import sheet UI --- Resources/Localizable.xcstrings | 60 ++--- Sources/Panels/BrowserPanel.swift | 208 +++++++++++++----- cmuxTests/BrowserImportMappingTests.swift | 17 ++ .../BrowserImportProfilesUITests.swift | 6 +- 4 files changed, 202 insertions(+), 89 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 68b84487..257a60d5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -4740,13 +4740,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": "ブックマーク、設定、拡張機能はまだ利用できません。" } } } @@ -5029,13 +5029,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux destination" + "value": "Destination" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux の保存先" + "value": "保存先" } } } @@ -5080,13 +5080,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 +5097,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 +5114,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 +5131,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 +5148,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Keep profiles separate" + "value": "Separate profiles" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プロファイルを分けたまま取り込む" + "value": "分けて取り込む" } } } @@ -5233,13 +5233,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Limit to" + "value": "Domains" } }, "ja": { "stringUnit": { "state": "translated", - "value": "対象ドメイン" + "value": "ドメイン" } } } @@ -5250,13 +5250,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 +5505,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Source" + "value": "Browser" } }, "ja": { "stringUnit": { "state": "translated", - "value": "インポート元" + "value": "ブラウザー" } } } @@ -5539,13 +5539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Source Profiles" + "value": "Profiles" } }, "ja": { "stringUnit": { "state": "translated", - "value": "元プロファイル" + "value": "プロファイル" } } } @@ -5556,13 +5556,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 +5607,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 +5624,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 +5641,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" } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index c7a17f4d..9e2e5504 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -6837,6 +6837,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( @@ -8378,6 +8390,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() @@ -8387,6 +8400,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) @@ -8412,7 +8426,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 @@ -8538,6 +8552,7 @@ final class BrowserDataImportCoordinator { guard selectedSourceProfiles.count > 1 else { return } destinationMode = sender == separateProfilesRadio ? .separateProfiles : .mergeIntoOne rebuildStep3DestinationUI() + updatePanelSize() } @objc @@ -8560,6 +8575,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", @@ -8570,7 +8592,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 @@ -8580,9 +8602,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() @@ -8594,6 +8616,7 @@ final class BrowserDataImportCoordinator { validationLabel.isHidden = true validationLabel.lineBreakMode = .byWordWrapping validationLabel.maximumNumberOfLines = 3 + validationLabel.translatesAutoresizingMaskIntoConstraints = false backButton.target = self backButton.action = #selector(handleBack) @@ -8631,23 +8654,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), ]) } @@ -8663,23 +8695,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) @@ -8692,17 +8728,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 @@ -8721,19 +8757,22 @@ 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 + sourceProfilesScrollView.widthAnchor.constraint(equalTo: sourceProfilesContainer.widthAnchor).isActive = true - 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) @@ -8758,6 +8797,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") @@ -8782,25 +8827,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( @@ -8808,32 +8857,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) @@ -8843,13 +8892,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() } @@ -8858,7 +8908,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 @@ -8868,11 +8918,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 @@ -8883,11 +8930,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 @@ -8899,6 +8943,7 @@ final class BrowserDataImportCoordinator { defaultValue: "Start Import" ) } + updatePanelSize() } private func selectedBrowser() -> InstalledBrowserCandidate { @@ -8925,6 +8970,7 @@ final class BrowserDataImportCoordinator { browser.displayName ) sourceProfilesList.addArrangedSubview(sourceProfilesEmptyLabel) + updateSourceProfilesPresentation(for: browser) return } @@ -8940,6 +8986,8 @@ final class BrowserDataImportCoordinator { sourceProfilesList.addArrangedSubview(checkbox) sourceProfileCheckboxes.append(checkbox) } + + updateSourceProfilesPresentation(for: browser) } private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set { @@ -9055,16 +9103,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 } } @@ -9081,7 +9129,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 @@ -9101,11 +9149,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) } } @@ -9137,7 +9188,7 @@ final class BrowserDataImportCoordinator { ) ) destinationLabel.alignment = .right - destinationLabel.frame.size.width = 140 + destinationLabel.frame.size.width = 110 mergeDestinationRow.addArrangedSubview(destinationLabel) mergeDestinationRow.addArrangedSubview(mergeDestinationPopup) @@ -9211,6 +9262,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/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 1f6c662c..2f122921 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -127,6 +127,23 @@ 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) + } + @MainActor func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index feb55471..8ba0e7d6 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -19,10 +19,10 @@ final class BrowserImportProfilesUITests: XCTestCase { 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) @@ -49,7 +49,7 @@ final class BrowserImportProfilesUITests: XCTestCase { 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() From 3c549b4cb8bca8dfd5f8401c0d5244831dba33fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:06:50 -0700 Subject: [PATCH 05/12] Fix browser import UI test harness --- .../BrowserImportProfilesUITests.swift | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 8ba0e7d6..a5b4df7a 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 = "" @@ -121,11 +138,7 @@ 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" - ) + launchAndActivate(app) return app } @@ -145,30 +158,33 @@ final class BrowserImportProfilesUITests: XCTestCase { } 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 } - if let data = try? Data(contentsOf: url), + if foundCapture, + let data = try? Data(contentsOf: url), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { 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 } } From 7bb75647264dd8a4461727c3f902c32e7b73f061 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:18:58 -0700 Subject: [PATCH 06/12] Stabilize browser import menu test flow --- Sources/cmuxApp.swift | 5 ++++- cmuxUITests/BrowserImportProfilesUITests.swift | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2b9fb5ec..d58503a2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -588,7 +588,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) { diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index a5b4df7a..1edf69a3 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -123,7 +123,7 @@ final class BrowserImportProfilesUITests: XCTestCase { let payloadData = try JSONSerialization.data(withJSONObject: payload) let captureURL = URL(fileURLWithPath: capturePath) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.19) { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.18) { try? payloadData.write(to: captureURL) } @@ -151,10 +151,10 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist") importItem.click() - XCTAssertTrue( - app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0), - "Expected the import wizard to open" - ) + let wizardOpened = browserImportPollUntil(timeout: 5.0) { + app.buttons["Next"].exists || app.windows["Import Browser Data"].exists + } + XCTAssertTrue(wizardOpened, "Expected the import wizard to open") } private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { From f97716939a04ef190909aa2751c2d364f0a3ba1c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 02:29:39 -0700 Subject: [PATCH 07/12] Add browser import UI test launch hook --- Sources/AppDelegate.swift | 5 +++ .../BrowserImportProfilesUITests.swift | 31 ++----------------- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a77cf00a..b84d7c57 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2314,6 +2314,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } + if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } } #endif } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 1edf69a3..c8d95f08 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -31,7 +31,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testMultipleSourceProfilesDefaultToSeparateDestinations() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -62,7 +61,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testMergeModeCapturesSingleMergedDestination() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -90,7 +88,6 @@ final class BrowserImportProfilesUITests: XCTestCase { func testAdditionalDataSelectionCapturesEverythingScope() throws { let app = launchApp() - openImportWizard(app) app.buttons["Next"].click() app.buttons["Next"].click() @@ -115,42 +112,20 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } - func testWaitForCapturedSelectionReadsCaptureWrittenAtTimeoutBoundary() throws { - let payload: [String: Any] = [ - "mode": "boundary-write", - "entries": [] - ] - let payloadData = try JSONSerialization.data(withJSONObject: payload) - let captureURL = URL(fileURLWithPath: capturePath) - - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.18) { - try? payloadData.write(to: captureURL) - } - - let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 0.2)) - XCTAssertEqual(capture["mode"] as? String, "boundary-write") - } - private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] = "1" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# 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 launchAndActivate(app) + waitForImportWizard(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() - - 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 } From b9de0f044642eeea10b0e76fa78dfd05676e9227 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:01:50 -0700 Subject: [PATCH 08/12] Add browser import hint debug variants --- Resources/Localizable.xcstrings | 187 +++++++++++ Sources/AppDelegate.swift | 32 ++ Sources/Panels/BrowserPanel.swift | 105 ++++++ Sources/Panels/BrowserPanelView.swift | 194 +++++++++-- Sources/cmuxApp.swift | 307 +++++++++++++++++- cmuxTests/BrowserImportMappingTests.swift | 44 +++ .../BrowserImportProfilesUITests.swift | 45 +++ 7 files changed, 892 insertions(+), 22 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 257a60d5..0f8f83df 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5669,6 +5669,125 @@ } } }, + "browser.import.hint.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Hint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヒントを隠す" + } + } + } + }, + "browser.import.hint.import": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート…" + } + } + } + }, + "browser.import.hint.settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザー設定" + } + } + } + }, + "browser.import.hint.settingsFootnote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can always find this in Settings > Browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あとでいつでも「設定 > ブラウザー」で見つけられます。" + } + } + } + }, + "browser.import.hint.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, + "browser.import.hint.toolbar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インポート" + } + } + } + }, + "browser.import.hint.toolbar.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import browser data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザーデータをインポート" + } + } + } + }, "browser.import.validation.scope": { "extractionState": "manual", "localizations": { @@ -50827,6 +50946,74 @@ } } }, + "settings.browser.import.hint.note.hidden": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The blank-tab import hint is hidden. Turn it back on here any time." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空タブのインポート案内は非表示です。ここでいつでも再表示できます。" + } + } + } + }, + "settings.browser.import.hint.note.settingsOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank tabs are currently using Settings only mode from the debug window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在、空タブはデバッグウィンドウの「設定のみ」モードになっています。" + } + } + } + }, + "settings.browser.import.hint.note.visible": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blank browser tabs can show this import suggestion. Hide or re-enable it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにこのインポート案内を表示できます。ここで非表示や再表示を切り替えられます。" + } + } + } + }, + "settings.browser.import.hint.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show import hint on blank browser tabs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空のブラウザータブにインポート案内を表示" + } + } + } + }, "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b84d7c57..7b5abd04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2306,6 +2306,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. // If there are no windows shortly after launch, force-create one so XCUITest can proceed. if isRunningUnderXCTest { + if let rawVariant = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] { + UserDefaults.standard.set( + BrowserImportHintSettings.variant(for: rawVariant).rawValue, + forKey: BrowserImportHintSettings.variantKey + ) + } + if let rawShow = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self else { return } if NSApp.windows.isEmpty { @@ -2314,6 +2332,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser + ) + } + } if env["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { BrowserDataImportCoordinator.shared.presentImportDialog() diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9e2e5504..dc943d31 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -198,6 +198,111 @@ enum BrowserThemeSettings { } } +enum BrowserImportHintVariant: String, CaseIterable, Identifiable { + case inlineStrip + case floatingCard + case toolbarChip + case settingsOnly + + var id: String { rawValue } +} + +enum BrowserImportHintBlankTabPlacement: Equatable { + case hidden + case inlineStrip + case floatingCard + case toolbarChip +} + +enum BrowserImportHintSettingsStatus: Equatable { + case visible + case hidden + case settingsOnly +} + +struct BrowserImportHintPresentation: Equatable { + let blankTabPlacement: BrowserImportHintBlankTabPlacement + let settingsStatus: BrowserImportHintSettingsStatus + + init( + variant: BrowserImportHintVariant, + showOnBlankTabs: Bool, + isDismissed: Bool + ) { + if variant == .settingsOnly { + blankTabPlacement = .hidden + settingsStatus = .settingsOnly + return + } + + if !showOnBlankTabs || isDismissed { + blankTabPlacement = .hidden + settingsStatus = .hidden + return + } + + switch variant { + case .inlineStrip: + blankTabPlacement = .inlineStrip + case .floatingCard: + blankTabPlacement = .floatingCard + case .toolbarChip: + blankTabPlacement = .toolbarChip + case .settingsOnly: + blankTabPlacement = .hidden + } + settingsStatus = .visible + } +} + +enum BrowserImportHintSettings { + static let variantKey = "browserImportHintVariant" + static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" + static let dismissedKey = "browserImportHintDismissed" + static let defaultVariant: BrowserImportHintVariant = .inlineStrip + static let defaultShowOnBlankTabs = true + static let defaultDismissed = false + + static func variant(for rawValue: String?) -> BrowserImportHintVariant { + guard let rawValue, let variant = BrowserImportHintVariant(rawValue: rawValue) else { + return defaultVariant + } + return variant + } + + static func variant(defaults: UserDefaults = .standard) -> BrowserImportHintVariant { + variant(for: defaults.string(forKey: variantKey)) + } + + static func showOnBlankTabs(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showOnBlankTabsKey) == nil { + return defaultShowOnBlankTabs + } + return defaults.bool(forKey: showOnBlankTabsKey) + } + + static func isDismissed(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: dismissedKey) == nil { + return defaultDismissed + } + return defaults.bool(forKey: dismissedKey) + } + + static func presentation(defaults: UserDefaults = .standard) -> BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: variant(defaults: defaults), + showOnBlankTabs: showOnBlankTabs(defaults: defaults), + isDismissed: isDismissed(defaults: defaults) + ) + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.set(defaultVariant.rawValue, forKey: variantKey) + defaults.set(defaultShowOnBlankTabs, forKey: showOnBlankTabsKey) + defaults.set(defaultDismissed, forKey: dismissedKey) + } +} + struct BrowserProfileDefinition: Codable, Hashable, Identifiable, Sendable { let id: UUID var displayName: String diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f0b16dc1..4f1bcc62 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -250,6 +250,9 @@ struct BrowserPanelView: View { @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) private var toggleBrowserDeveloperToolsShortcutData = Data() @State private var suggestionTask: Task? @@ -267,6 +270,7 @@ struct BrowserPanelView: View { @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 + @State private var isBrowserImportHintPopoverPresented = false @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @@ -321,6 +325,18 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + private var browserChromeBackground: Color { Color(nsColor: browserChromeStyle.backgroundColor) } @@ -346,6 +362,14 @@ struct BrowserPanelView: View { return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" } + private var browserImportHintSummary: String { + InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers) + } + + private var shouldShowToolbarImportHintChip: Bool { + shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip + } + private var owningWorkspace: Workspace? { guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId) else { @@ -459,6 +483,10 @@ struct BrowserPanelView: View { if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue } + let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + if browserImportHintVariantRaw != resolvedHintVariant.rawValue { + browserImportHintVariantRaw = resolvedHintVariant.rawValue + } panel.refreshAppearanceDrivenColors() panel.setBrowserThemeMode(browserThemeMode) applyPendingAddressBarFocusRequestIfNeeded() @@ -613,6 +641,9 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + if shouldShowToolbarImportHintChip { + browserImportHintToolbarChip + } browserProfileButton browserThemeModeButton developerToolsButton @@ -776,6 +807,29 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserThemeModeButton") } + private var browserImportHintToolbarChip: some View { + Button(action: { + isBrowserImportHintPopoverPresented.toggle() + }) { + HStack(spacing: 4) { + Image(systemName: "square.and.arrow.down.on.square") + .font(.system(size: 10, weight: .medium)) + Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(devToolsColorOption.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) { + browserImportHintPopover + } + .safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data")) + .accessibilityIdentifier("BrowserImportHintToolbarChip") + } + private var browserProfilePopover: some View { VStack(alignment: .leading, spacing: 8) { Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles")) @@ -1018,9 +1072,16 @@ struct BrowserPanelView: View { setAddressBarFocused(false, reason: "placeholderContent.tapBlur") } } + .overlay(alignment: .topLeading) { + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .inlineStrip { + emptyBrowserStateInlineStrip + } + } .overlay { - if shouldShowEmptyStateImportOverlay { - emptyBrowserStateOverlay + if shouldShowEmptyStateImportOverlay, + browserImportHintPresentation.blankTabPlacement == .floatingCard { + emptyBrowserStateCardOverlay } } } @@ -1288,28 +1349,11 @@ struct BrowserPanelView: View { #endif } - private var emptyBrowserStateOverlay: some View { + private var emptyBrowserStateCardOverlay: some View { VStack { Spacer(minLength: 22) - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data")) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - - Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } - .buttonStyle(.bordered) - .controlSize(.small) - } + browserImportHintBody .padding(12) .frame(maxWidth: 360, alignment: .leading) .background( @@ -1329,10 +1373,118 @@ struct BrowserPanelView: View { .padding(.horizontal, 18) } + private var emptyBrowserStateInlineStrip: some View { + VStack(alignment: .leading, spacing: 0) { + browserImportHintBody + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: 520, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.84)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous).stroke( + Color(nsColor: .separatorColor).opacity(0.35), + lineWidth: 1 + ) + ) + .shadow(color: Color.black.opacity(0.05), radius: 6, y: 2) + + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .padding(.top, 14) + } + + private var browserImportHintPopover: some View { + browserImportHintBody + .padding(12) + .frame(width: 300, alignment: .leading) + } + + private var browserImportHintBody: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportHintSummary) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + browserImportHintPrimaryButton + browserImportHintSettingsButton + browserImportHintDismissButton + } + + VStack(alignment: .leading, spacing: 8) { + browserImportHintPrimaryButton + HStack(spacing: 10) { + browserImportHintSettingsButton + browserImportHintDismissButton + } + } + } + } + .accessibilityElement(children: .contain) + } + + private var browserImportHintPrimaryButton: some View { + Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) { + presentImportDialogFromHint() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + private var browserImportHintSettingsButton: some View { + Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) { + openBrowserImportSettings() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintSettingsButton") + } + + private var browserImportHintDismissButton: some View { + Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) { + dismissBrowserImportHint() + } + .buttonStyle(.plain) + .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintDismissButton") + } + private var shouldShowEmptyStateImportOverlay: Bool { !panel.shouldRenderWebView && isWebViewBlank() } + private func presentImportDialogFromHint() { + isBrowserImportHintPopoverPresented = false + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } + + private func openBrowserImportSettings() { + isBrowserImportHintPopoverPresented = false + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + + private func dismissBrowserImportHint() { + showBrowserImportHintOnBlankTabs = false + isBrowserImportHintDismissed = true + isBrowserImportHintPopoverPresented = false + } + /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d58503a2..15ceaa47 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -337,6 +337,10 @@ struct cmuxApp: App { DebugWindowControlsWindowController.shared.show() } + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } + Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1060,6 +1064,7 @@ struct cmuxApp: App { } private func openAllDebugWindows() { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1074,6 +1079,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.browser-popup", "cmux.settingsAboutTitlebarDebug", "cmux.debugWindowControls", + "cmux.browserImportHintDebug", "cmux.sidebarDebug", "cmux.menubarDebug", "cmux.backgroundDebug", @@ -1689,6 +1695,9 @@ private struct DebugWindowControlsView: View { GroupBox("Open") { VStack(alignment: .leading, spacing: 8) { + Button("Browser Import Hint Debug…") { + BrowserImportHintDebugWindowController.shared.show() + } Button("Settings/About Titlebar Debug…") { SettingsAboutTitlebarDebugWindowController.shared.show() } @@ -1702,6 +1711,7 @@ private struct DebugWindowControlsView: View { MenuBarExtraDebugWindowController.shared.show() } Button("Open All Debug Windows") { + BrowserImportHintDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1905,6 +1915,210 @@ private struct DebugWindowControlsView: View { } } +private final class BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BrowserImportHintDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 420), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Browser Import Hint Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug") + window.center() + window.contentView = NSHostingView(rootView: BrowserImportHintDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BrowserImportHintDebugView: View { + @AppStorage(BrowserImportHintSettings.variantKey) + private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) + private var showOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) + private var isDismissed = BrowserImportHintSettings.defaultDismissed + + private var selectedVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: variantRaw) + } + + private var variantSelection: Binding { + Binding( + get: { selectedVariant.rawValue }, + set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue } + ) + } + + private var showOnBlankTabsBinding: Binding { + Binding( + get: { showOnBlankTabs }, + set: { newValue in + showOnBlankTabs = newValue + if newValue { + isDismissed = false + } + } + ) + } + + private var presentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: selectedVariant, + showOnBlankTabs: showOnBlankTabs, + isDismissed: isDismissed + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Browser Import Hint") + .font(.headline) + + Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.") + .font(.caption) + .foregroundStyle(.secondary) + + GroupBox("Variant") { + VStack(alignment: .leading, spacing: 10) { + Picker("Blank Tab Style", selection: variantSelection) { + ForEach(BrowserImportHintVariant.allCases) { variant in + Text(title(for: variant)).tag(variant.rawValue) + } + } + .pickerStyle(.menu) + + Text(description(for: selectedVariant)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) + } + + GroupBox("State") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding) + Toggle("Pretend the user dismissed it", isOn: $isDismissed) + + Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 2) + } + + GroupBox("Quick Actions") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button("Open Browser Settings") { + AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + } + Button("Open Import Dialog") { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } + + Button("Reset Hint Debug State") { + BrowserImportHintSettings.reset() + } + } + .padding(.top, 2) + } + + GroupBox("Ideas") { + VStack(alignment: .leading, spacing: 6) { + Text("Inline strip: default candidate, visible but quieter than the old floating card.") + Text("Floating card: strongest nudge, useful when we want more explanation.") + Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.") + Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.") + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func title(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + case .settingsOnly: + return "Settings Only" + } + } + + private func description(for variant: BrowserImportHintVariant) -> String { + switch variant { + case .inlineStrip: + return "Shows a thin hint bar at the top of blank browser tabs." + case .floatingCard: + return "Shows the fuller callout card inside blank browser tabs." + case .toolbarChip: + return "Moves the hint into a small toolbar chip beside the browser controls." + case .settingsOnly: + return "Hides the blank-tab hint and leaves Browser settings as the only home." + } + } + + private func placementTitle(_ placement: BrowserImportHintBlankTabPlacement) -> String { + switch placement { + case .hidden: + return "Hidden" + case .inlineStrip: + return "Inline Strip" + case .floatingCard: + return "Floating Card" + case .toolbarChip: + return "Toolbar Chip" + } + } + + private func settingsStatusTitle(_ status: BrowserImportHintSettingsStatus) -> String { + switch status { + case .visible: + return "Visible" + case .hidden: + return "Hidden" + case .settingsOnly: + return "Settings Only" + } + } +} + private final class AboutWindowController: NSWindowController, NSWindowDelegate { static let shared = AboutWindowController() @@ -2035,6 +2249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } enum SettingsNavigationTarget: String { + case browser case keyboardShortcuts } @@ -3103,6 +3318,9 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @@ -3204,6 +3422,30 @@ struct SettingsView: View { ) } + private var browserImportHintVariant: BrowserImportHintVariant { + BrowserImportHintSettings.variant(for: browserImportHintVariantRaw) + } + + private var browserImportHintPresentation: BrowserImportHintPresentation { + BrowserImportHintPresentation( + variant: browserImportHintVariant, + showOnBlankTabs: showBrowserImportHintOnBlankTabs, + isDismissed: isBrowserImportHintDismissed + ) + } + + private var browserImportHintVisibilityBinding: Binding { + Binding( + get: { showBrowserImportHintOnBlankTabs }, + set: { newValue in + showBrowserImportHintOnBlankTabs = newValue + if newValue { + isBrowserImportHintDismissed = false + } + } + ) + } + private var socketModeSelection: Binding { Binding( get: { socketControlMode }, @@ -3266,6 +3508,17 @@ struct SettingsView: View { InstalledBrowserDetector.summaryText(for: detectedImportBrowsers) } + private var browserImportHintSettingsNote: String { + switch browserImportHintPresentation.settingsStatus { + case .visible: + return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") + case .hidden: + return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") + case .settingsOnly: + return String(localized: "settings.browser.import.hint.note.settingsOnly", defaultValue: "Blank tabs are currently using Settings only mode from the debug window.") + } + } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -4187,6 +4440,8 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser")) + .id(SettingsNavigationTarget.browser) + .accessibilityIdentifier("SettingsBrowserSection") SettingsCard { SettingsPickerRow( String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), @@ -4361,7 +4616,38 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser")) + .font(.system(size: 13, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) + + Text(browserImportSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } + HStack(spacing: 8) { Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { BrowserDataImportCoordinator.shared.presentImportDialog() @@ -4376,7 +4662,22 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) } + .accessibilityIdentifier("SettingsBrowserImportActions") + + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: browserImportHintVisibilityBinding + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") + + Text(browserImportHintSettingsNote) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 14) + .padding(.vertical, 10) SettingsCardDivider() @@ -4520,6 +4821,7 @@ struct SettingsView: View { BrowserHistoryStore.shared.loadIfNeeded() notificationStore.refreshAuthorizationStatus() browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist refreshDetectedImportBrowsers() @@ -4633,6 +4935,9 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue + browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue + showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs + isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index 2f122921..6eed3932 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -144,6 +144,50 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(manyProfilesPresentation.showsHelpText) } + func testBrowserImportHintPresentationDefaultsToInlineStrip() { + let presentation = BrowserImportHintPresentation( + variant: .inlineStrip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .inlineStrip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationHidesBlankTabHintWhenDismissed() { + let presentation = BrowserImportHintPresentation( + variant: .floatingCard, + showOnBlankTabs: true, + isDismissed: true + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .hidden) + } + + func testBrowserImportHintPresentationUsesToolbarChipWhenEnabled() { + let presentation = BrowserImportHintPresentation( + variant: .toolbarChip, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) + XCTAssertEqual(presentation.settingsStatus, .visible) + } + + func testBrowserImportHintPresentationSettingsOnlyVariantStaysInSettings() { + let presentation = BrowserImportHintPresentation( + variant: .settingsOnly, + showOnBlankTabs: true, + isDismissed: false + ) + + XCTAssertEqual(presentation.blankTabPlacement, .hidden) + XCTAssertEqual(presentation.settingsStatus, .settingsOnly) + } + @MainActor func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index c8d95f08..ab30b3e1 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -112,6 +112,32 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertEqual(capture["scope"] as? String, "everything") } + func testBlankBrowserImportHintCanOpenBrowserSettings() { + let app = launchAppForBlankImportHint() + + let settingsButton = app.buttons["BrowserImportHintSettingsButton"] + XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) + settingsButton.click() + + XCTAssertTrue( + app.otherElements["SettingsBrowserSection"].waitForExistence(timeout: 5.0), + "Expected Browser Settings to open from the blank-tab import hint" + ) + } + + func testBlankBrowserImportHintCanBeDismissed() { + let app = launchAppForBlankImportHint() + + let dismissButton = app.buttons["BrowserImportHintDismissButton"] + XCTAssertTrue(dismissButton.waitForExistence(timeout: 5.0)) + dismissButton.click() + + XCTAssertTrue( + browserImportPollUntil(timeout: 2.0) { !dismissButton.exists }, + "Expected the blank-tab import hint to disappear after dismissal" + ) + } + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" @@ -125,6 +151,18 @@ final class BrowserImportProfilesUITests: XCTestCase { return app } + private func launchAppForBlankImportHint() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" + app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" + launchAndActivate(app) + waitForBlankImportHint(app) + return app + } + private func waitForImportWizard(_ app: XCUIApplication) { let wizardOpened = browserImportPollUntil(timeout: 5.0) { app.buttons["Next"].exists || app.windows["Import Browser Data"].exists @@ -132,6 +170,13 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(wizardOpened, "Expected the import wizard to open") } + private func waitForBlankImportHint(_ app: XCUIApplication) { + let hintOpened = browserImportPollUntil(timeout: 5.0) { + app.buttons["BrowserImportHintDismissButton"].exists + } + XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear") + } + private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { let url = URL(fileURLWithPath: capturePath) let foundCapture = browserImportPollUntil(timeout: timeout) { From 9807cb087b0c042a5938236a6885edef429138fb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:14:23 -0700 Subject: [PATCH 09/12] Stabilize browser import hint UI tests --- Sources/Panels/BrowserPanelView.swift | 1 + .../BrowserImportProfilesUITests.swift | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 4f1bcc62..8192fba8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1443,6 +1443,7 @@ struct BrowserPanelView: View { } .buttonStyle(.bordered) .controlSize(.small) + .accessibilityIdentifier("BrowserImportHintImportButton") } private var browserImportHintSettingsButton: some View { diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index ab30b3e1..62f85537 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -120,7 +120,7 @@ final class BrowserImportProfilesUITests: XCTestCase { settingsButton.click() XCTAssertTrue( - app.otherElements["SettingsBrowserSection"].waitForExistence(timeout: 5.0), + app.switches["SettingsBrowserImportHintToggle"].waitForExistence(timeout: 5.0), "Expected Browser Settings to open from the blank-tab import hint" ) } @@ -141,13 +141,16 @@ final class BrowserImportProfilesUITests: XCTestCase { private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" - app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] = "1" app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# 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.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) - waitForImportWizard(app) + openImportWizardFromBlankImportHint(app) return app } @@ -172,11 +175,21 @@ final class BrowserImportProfilesUITests: XCTestCase { private func waitForBlankImportHint(_ app: XCUIApplication) { let hintOpened = browserImportPollUntil(timeout: 5.0) { - app.buttons["BrowserImportHintDismissButton"].exists + 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 url = URL(fileURLWithPath: capturePath) let foundCapture = browserImportPollUntil(timeout: timeout) { From c5ae8dc9ebf1c935707ba420b20ef4329da300fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 03:23:18 -0700 Subject: [PATCH 10/12] Defer browser import dialog presentation --- Sources/Panels/BrowserPanelView.swift | 8 +++++--- Sources/cmuxApp.swift | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 8192fba8..7108d183 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1470,9 +1470,11 @@ struct BrowserPanelView: View { private func presentImportDialogFromHint() { isBrowserImportHintPopoverPresented = false - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog( + defaultDestinationProfileID: panel.profileID + ) + } } private func openBrowserImportSettings() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 15ceaa47..5c5dd445 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2037,7 +2037,9 @@ private struct BrowserImportHintDebugView: View { AppDelegate.presentPreferencesWindow(navigationTarget: .browser) } Button("Open Import Dialog") { - BrowserDataImportCoordinator.shared.presentImportDialog() + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } } } @@ -4650,8 +4652,10 @@ struct SettingsView: View { 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) From d369778f7ff571522c250cbba4a50fecd79fdf45 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 04:48:21 -0700 Subject: [PATCH 11/12] Scroll settings hint to import controls --- Sources/ContentView.swift | 12 +++++++++++ Sources/Panels/BrowserPanel.swift | 2 +- Sources/Panels/BrowserPanelView.swift | 20 ++++++++++++++++++- Sources/cmuxApp.swift | 4 ++++ .../AppDelegateShortcutRoutingTests.swift | 18 +++++++++++++++++ cmuxTests/BrowserImportMappingTests.swift | 15 +++++++------- .../BrowserImportProfilesUITests.swift | 17 ++++++++++++++-- 7 files changed, 77 insertions(+), 11 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c7708c7c..a50f275c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8944,6 +8944,7 @@ private final class FeedbackComposerMessageEditorView: NSView { } private enum SidebarHelpMenuAction { + case importBrowserData case keyboardShortcuts case docs case changelog @@ -9514,6 +9515,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"), @@ -9618,6 +9625,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 dc943d31..dd65fad9 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -259,7 +259,7 @@ enum BrowserImportHintSettings { static let variantKey = "browserImportHintVariant" static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" static let dismissedKey = "browserImportHintDismissed" - static let defaultVariant: BrowserImportHintVariant = .inlineStrip + static let defaultVariant: BrowserImportHintVariant = .toolbarChip static let defaultShowOnBlankTabs = true static let defaultDismissed = false diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7108d183..0fc8446b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -873,6 +873,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 @@ -1470,6 +1478,16 @@ struct BrowserPanelView: View { 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 @@ -1479,7 +1497,7 @@ struct BrowserPanelView: View { private func openBrowserImportSettings() { isBrowserImportHintPopoverPresented = false - AppDelegate.presentPreferencesWindow(navigationTarget: .browser) + AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport) } private func dismissBrowserImportHint() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 5c5dd445..4140cbd6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2252,6 +2252,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { enum SettingsNavigationTarget: String { case browser + case browserImport case keyboardShortcuts } @@ -4659,6 +4660,7 @@ struct SettingsView: View { } .buttonStyle(.bordered) .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportChooseButton") Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { refreshDetectedImportBrowsers() @@ -4680,6 +4682,8 @@ struct SettingsView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } + .id(SettingsNavigationTarget.browserImport) + .accessibilityIdentifier("SettingsBrowserImportSection") .padding(.horizontal, 14) .padding(.vertical, 10) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 820cdb0b..8b569de5 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2550,6 +2550,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 6eed3932..e4d5f54f 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -144,14 +144,15 @@ final class BrowserImportMappingTests: XCTestCase { XCTAssertTrue(manyProfilesPresentation.showsHelpText) } - func testBrowserImportHintPresentationDefaultsToInlineStrip() { - let presentation = BrowserImportHintPresentation( - variant: .inlineStrip, - showOnBlankTabs: true, - isDismissed: false - ) + func testBrowserImportHintSettingsDefaultToToolbarChip() throws { + let suiteName = "BrowserImportHintDefaults-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } - XCTAssertEqual(presentation.blankTabPlacement, .inlineStrip) + let presentation = BrowserImportHintSettings.presentation(defaults: defaults) + + XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) XCTAssertEqual(presentation.settingsStatus, .visible) } diff --git a/cmuxUITests/BrowserImportProfilesUITests.swift b/cmuxUITests/BrowserImportProfilesUITests.swift index 62f85537..d959de30 100644 --- a/cmuxUITests/BrowserImportProfilesUITests.swift +++ b/cmuxUITests/BrowserImportProfilesUITests.swift @@ -119,9 +119,22 @@ final class BrowserImportProfilesUITests: XCTestCase { XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) settingsButton.click() + let importSection = app.otherElements["SettingsBrowserImportSection"] XCTAssertTrue( - app.switches["SettingsBrowserImportHintToggle"].waitForExistence(timeout: 5.0), - "Expected Browser Settings to open from the blank-tab import hint" + 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" ) } From c4742a4ba1159516c7f11ed2f3baf399ca742f03 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 16:46:10 -0700 Subject: [PATCH 12/12] Refine browser import minimal UI --- Resources/Localizable.xcstrings | 187 +++++++++++++ Sources/Panels/BrowserPanel.swift | 24 +- Sources/Panels/BrowserPanelView.swift | 86 +++++- Sources/cmuxApp.swift | 247 ++++++++++++++++++ cmuxTests/BrowserImportMappingTests.swift | 61 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 50 ++++ 6 files changed, 648 insertions(+), 7 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0f8f83df..2a7b1e4a 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": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dd65fad9..67d9e2d0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8384,6 +8384,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 { @@ -8555,6 +8570,10 @@ final class BrowserDataImportCoordinator { return selection } +#if DEBUG + var debugPanelWindow: NSWindow { panel } +#endif + func windowWillClose(_ notification: Notification) { finishModal(with: .cancel) } @@ -8864,7 +8883,9 @@ final class BrowserDataImportCoordinator { sourceProfilesScrollView.contentView.postsBoundsChangedNotifications = true sourceProfilesScrollHeightConstraint = sourceProfilesScrollView.heightAnchor.constraint(equalToConstant: 76) sourceProfilesScrollHeightConstraint?.isActive = true - sourceProfilesScrollView.widthAnchor.constraint(equalTo: sourceProfilesContainer.widthAnchor).isActive = true + let sourceProfilesScrollWidthConstraint = sourceProfilesScrollView.widthAnchor.constraint( + equalTo: sourceProfilesContainer.widthAnchor + ) sourceProfilesHelpLabel.font = NSFont.systemFont(ofSize: 11) sourceProfilesHelpLabel.textColor = .secondaryLabelColor @@ -8882,6 +8903,7 @@ final class BrowserDataImportCoordinator { sourceProfilesContainer.addArrangedSubview(sourceProfilesTitle) sourceProfilesContainer.addArrangedSubview(sourceProfilesScrollView) sourceProfilesContainer.addArrangedSubview(sourceProfilesHelpLabel) + sourceProfilesScrollWidthConstraint.isActive = true sourceProfilesContainer.setHuggingPriority(.defaultLow, for: .vertical) sourceProfilesContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 0fc8446b..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,6 +288,11 @@ 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 @@ -337,6 +381,18 @@ struct BrowserPanelView: View { ) } + 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) } @@ -475,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() @@ -487,6 +546,18 @@ struct BrowserPanelView: View { 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() @@ -641,12 +712,14 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") - if shouldShowToolbarImportHintChip { - browserImportHintToolbarChip + HStack(spacing: browserToolbarAccessorySpacing) { + if shouldShowToolbarImportHintChip { + browserImportHintToolbarChip + } + browserProfileButton + browserThemeModeButton + developerToolsButton } - browserProfileButton - browserThemeModeButton - developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) @@ -892,7 +965,8 @@ struct BrowserPanelView: View { .buttonStyle(.plain) } } - .padding(8) + .padding(.horizontal, browserProfilePopoverHorizontalPadding) + .padding(.vertical, browserProfilePopoverVerticalPadding) .frame(minWidth: 208) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 4140cbd6..343ca118 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() @@ -341,6 +346,15 @@ struct cmuxApp: App { 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() } @@ -365,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"), @@ -1065,6 +1102,7 @@ struct cmuxApp: App { private func openAllDebugWindows() { BrowserImportHintDebugWindowController.shared.show() + BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1698,6 +1736,14 @@ private struct DebugWindowControlsView: View { 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() } @@ -1712,6 +1758,7 @@ private struct DebugWindowControlsView: View { } Button("Open All Debug Windows") { BrowserImportHintDebugWindowController.shared.show() + BrowserProfilePopoverDebugWindowController.shared.show() SettingsAboutTitlebarDebugWindowController.shared.show() SidebarDebugWindowController.shared.show() BackgroundDebugWindowController.shared.show() @@ -1949,6 +1996,205 @@ private final class BrowserImportHintDebugWindowController: NSWindowController, } } +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 @@ -3369,6 +3615,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 diff --git a/cmuxTests/BrowserImportMappingTests.swift b/cmuxTests/BrowserImportMappingTests.swift index e4d5f54f..58ccf28e 100644 --- a/cmuxTests/BrowserImportMappingTests.swift +++ b/cmuxTests/BrowserImportMappingTests.swift @@ -284,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, @@ -291,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 6ce551a0..a422cedd 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1470,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)