feat: add browser profile mapping import flow
This commit is contained in:
parent
1d540d0806
commit
92cb42262c
12 changed files with 4609 additions and 258 deletions
|
|
@ -83,12 +83,14 @@
|
|||
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
|
||||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
|
||||
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
|
|
@ -228,12 +230,14 @@
|
|||
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -454,6 +458,7 @@
|
|||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
|
||||
);
|
||||
|
|
@ -468,6 +473,7 @@
|
|||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
|
|
@ -693,6 +699,7 @@
|
|||
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
|
||||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
|
||||
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
|
||||
);
|
||||
|
|
@ -707,6 +714,7 @@
|
|||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2313,7 +2313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
stopSocketListenerHealthMonitor()
|
||||
TerminalController.shared.stop()
|
||||
VSCodeServeWebController.shared.stop()
|
||||
BrowserHistoryStore.shared.flushPendingSaves()
|
||||
BrowserProfileStore.shared.flushPendingSaves()
|
||||
if TelemetrySettings.enabledForCurrentLaunch {
|
||||
PostHogAnalytics.shared.flush()
|
||||
}
|
||||
|
|
@ -8673,7 +8673,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@discardableResult
|
||||
func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else {
|
||||
let preferredProfileID =
|
||||
tabManager?.focusedBrowserPanel?.profileID
|
||||
?? tabManager?.selectedWorkspace?.preferredBrowserProfileID
|
||||
guard let panelId = tabManager?.openBrowser(
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
insertAtEnd: insertAtEnd
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " +
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor(
|
|||
/// View for rendering a browser panel with address bar
|
||||
struct BrowserPanelView: View {
|
||||
@ObservedObject var panel: BrowserPanel
|
||||
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
|
||||
let paneId: PaneID
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
|
|
@ -236,6 +237,7 @@ struct BrowserPanelView: View {
|
|||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
|
||||
@State private var isBrowserProfileMenuPresented = false
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
@State private var ghosttyBackgroundGeneration: Int = 0
|
||||
// Keep this below half of the compact omnibar height so it reads as a squircle,
|
||||
|
|
@ -433,7 +435,7 @@ struct BrowserPanelView: View {
|
|||
autoFocusOmnibarIfBlank()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
|
||||
refreshEmptyStateImportBrowsers()
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
panel.historyStore.loadIfNeeded()
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "view.onAppear")
|
||||
#endif
|
||||
|
|
@ -469,6 +471,12 @@ struct BrowserPanelView: View {
|
|||
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
}
|
||||
.onChange(of: panel.profileID) { _ in
|
||||
panel.historyStore.loadIfNeeded()
|
||||
if addressBarFocused {
|
||||
refreshSuggestions()
|
||||
}
|
||||
}
|
||||
.onChange(of: isFocused) { focused in
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
|
|
@ -541,7 +549,7 @@ struct BrowserPanelView: View {
|
|||
applyOmnibarEffects(effects)
|
||||
refreshInlineCompletion()
|
||||
}
|
||||
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
|
||||
.onReceive(panel.historyStore.$entries) { _ in
|
||||
guard addressBarFocused else { return }
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
|
@ -569,11 +577,10 @@ struct BrowserPanelView: View {
|
|||
.accessibilityIdentifier("BrowserOmnibarPill")
|
||||
.accessibilityLabel("Browser omnibar")
|
||||
|
||||
if !panel.isShowingNewTabPage {
|
||||
browserProfileButton
|
||||
browserThemeModeButton
|
||||
developerToolsButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, addressBarVerticalPadding)
|
||||
.background(browserChromeBackground)
|
||||
|
|
@ -677,6 +684,34 @@ struct BrowserPanelView: View {
|
|||
.accessibilityIdentifier("BrowserToggleDevToolsButton")
|
||||
}
|
||||
|
||||
private var browserProfileButton: some View {
|
||||
Button(action: {
|
||||
isBrowserProfileMenuPresented.toggle()
|
||||
}) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.cmuxFlatSymbolColorRendering()
|
||||
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||
.foregroundStyle(devToolsColorOption.color)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
|
||||
browserProfilePopover
|
||||
}
|
||||
.safeHelp(
|
||||
String(
|
||||
format: String(
|
||||
localized: "browser.profile.buttonHelp",
|
||||
defaultValue: "Browser Profile: %@"
|
||||
),
|
||||
panel.profileDisplayName
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("BrowserProfileButton")
|
||||
}
|
||||
|
||||
private var browserThemeModeButton: some View {
|
||||
Button(action: {
|
||||
isBrowserThemeMenuPresented.toggle()
|
||||
|
|
@ -693,10 +728,76 @@ struct BrowserPanelView: View {
|
|||
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
|
||||
browserThemeModePopover
|
||||
}
|
||||
.safeHelp("Browser Theme: \(browserThemeMode.displayName)")
|
||||
.safeHelp(
|
||||
String(
|
||||
format: String(
|
||||
localized: "browser.theme.buttonHelp",
|
||||
defaultValue: "Browser Theme: %@"
|
||||
),
|
||||
browserThemeMode.displayName
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("BrowserThemeModeButton")
|
||||
}
|
||||
|
||||
private var browserProfilePopover: 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) {
|
||||
ForEach(browserProfileStore.profiles) { profile in
|
||||
Button {
|
||||
applyBrowserProfileSelection(profile.id)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
|
||||
.frame(width: 12, alignment: .center)
|
||||
Text(profile.displayName)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
isBrowserProfileMenuPresented = false
|
||||
presentCreateBrowserProfilePrompt()
|
||||
} label: {
|
||||
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if browserProfileStore.canRenameProfile(id: panel.profileID) {
|
||||
Button {
|
||||
isBrowserProfileMenuPresented = false
|
||||
presentRenameBrowserProfilePrompt()
|
||||
} label: {
|
||||
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(minWidth: 208)
|
||||
}
|
||||
|
||||
private var browserThemeModePopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(BrowserThemeMode.allCases) { mode in
|
||||
|
|
@ -1145,7 +1246,9 @@ struct BrowserPanelView: View {
|
|||
|
||||
Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) {
|
||||
refreshEmptyStateImportBrowsers()
|
||||
BrowserDataImportCoordinator.shared.presentImportDialog()
|
||||
BrowserDataImportCoordinator.shared.presentImportDialog(
|
||||
defaultDestinationProfileID: panel.profileID
|
||||
)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
|
@ -1327,10 +1430,73 @@ struct BrowserPanelView: View {
|
|||
|
||||
let target = omnibarState.suggestions[idx]
|
||||
guard case .history(let url, _) = target.kind else { return }
|
||||
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
|
||||
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
private func applyBrowserProfileSelection(_ profileID: UUID) {
|
||||
isBrowserProfileMenuPresented = false
|
||||
owningWorkspace?.setPreferredBrowserProfileID(profileID)
|
||||
_ = panel.switchToProfile(profileID)
|
||||
}
|
||||
|
||||
private func presentCreateBrowserProfilePrompt() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
|
||||
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
|
||||
|
||||
let input = NSTextField(string: "")
|
||||
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
|
||||
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
|
||||
alert.accessoryView = input
|
||||
|
||||
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
let alertWindow = alert.window
|
||||
alertWindow.initialFirstResponder = input
|
||||
DispatchQueue.main.async {
|
||||
alertWindow.makeFirstResponder(input)
|
||||
input.selectText(nil)
|
||||
}
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn,
|
||||
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
applyBrowserProfileSelection(profile.id)
|
||||
}
|
||||
|
||||
private func presentRenameBrowserProfilePrompt() {
|
||||
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
|
||||
browserProfileStore.canRenameProfile(id: profile.id) else {
|
||||
return
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
|
||||
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
|
||||
|
||||
let input = NSTextField(string: profile.displayName)
|
||||
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
|
||||
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
|
||||
alert.accessoryView = input
|
||||
|
||||
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
let alertWindow = alert.window
|
||||
alertWindow.initialFirstResponder = input
|
||||
DispatchQueue.main.async {
|
||||
alertWindow.makeFirstResponder(input)
|
||||
input.selectText(nil)
|
||||
}
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
|
||||
}
|
||||
|
||||
private func refreshInlineCompletion() {
|
||||
inlineCompletion = omnibarInlineCompletionForDisplay(
|
||||
typedText: omnibarState.buffer,
|
||||
|
|
@ -1366,9 +1532,9 @@ struct BrowserPanelView: View {
|
|||
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let historyEntries: [BrowserHistoryStore.Entry] = {
|
||||
if query.isEmpty {
|
||||
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
|
||||
return panel.historyStore.recentSuggestions(limit: 12)
|
||||
}
|
||||
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
|
||||
return panel.historyStore.suggestions(for: query, limit: 12)
|
||||
}()
|
||||
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
|
||||
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
|
||||
|
|
@ -1432,7 +1598,7 @@ struct BrowserPanelView: View {
|
|||
let merged = buildOmnibarSuggestions(
|
||||
query: query,
|
||||
engineName: searchEngine.displayName,
|
||||
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
|
||||
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
|
||||
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
|
||||
remoteQueries: remote,
|
||||
resolvedURL: panel.resolveNavigableURL(from: query),
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable {
|
|||
|
||||
struct SessionBrowserPanelSnapshot: Codable, Sendable {
|
||||
var urlString: String?
|
||||
var profileID: UUID?
|
||||
var shouldRenderWebView: Bool
|
||||
var pageZoom: Double
|
||||
var developerToolsVisible: Bool
|
||||
|
|
|
|||
|
|
@ -2450,6 +2450,7 @@ class TabManager: ObservableObject {
|
|||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
focus: Bool = true
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
|
|
@ -2458,14 +2459,24 @@ class TabManager: ObservableObject {
|
|||
orientation: orientation,
|
||||
insertFirst: insertFirst,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
focus: focus
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Create a new browser surface in a pane
|
||||
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
|
||||
func newBrowserSurface(
|
||||
tabId: UUID,
|
||||
inPane paneId: PaneID,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newBrowserSurface(inPane: paneId, url: url)?.id
|
||||
return tab.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Get a browser panel by ID
|
||||
|
|
@ -2480,6 +2491,7 @@ class TabManager: ObservableObject {
|
|||
inWorkspace tabId: UUID,
|
||||
url: URL? = nil,
|
||||
preferSplitRight: Bool = false,
|
||||
preferredProfileID: UUID? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
) -> UUID? {
|
||||
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
|
|
@ -2493,7 +2505,8 @@ class TabManager: ObservableObject {
|
|||
inPane: targetPaneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
insertAtEnd: insertAtEnd,
|
||||
preferredProfileID: preferredProfileID
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
return browserPanel.id
|
||||
|
|
@ -2519,6 +2532,7 @@ class TabManager: ObservableObject {
|
|||
from: splitSourcePanelId,
|
||||
orientation: .horizontal,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
focus: true
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
|
|
@ -2531,7 +2545,8 @@ class TabManager: ObservableObject {
|
|||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
insertAtEnd: insertAtEnd,
|
||||
preferredProfileID: preferredProfileID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -2541,12 +2556,17 @@ class TabManager: ObservableObject {
|
|||
|
||||
/// Open a browser in the currently focused pane (as a new surface)
|
||||
@discardableResult
|
||||
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
func openBrowser(
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
) -> UUID? {
|
||||
guard let tabId = selectedTabId else { return nil }
|
||||
return openBrowser(
|
||||
inWorkspace: tabId,
|
||||
url: url,
|
||||
preferSplitRight: false,
|
||||
preferredProfileID: preferredProfileID,
|
||||
insertAtEnd: insertAtEnd
|
||||
)
|
||||
}
|
||||
|
|
@ -2642,7 +2662,12 @@ class TabManager: ObservableObject {
|
|||
in workspace: Workspace
|
||||
) -> UUID? {
|
||||
if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }),
|
||||
let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) {
|
||||
let browserPanel = workspace.newBrowserSurface(
|
||||
inPane: originalPane,
|
||||
url: snapshot.url,
|
||||
focus: true,
|
||||
preferredProfileID: snapshot.profileID
|
||||
) {
|
||||
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
|
||||
let maxIndex = max(0, tabCount - 1)
|
||||
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
|
||||
|
|
@ -2659,7 +2684,8 @@ class TabManager: ObservableObject {
|
|||
from: anchorPanelId,
|
||||
orientation: orientation,
|
||||
insertFirst: snapshot.fallbackSplitInsertFirst,
|
||||
url: snapshot.url
|
||||
url: snapshot.url,
|
||||
preferredProfileID: snapshot.profileID
|
||||
)?.id {
|
||||
return browserPanelId
|
||||
}
|
||||
|
|
@ -2667,7 +2693,12 @@ class TabManager: ObservableObject {
|
|||
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
|
||||
return nil
|
||||
}
|
||||
return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id
|
||||
return workspace.newBrowserSurface(
|
||||
inPane: focusedPane,
|
||||
url: snapshot.url,
|
||||
focus: true,
|
||||
preferredProfileID: snapshot.profileID
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Flash the currently focused panel so the user can visually confirm focus.
|
||||
|
|
|
|||
|
|
@ -335,6 +335,7 @@ extension Workspace {
|
|||
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
|
||||
browserSnapshot = SessionBrowserPanelSnapshot(
|
||||
urlString: browserPanel.preferredURLStringForOmnibar(),
|
||||
profileID: browserPanel.profileID,
|
||||
shouldRenderWebView: browserPanel.shouldRenderWebView,
|
||||
pageZoom: Double(browserPanel.webView.pageZoom),
|
||||
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
|
||||
|
|
@ -516,7 +517,8 @@ extension Workspace {
|
|||
guard let browserPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: initialURL,
|
||||
focus: false
|
||||
focus: false,
|
||||
preferredProfileID: snapshot.browser?.profileID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -901,6 +903,7 @@ enum SidebarBranchOrdering {
|
|||
struct ClosedBrowserPanelRestoreSnapshot {
|
||||
let workspaceId: UUID
|
||||
let url: URL?
|
||||
let profileID: UUID?
|
||||
let originalPaneId: UUID
|
||||
let originalTabIndex: Int
|
||||
let fallbackSplitOrientation: SplitOrientation?
|
||||
|
|
@ -918,6 +921,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var isPinned: Bool = false
|
||||
@Published var customColor: String? // hex string, e.g. "#C0392B"
|
||||
@Published var currentDirectory: String
|
||||
private(set) var preferredBrowserProfileID: UUID?
|
||||
|
||||
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
|
||||
var portOrdinal: Int = 0
|
||||
|
|
@ -1330,6 +1334,35 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
}
|
||||
panelSubscriptions[browserPanel.id] = subscription
|
||||
setPreferredBrowserProfileID(browserPanel.profileID)
|
||||
}
|
||||
|
||||
func setPreferredBrowserProfileID(_ profileID: UUID?) {
|
||||
guard let profileID else {
|
||||
preferredBrowserProfileID = nil
|
||||
return
|
||||
}
|
||||
guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return }
|
||||
preferredBrowserProfileID = profileID
|
||||
}
|
||||
|
||||
private func resolvedNewBrowserProfileID(
|
||||
preferredProfileID: UUID? = nil,
|
||||
sourcePanelId: UUID? = nil
|
||||
) -> UUID {
|
||||
if let preferredProfileID,
|
||||
BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil {
|
||||
return preferredProfileID
|
||||
}
|
||||
if let sourcePanelId,
|
||||
let sourceBrowserPanel = browserPanel(for: sourcePanelId) {
|
||||
return sourceBrowserPanel.profileID
|
||||
}
|
||||
if let preferredBrowserProfileID,
|
||||
BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil {
|
||||
return preferredBrowserProfileID
|
||||
}
|
||||
return BrowserProfileStore.shared.effectiveLastUsedProfileID
|
||||
}
|
||||
|
||||
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
|
||||
|
|
@ -2197,6 +2230,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
focus: Bool = true
|
||||
) -> BrowserPanel? {
|
||||
// Find the pane containing the source panel
|
||||
|
|
@ -2213,9 +2247,17 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
guard let paneId = sourcePaneId else { return nil }
|
||||
|
||||
// Create browser panel
|
||||
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
|
||||
let browserPanel = BrowserPanel(
|
||||
workspaceId: id,
|
||||
profileID: resolvedNewBrowserProfileID(
|
||||
preferredProfileID: preferredProfileID,
|
||||
sourcePanelId: panelId
|
||||
),
|
||||
initialURL: url
|
||||
)
|
||||
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(
|
||||
|
|
@ -2271,17 +2313,20 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
url: URL? = nil,
|
||||
focus: Bool? = nil,
|
||||
insertAtEnd: Bool = false,
|
||||
preferredProfileID: UUID? = nil,
|
||||
bypassInsecureHTTPHostOnce: String? = nil
|
||||
) -> BrowserPanel? {
|
||||
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
||||
|
||||
let browserPanel = BrowserPanel(
|
||||
workspaceId: id,
|
||||
profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID),
|
||||
initialURL: url,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
||||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||
setPreferredBrowserProfileID(browserPanel.profileID)
|
||||
|
||||
guard let newTabId = bonsplitController.createTab(
|
||||
title: browserPanel.displayTitle,
|
||||
|
|
@ -2754,6 +2799,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
|
||||
workspaceId: id,
|
||||
url: resolvedURL,
|
||||
profileID: browserPanel.profileID,
|
||||
originalPaneId: pane.id,
|
||||
originalTabIndex: tabIndex,
|
||||
fallbackSplitOrientation: fallbackPlan?.orientation,
|
||||
|
|
@ -3933,14 +3979,27 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
|
||||
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return }
|
||||
let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID }
|
||||
guard let newPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
preferredProfileID: preferredProfileID
|
||||
) else { return }
|
||||
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
}
|
||||
|
||||
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
|
||||
guard let panelId = panelIdFromSurfaceId(anchorTabId),
|
||||
let browser = browserPanel(for: panelId) else { return }
|
||||
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
|
||||
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: browser.currentURL,
|
||||
focus: true,
|
||||
preferredProfileID: browser.profileID
|
||||
) else { return }
|
||||
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
}
|
||||
|
||||
private func promptRenamePanel(tabId: TabID) {
|
||||
|
|
|
|||
232
cmuxTests/BrowserImportMappingTests.swift
Normal file
232
cmuxTests/BrowserImportMappingTests.swift
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class BrowserImportMappingTests: XCTestCase {
|
||||
@MainActor
|
||||
func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() {
|
||||
let defaultProfile = BrowserProfileDefinition(
|
||||
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
|
||||
displayName: "Default",
|
||||
createdAt: .distantPast,
|
||||
isBuiltInDefault: true
|
||||
)
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true),
|
||||
makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false),
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.defaultPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: [defaultProfile],
|
||||
preferredSingleDestinationProfileID: defaultProfile.id
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.mode, .separateProfiles)
|
||||
XCTAssertEqual(plan.entries.count, 2)
|
||||
XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() {
|
||||
let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!
|
||||
let sourceProfile = makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-single",
|
||||
isDefault: true
|
||||
)
|
||||
|
||||
let plan = BrowserImportPlanResolver.defaultPlan(
|
||||
selectedSourceProfiles: [sourceProfile],
|
||||
destinationProfiles: [],
|
||||
preferredSingleDestinationProfileID: defaultProfileID
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.mode, .singleDestination)
|
||||
XCTAssertEqual(plan.entries.count, 1)
|
||||
XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSeparatePlanReusesExistingSameNamedDestinationProfiles() {
|
||||
let workID = UUID()
|
||||
let destinationProfiles = [
|
||||
BrowserProfileDefinition(
|
||||
id: workID,
|
||||
displayName: "You",
|
||||
createdAt: .distantPast,
|
||||
isBuiltInDefault: false
|
||||
)
|
||||
]
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true)
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.separateProfilesPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: destinationProfiles
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.entries.count, 1)
|
||||
XCTAssertEqual(plan.entries[0].destination, .existing(workID))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() {
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true),
|
||||
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false),
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.separateProfilesPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: []
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.entries.count, 2)
|
||||
XCTAssertEqual(plan.entries[0].destination, .createNamed("Work"))
|
||||
XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)"))
|
||||
}
|
||||
|
||||
func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() {
|
||||
let presentation = BrowserImportStep3Presentation(
|
||||
plan: BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-presentation-separate",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .createNamed("You")
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertTrue(presentation.showsSeparateRows)
|
||||
XCTAssertFalse(presentation.showsSingleDestinationPicker)
|
||||
}
|
||||
|
||||
func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() {
|
||||
let presentation = BrowserImportStep3Presentation(
|
||||
plan: BrowserImportExecutionPlan(
|
||||
mode: .mergeIntoOne,
|
||||
entries: []
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(presentation.showsSeparateRows)
|
||||
XCTAssertTrue(presentation.showsSingleDestinationPicker)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws {
|
||||
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = BrowserProfileStore(defaults: defaults)
|
||||
let plan = BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-realize-create",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .createNamed("You")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
|
||||
|
||||
XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"])
|
||||
XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws {
|
||||
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = BrowserProfileStore(defaults: defaults)
|
||||
let existing = try XCTUnwrap(store.createProfile(named: "You"))
|
||||
let plan = BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-realize-existing",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .existing(existing.id)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
|
||||
|
||||
XCTAssertTrue(realized.createdProfiles.isEmpty)
|
||||
XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id)
|
||||
}
|
||||
|
||||
func testAggregateOutcomeIncludesOneMappingLinePerDestination() {
|
||||
let outcome = BrowserImportOutcome(
|
||||
browserName: "Helium",
|
||||
scope: .cookiesAndHistory,
|
||||
domainFilters: [],
|
||||
createdDestinationProfileNames: ["You", "austin"],
|
||||
entries: [
|
||||
BrowserImportOutcomeEntry(
|
||||
sourceProfileNames: ["You"],
|
||||
destinationProfileName: "You",
|
||||
importedCookies: 10,
|
||||
skippedCookies: 0,
|
||||
importedHistoryEntries: 20,
|
||||
warnings: []
|
||||
),
|
||||
BrowserImportOutcomeEntry(
|
||||
sourceProfileNames: ["austin"],
|
||||
destinationProfileName: "austin",
|
||||
importedCookies: 5,
|
||||
skippedCookies: 1,
|
||||
importedHistoryEntries: 9,
|
||||
warnings: []
|
||||
),
|
||||
],
|
||||
warnings: []
|
||||
)
|
||||
|
||||
let lines = BrowserImportOutcomeFormatter.lines(for: outcome)
|
||||
|
||||
XCTAssertTrue(lines.contains("You -> You"))
|
||||
XCTAssertTrue(lines.contains("austin -> austin"))
|
||||
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
|
||||
}
|
||||
|
||||
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
|
||||
InstalledBrowserProfile(
|
||||
displayName: displayName,
|
||||
rootURL: URL(fileURLWithPath: path, isDirectory: true),
|
||||
isDefault: isDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -930,6 +930,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|||
ClosedBrowserPanelRestoreSnapshot(
|
||||
workspaceId: UUID(),
|
||||
url: URL(string: "https://example.com/\(index)"),
|
||||
profileID: nil,
|
||||
originalPaneId: UUID(),
|
||||
originalTabIndex: index,
|
||||
fallbackSplitOrientation: .horizontal,
|
||||
|
|
@ -1614,6 +1615,104 @@ final class BrowserInstallDetectorTests: XCTestCase {
|
|||
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
|
||||
}
|
||||
|
||||
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Default/History"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Local State"),
|
||||
contents: Data(
|
||||
"""
|
||||
{
|
||||
"profile": {
|
||||
"info_cache": {
|
||||
"Default": {
|
||||
"name": "Personal"
|
||||
},
|
||||
"Profile 1": {
|
||||
"name": "Work"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
|
||||
XCTFail("Expected Helium to be detected")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(helium.family, .chromium)
|
||||
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
|
||||
XCTAssertEqual(
|
||||
helium.profiles.map(\.rootURL.lastPathComponent),
|
||||
["Default", "Profile 1"]
|
||||
)
|
||||
}
|
||||
|
||||
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
try createFile(
|
||||
at: home.appendingPathComponent("Library/Safari/History.db"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: home.appendingPathComponent(
|
||||
"Library/Safari/Profiles/Work/History.db"
|
||||
),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: home.appendingPathComponent(
|
||||
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
|
||||
),
|
||||
contents: Data()
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
|
||||
XCTFail("Expected Safari to be detected")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"])
|
||||
XCTAssertEqual(
|
||||
safari.profiles.map { $0.rootURL.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/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
|
||||
isDirectory: true
|
||||
).path(percentEncoded: false),
|
||||
].sorted()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTemporaryHome() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,8 +150,10 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
|
||||
let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")
|
||||
let source = SessionBrowserPanelSnapshot(
|
||||
urlString: "https://example.com/current",
|
||||
profileID: profileID,
|
||||
shouldRenderWebView: true,
|
||||
pageZoom: 1.2,
|
||||
developerToolsVisible: true,
|
||||
|
|
@ -167,6 +169,7 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
let data = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
|
||||
XCTAssertEqual(decoded.urlString, source.urlString)
|
||||
XCTAssertEqual(decoded.profileID, source.profileID)
|
||||
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
|
||||
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
|
||||
}
|
||||
|
|
@ -183,6 +186,7 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
|
||||
XCTAssertEqual(decoded.urlString, "https://example.com/current")
|
||||
XCTAssertNil(decoded.profileID)
|
||||
XCTAssertNil(decoded.backHistoryURLStrings)
|
||||
XCTAssertNil(decoded.forwardHistoryURLStrings)
|
||||
}
|
||||
|
|
|
|||
131
cmuxUITests/BrowserImportProfilesUITests.swift
Normal file
131
cmuxUITests/BrowserImportProfilesUITests.swift
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
|
||||
final class BrowserImportProfilesUITests: XCTestCase {
|
||||
private var capturePath = ""
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json"
|
||||
try? FileManager.default.removeItem(atPath: capturePath)
|
||||
}
|
||||
|
||||
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
|
||||
let app = launchApp()
|
||||
|
||||
openImportWizard(app)
|
||||
app.buttons["Next"].click()
|
||||
app.buttons["Next"].click()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0),
|
||||
"Expected Step 3 to show the separate-profiles default"
|
||||
)
|
||||
XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists)
|
||||
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
|
||||
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
|
||||
|
||||
app.buttons["Start Import"].click()
|
||||
|
||||
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
|
||||
XCTAssertEqual(capture["mode"] as? String, "separateProfiles")
|
||||
XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory")
|
||||
|
||||
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
|
||||
XCTAssertEqual(entries.count, 2)
|
||||
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"])
|
||||
XCTAssertEqual(entries[0]["destinationKind"] as? String, "create")
|
||||
XCTAssertEqual(entries[0]["destinationName"] as? String, "You")
|
||||
XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"])
|
||||
XCTAssertEqual(entries[1]["destinationKind"] as? String, "create")
|
||||
XCTAssertEqual(entries[1]["destinationName"] as? String, "austin")
|
||||
}
|
||||
|
||||
func testMergeModeCapturesSingleMergedDestination() throws {
|
||||
let app = launchApp()
|
||||
|
||||
openImportWizard(app)
|
||||
app.buttons["Next"].click()
|
||||
app.buttons["Next"].click()
|
||||
|
||||
let mergeRadio = app.radioButtons["Merge all into one cmux profile"]
|
||||
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
|
||||
mergeRadio.click()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0),
|
||||
"Expected merge mode to show the single destination popup"
|
||||
)
|
||||
|
||||
app.buttons["Start Import"].click()
|
||||
|
||||
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
|
||||
XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne")
|
||||
|
||||
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"])
|
||||
XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing")
|
||||
XCTAssertEqual(entries[0]["destinationName"] as? String, "Default")
|
||||
}
|
||||
|
||||
private func launchApp() -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "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.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch in the foreground for browser import UI tests"
|
||||
)
|
||||
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()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0),
|
||||
"Expected the import wizard to open"
|
||||
)
|
||||
}
|
||||
|
||||
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
let url = URL(fileURLWithPath: capturePath)
|
||||
while Date() < deadline {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return object
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue