From c1ffc178b863edce08f265e4925bbdd10cebd72f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:27:23 -0800 Subject: [PATCH] Make browser import a 2-step choice flow --- Sources/Panels/BrowserPanel.swift | 201 +++++++++++++++++++------- Sources/Panels/BrowserPanelView.swift | 2 +- Sources/cmuxApp.swift | 6 +- cmuxTests/GhosttyConfigTests.swift | 38 +++++ 4 files changed, 192 insertions(+), 55 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d58fd21f..a67cf4ab 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2874,6 +2874,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { enum BrowserImportScope: String, CaseIterable, Identifiable { case cookiesOnly + case historyOnly case cookiesAndHistory case everything @@ -2883,6 +2884,8 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly: return "Cookies only" + case .historyOnly: + return "History only" case .cookiesAndHistory: return "Cookies + history" case .everything: @@ -2894,6 +2897,8 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly, .cookiesAndHistory, .everything: return true + case .historyOnly: + return false } } @@ -2901,10 +2906,28 @@ enum BrowserImportScope: String, CaseIterable, Identifiable { switch self { case .cookiesOnly: return false - case .cookiesAndHistory, .everything: + case .historyOnly, .cookiesAndHistory, .everything: return true } } + + static func fromSelection( + includeCookies: Bool, + includeHistory: Bool, + includeAdditionalData: Bool + ) -> BrowserImportScope? { + guard includeCookies || includeHistory else { return nil } + if includeAdditionalData { + return .everything + } + if includeCookies && includeHistory { + return .cookiesAndHistory + } + if includeCookies { + return .cookiesOnly + } + return .historyOnly + } } enum BrowserImportEngineFamily: String, Hashable { @@ -4134,14 +4157,20 @@ final class BrowserDataImportCoordinator { let domainFilters: [String] } + private enum ImportOptionsPromptResult { + case proceed(scope: BrowserImportScope, domainFilters: [String]) + case back + case cancel + } + private func presentImportDialog(prefilledBrowsers: [InstalledBrowserCandidate]?) { guard !importInProgress else { return } let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() guard !browsers.isEmpty else { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "No supported browsers detected" - alert.informativeText = "cmux could not find installed browser profiles to import from." + alert.messageText = "No importable browsers found" + alert.informativeText = "cmux could not find browser profiles to import from on this Mac." alert.addButton(withTitle: "OK") alert.runModal() return @@ -4171,84 +4200,154 @@ final class BrowserDataImportCoordinator { } private func promptForSelection(browsers: [InstalledBrowserCandidate]) -> ImportSelection? { + guard !browsers.isEmpty else { return nil } + var preselectedBrowser = browsers[0] + + while true { + guard let selectedBrowser = promptForBrowserSelection( + browsers: browsers, + preselectedBrowserID: preselectedBrowser.id + ) else { + return nil + } + preselectedBrowser = selectedBrowser + + switch promptForImportOptions(for: selectedBrowser) { + case .proceed(let scope, let domainFilters): + return ImportSelection( + browser: selectedBrowser, + scope: scope, + domainFilters: domainFilters + ) + case .back: + continue + case .cancel: + return nil + } + } + } + + private func promptForBrowserSelection( + browsers: [InstalledBrowserCandidate], + preselectedBrowserID: String + ) -> InstalledBrowserCandidate? { let alert = NSAlert() alert.alertStyle = .informational alert.messageText = "Import Browser Data" - alert.informativeText = "Choose a browser and what to import." - alert.addButton(withTitle: "Import") + alert.informativeText = "Step 1 of 2: Choose the browser to import from." + alert.addButton(withTitle: "Next") alert.addButton(withTitle: "Cancel") let browserPopup = NSPopUpButton(frame: .zero, pullsDown: false) for browser in browsers { browserPopup.addItem(withTitle: browser.displayName) } - browserPopup.selectItem(at: 0) - - let scopePopup = NSPopUpButton(frame: .zero, pullsDown: false) - for scope in BrowserImportScope.allCases { - scopePopup.addItem(withTitle: scope.displayName) - scopePopup.item(at: scopePopup.numberOfItems - 1)?.representedObject = scope.rawValue + if let index = browsers.firstIndex(where: { $0.id == preselectedBrowserID }) { + browserPopup.selectItem(at: index) + } else { + browserPopup.selectItem(at: 0) } - if let defaultIndex = BrowserImportScope.allCases.firstIndex(of: .cookiesAndHistory) { - scopePopup.selectItem(at: defaultIndex) - } - - let domainField = NSTextField(frame: .zero) - domainField.placeholderString = "Optional domains (comma or space separated)" - domainField.stringValue = "" let browserRow = NSStackView() browserRow.orientation = .horizontal browserRow.spacing = 8 browserRow.alignment = .centerY - let browserLabel = NSTextField(labelWithString: "Browser") + let browserLabel = NSTextField(labelWithString: "Source") browserLabel.alignment = .right - browserLabel.frame.size.width = 72 + browserLabel.frame.size.width = 80 browserRow.addArrangedSubview(browserLabel) browserRow.addArrangedSubview(browserPopup) - let scopeRow = NSStackView() - scopeRow.orientation = .horizontal - scopeRow.spacing = 8 - scopeRow.alignment = .centerY - let scopeLabel = NSTextField(labelWithString: "Import") - scopeLabel.alignment = .right - scopeLabel.frame.size.width = 72 - scopeRow.addArrangedSubview(scopeLabel) - scopeRow.addArrangedSubview(scopePopup) - - let domainRow = NSStackView() - domainRow.orientation = .horizontal - domainRow.spacing = 8 - domainRow.alignment = .centerY - let domainLabel = NSTextField(labelWithString: "Domains") - domainLabel.alignment = .right - domainLabel.frame.size.width = 72 - domainRow.addArrangedSubview(domainLabel) - domainRow.addArrangedSubview(domainField) + let hintLabel = NSTextField(wrappingLabelWithString: InstalledBrowserDetector.summaryText(for: browsers)) + hintLabel.font = NSFont.systemFont(ofSize: 11) + hintLabel.textColor = .secondaryLabelColor let accessory = NSStackView() accessory.orientation = .vertical accessory.spacing = 8 accessory.alignment = .leading accessory.addArrangedSubview(browserRow) - accessory.addArrangedSubview(scopeRow) - accessory.addArrangedSubview(domainRow) - accessory.setFrameSize(NSSize(width: 420, height: 108)) + accessory.addArrangedSubview(hintLabel) + accessory.setFrameSize(NSSize(width: 420, height: 72)) alert.accessoryView = accessory guard alert.runModal() == .alertFirstButtonReturn else { return nil } let browserIndex = max(0, min(browserPopup.indexOfSelectedItem, browsers.count - 1)) - let selectedBrowser = browsers[browserIndex] - let selectedScopeRaw = scopePopup.selectedItem?.representedObject as? String ?? BrowserImportScope.cookiesAndHistory.rawValue - let selectedScope = BrowserImportScope(rawValue: selectedScopeRaw) ?? .cookiesAndHistory - let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + return browsers[browserIndex] + } - return ImportSelection( - browser: selectedBrowser, - scope: selectedScope, - domainFilters: domainFilters - ) + private func promptForImportOptions(for browser: InstalledBrowserCandidate) -> ImportOptionsPromptResult { + while true { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Choose What to Import" + alert.informativeText = "Step 2 of 2: Pick data types from \(browser.displayName). Nothing is imported until you click Start Import." + alert.addButton(withTitle: "Start Import") + alert.addButton(withTitle: "Back") + alert.addButton(withTitle: "Cancel") + + let cookiesCheckbox = NSButton(checkboxWithTitle: "Cookies (site sign-ins)", target: nil, action: nil) + cookiesCheckbox.state = .on + + let historyCheckbox = NSButton(checkboxWithTitle: "History (visited pages)", target: nil, action: nil) + historyCheckbox.state = .on + + let domainField = NSTextField(frame: .zero) + domainField.placeholderString = "Optional domains only (e.g. github.com, openai.com)" + domainField.stringValue = "" + + let domainRow = NSStackView() + domainRow.orientation = .horizontal + domainRow.spacing = 8 + domainRow.alignment = .centerY + let domainLabel = NSTextField(labelWithString: "Limit to") + domainLabel.alignment = .right + domainLabel.frame.size.width = 80 + domainRow.addArrangedSubview(domainLabel) + domainRow.addArrangedSubview(domainField) + + let noteLabel = NSTextField( + wrappingLabelWithString: "Bookmarks and settings import is not available yet." + ) + noteLabel.font = NSFont.systemFont(ofSize: 11) + noteLabel.textColor = .secondaryLabelColor + + let accessory = NSStackView() + accessory.orientation = .vertical + accessory.spacing = 8 + accessory.alignment = .leading + accessory.addArrangedSubview(cookiesCheckbox) + accessory.addArrangedSubview(historyCheckbox) + accessory.addArrangedSubview(domainRow) + accessory.addArrangedSubview(noteLabel) + accessory.setFrameSize(NSSize(width: 440, height: 122)) + alert.accessoryView = accessory + + switch alert.runModal() { + case .alertFirstButtonReturn: + let includeCookies = cookiesCheckbox.state == .on + let includeHistory = historyCheckbox.state == .on + guard let scope = BrowserImportScope.fromSelection( + includeCookies: includeCookies, + includeHistory: includeHistory, + includeAdditionalData: false + ) else { + let validationAlert = NSAlert() + validationAlert.alertStyle = .warning + validationAlert.messageText = "Choose at least one data type" + validationAlert.informativeText = "Select Cookies, History, or both before starting import." + validationAlert.addButton(withTitle: "OK") + validationAlert.runModal() + continue + } + let domainFilters = BrowserDataImporter.parseDomainFilters(domainField.stringValue) + return .proceed(scope: scope, domainFilters: domainFilters) + case .alertSecondButtonReturn: + return .back + default: + return .cancel + } + } } private func showProgressWindow(title: String, message: String) -> NSWindow { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c05b75fe..e11097d1 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -717,7 +717,7 @@ struct BrowserPanelView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Button("Import…") { + Button("Choose What to Import…") { refreshEmptyStateImportBrowsers() BrowserDataImportCoordinator.shared.presentImportDialog() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 636a6969..5f200bc4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -470,7 +470,7 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } - Button("Import Browser Data…") { + Button("Import From Browser…") { BrowserDataImportCoordinator.shared.presentImportDialog() } @@ -2926,9 +2926,9 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow("Import Browser Data", subtitle: browserImportSubtitle) { + SettingsCardRow("Import From Browser", subtitle: browserImportSubtitle) { HStack(spacing: 8) { - Button("Import…") { + Button("Choose…") { BrowserDataImportCoordinator.shared.presentImportDialog() refreshDetectedImportBrowsers() } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index c0aa5089..994ccf25 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -603,3 +603,41 @@ final class BrowserInstallDetectorTests: XCTestCase { _ = FileManager.default.createFile(atPath: url.path, contents: contents) } } + +final class BrowserImportScopeTests: XCTestCase { + func testFromSelectionCookiesOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesOnly) + } + + func testFromSelectionHistoryOnly() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .historyOnly) + } + + func testFromSelectionCookiesAndHistory() { + let scope = BrowserImportScope.fromSelection( + includeCookies: true, + includeHistory: true, + includeAdditionalData: false + ) + XCTAssertEqual(scope, .cookiesAndHistory) + } + + func testFromSelectionRejectsEmptySelection() { + let scope = BrowserImportScope.fromSelection( + includeCookies: false, + includeHistory: false, + includeAdditionalData: false + ) + XCTAssertNil(scope) + } +}