From 74cee7579361a161203179872be15977e8fed55c Mon Sep 17 00:00:00 2001 From: Brad Slayter Date: Fri, 3 May 2024 19:53:17 -0500 Subject: [PATCH] Address tab feedback (#2705) Task/Issue URL: https://app.asana.com/0/1199230911884351/1207132178310556/f Tech Design URL: CC: @ayoy **Description**: Ship review for "Address tab management feedback" **Steps to test this PR**: 1. --- ###### Internal references: [Pull Request Review Checklist](https://app.asana.com/0/1202500774821704/1203764234894239/f) [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) [Pull Request Documentation](https://app.asana.com/0/1202500774821704/1204012835277482/f) --------- Co-authored-by: Dominik Kapusta --- DuckDuckGo.xcodeproj/project.pbxproj | 24 + DuckDuckGo/Common/Localizables/UserText.swift | 16 + .../Utilities/UserDefaultsWrapper.swift | 3 + DuckDuckGo/Localizable.xcstrings | 535 +++++++++++++++++- .../Preferences/Model/TabsPreferences.swift | 73 +++ .../View/PreferencesGeneralView.swift | 26 +- .../View/PreferencesRootView.swift | 1 + DuckDuckGo/Tab/Model/NewWindowPolicy.swift | 31 +- DuckDuckGo/Tab/Model/Tab+UIDelegate.swift | 8 +- DuckDuckGo/Tab/Model/Tab.swift | 11 +- .../TabExtensions/ContextMenuManager.swift | 20 +- .../Tab/View/BrowserTabViewController.swift | 5 +- DuckDuckGo/TabBar/Model/TabCollection.swift | 5 + .../TabBar/View/TabBarViewController.swift | 11 +- DuckDuckGo/TabBar/View/TabBarViewItem.swift | 32 +- .../ViewModel/TabCollectionViewModel.swift | 37 +- DuckDuckGo/TabBar/ViewModel/TabIndex.swift | 9 + .../Preferences/TabsPreferencesTests.swift | 53 ++ UnitTests/TabBar/Model/TabIndexTests.swift | 7 + .../TabBar/View/MockTabViewItemDelegate.swift | 7 +- .../TabBar/View/TabBarViewItemTests.swift | 41 +- ...bCollectionViewModelTests+PinnedTabs.swift | 38 ++ ...wModelTests+WithoutPinnedTabsManager.swift | 32 ++ .../TabCollectionViewModelTests.swift | 97 ++++ 24 files changed, 1088 insertions(+), 34 deletions(-) create mode 100644 DuckDuckGo/Preferences/Model/TabsPreferences.swift create mode 100644 UnitTests/Preferences/TabsPreferencesTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index f4d460fa88..68f3b7a707 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -7,10 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 021EA0802BD2A9D500772C9A /* TabsPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021EA07F2BD2A9D500772C9A /* TabsPreferences.swift */; }; + 021EA0812BD2A9D500772C9A /* TabsPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021EA07F2BD2A9D500772C9A /* TabsPreferences.swift */; }; + 021EA0842BD6E01A00772C9A /* TabsPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021EA0822BD6DF1B00772C9A /* TabsPreferencesTests.swift */; }; + 021EA0852BD6E0EB00772C9A /* TabsPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021EA0822BD6DF1B00772C9A /* TabsPreferencesTests.swift */; }; 0230C0A3272080090018F728 /* KeyedCodingExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0230C0A2272080090018F728 /* KeyedCodingExtension.swift */; }; 026ADE1426C3010C002518EE /* macos-config.json in Resources */ = {isa = PBXBuildFile; fileRef = 026ADE1326C3010C002518EE /* macos-config.json */; }; 028904202A7B25380028369C /* AppConfigurationURLProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0289041E2A7B23CE0028369C /* AppConfigurationURLProviderTests.swift */; }; 028904212A7B25770028369C /* AppConfigurationURLProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0289041E2A7B23CE0028369C /* AppConfigurationURLProviderTests.swift */; }; + 02C0737D2BE5B7E000BFE2F5 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 02C0737C2BE5B7E000BFE2F5 /* InfoPlist.xcstrings */; }; 142879DA24CE1179005419BB /* SuggestionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142879D924CE1179005419BB /* SuggestionViewModelTests.swift */; }; 142879DC24CE1185005419BB /* SuggestionContainerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142879DB24CE1185005419BB /* SuggestionContainerViewModelTests.swift */; }; 1430DFF524D0580F00B8978C /* TabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1430DFF424D0580F00B8978C /* TabBarViewController.swift */; }; @@ -2745,9 +2750,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 021EA07F2BD2A9D500772C9A /* TabsPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsPreferences.swift; sourceTree = ""; }; + 021EA0822BD6DF1B00772C9A /* TabsPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsPreferencesTests.swift; sourceTree = ""; }; 0230C0A2272080090018F728 /* KeyedCodingExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyedCodingExtension.swift; sourceTree = ""; }; 026ADE1326C3010C002518EE /* macos-config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "macos-config.json"; sourceTree = ""; }; 0289041E2A7B23CE0028369C /* AppConfigurationURLProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationURLProviderTests.swift; sourceTree = ""; }; + 02C0737C2BE5B7E000BFE2F5 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; 142879D924CE1179005419BB /* SuggestionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionViewModelTests.swift; sourceTree = ""; }; 142879DB24CE1185005419BB /* SuggestionContainerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionContainerViewModelTests.swift; sourceTree = ""; }; 1430DFF424D0580F00B8978C /* TabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarViewController.swift; sourceTree = ""; }; @@ -4773,6 +4781,7 @@ 37F19A6628E1B43200740DC6 /* DuckPlayerPreferences.swift */, 37CD54C527F2FDD100F1F7B9 /* AboutModel.swift */, 1D220BFB2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift */, + 021EA07F2BD2A9D500772C9A /* TabsPreferences.swift */, ); path = Model; sourceTree = ""; @@ -4884,6 +4893,7 @@ 1D9FDEC22B9B63C90040B78C /* DataClearingPreferencesTests.swift */, 3714B1E628EDB7FA0056C57A /* DuckPlayerPreferencesTests.swift */, 1D9FDEC52B9B64DB0040B78C /* PrivacyProtectionStatusTests.swift */, + 021EA0822BD6DF1B00772C9A /* TabsPreferencesTests.swift */, ); path = Preferences; sourceTree = ""; @@ -7914,6 +7924,7 @@ B6E6B9F42BA1FD90008AA7E1 /* sandbox-test-tool */ = { isa = PBXGroup; children = ( + 02C0737C2BE5B7E000BFE2F5 /* InfoPlist.xcstrings */, B6E6BA212BA2E4FB008AA7E1 /* Info.plist */, B6E6B9F52BA1FD90008AA7E1 /* SandboxTestTool.swift */, B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */, @@ -8618,6 +8629,7 @@ B6E6B9EF2BA1FD90008AA7E1 /* Sources */, B6E6B9F02BA1FD90008AA7E1 /* Frameworks */, B6AEB5532BA3029B00781A09 /* Cleanup entitlements */, + 02C0737E2BE5B7E000BFE2F5 /* Resources */, ); buildRules = ( ); @@ -8774,6 +8786,14 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 02C0737E2BE5B7E000BFE2F5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02C0737D2BE5B7E000BFE2F5 /* InfoPlist.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3706FCB1293F65D500E42796 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -9922,6 +9942,7 @@ 3706FC06293F65D500E42796 /* OnboardingViewModel.swift in Sources */, 3706FC07293F65D500E42796 /* ScriptSourceProviding.swift in Sources */, 31EF1E832B63FFCA00E6DB17 /* LoginItem+DataBrokerProtection.swift in Sources */, + 021EA0812BD2A9D500772C9A /* TabsPreferences.swift in Sources */, B6619EFC2B111CC600CD9186 /* InstructionsFormatParser.swift in Sources */, 3706FC08293F65D500E42796 /* CoreDataBookmarkImporter.swift in Sources */, 3706FC09293F65D500E42796 /* SuggestionViewModel.swift in Sources */, @@ -10392,6 +10413,7 @@ C1E961F32B87B273001760E1 /* MockAutofillActionExecutor.swift in Sources */, 376E2D2729428353001CD31B /* BrokenSiteReportingReferenceTests.swift in Sources */, 3707C72F294B5D4F00682A9F /* WebViewTests.swift in Sources */, + 021EA0852BD6E0EB00772C9A /* TabsPreferencesTests.swift in Sources */, 5682C69429B79B57004DE3C8 /* TabBarViewItemTests.swift in Sources */, 3706FE77293F661700E42796 /* PreferencesSidebarModelTests.swift in Sources */, 3706FE78293F661700E42796 /* HistoryCoordinatingMock.swift in Sources */, @@ -11289,6 +11311,7 @@ B684592725C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift in Sources */, B6DA06E62913F39400225DE2 /* MenuItemSelectors.swift in Sources */, 85589E9A27BFE3C30038AD11 /* FaviconView.swift in Sources */, + 021EA0802BD2A9D500772C9A /* TabsPreferences.swift in Sources */, 85707F2C276A364E00DC0649 /* OnboardingFlow.swift in Sources */, 4BE65480271FCD4D008D1D63 /* PasswordManagementLoginModel.swift in Sources */, AA9FF95B24A1EFC20039E328 /* TabViewModel.swift in Sources */, @@ -11729,6 +11752,7 @@ 56D145EE29E6DAD900E3488A /* DataImportProviderTests.swift in Sources */, 4BB99D0F26FE1A84001E4761 /* ChromiumBookmarksReaderTests.swift in Sources */, 1D9FDEB72B9B5D150040B78C /* SearchPreferencesTests.swift in Sources */, + 021EA0842BD6E01A00772C9A /* TabsPreferencesTests.swift in Sources */, 1D12F2E2298BC660009A65FD /* InternalUserDeciderStoreMock.swift in Sources */, 4BB99D1026FE1A84001E4761 /* FirefoxBookmarksReaderTests.swift in Sources */, 9FBD84702BB3DD8400220859 /* MockAttributionsPixelHandler.swift in Sources */, diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index f5e57f5a94..b6c361c836 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -204,6 +204,8 @@ struct UserText { static let muteTab = NSLocalizedString("mute.tab", value: "Mute Tab", comment: "Menu item. Mute tab") static let unmuteTab = NSLocalizedString("unmute.tab", value: "Unmute Tab", comment: "Menu item. Unmute tab") static let closeOtherTabs = NSLocalizedString("close.other.tabs", value: "Close Other Tabs", comment: "Menu item") + static let closeAllOtherTabs = NSLocalizedString("close.all.other.tabs", value: "Close All Other Tabs", comment: "Menu item") + static let closeTabsToTheLeft = NSLocalizedString("close.tabs.to.the.left", value: "Close Tabs to the Left", comment: "Menu item") static let closeTabsToTheRight = NSLocalizedString("close.tabs.to.the.right", value: "Close Tabs to the Right", comment: "Menu item") static let openInNewTab = NSLocalizedString("open.in.new.tab", value: "Open in New Tab", comment: "Menu item that opens the link in a new tab") static let openInNewWindow = NSLocalizedString("open.in.new.window", value: "Open in New Window", comment: "Menu item that opens the link in a new window") @@ -606,6 +608,20 @@ struct UserText { static let setHomePage = NSLocalizedString("preferences-homepage-set-homePage", value: "Set Homepage", comment: "Set Homepage dialog title") static let addressLabel = NSLocalizedString("preferences-homepage-address", value: "Address:", comment: "Homepage address field label") + static let tabs = NSLocalizedString("preferences-tabs.title", value: "Tabs", comment: "Title for tabs section in settings") + static let preferNewTabsToWindows = NSLocalizedString("preferences-tabs.prefer.new.tabs.to.windows", value: "Open links in new tabs instead of new windows whenever possible", comment: "Option to prefer opening new tabs instead of windows when opening links") + static let switchToNewTabWhenOpened = NSLocalizedString("preferences-tabs.switch.tab.when.opened", value: "When opening links, switch to the new tab or window immediately", comment: "Option to switch to a new tab/window when it is opened") + static let newTabPositionTitle = NSLocalizedString("preferences-tabs.new.tab.position.title", value: "When creating a new tab", comment: "Title for new tab positioning") + + static func newTabPositionMode(for position: NewTabPosition) -> String { + switch position { + case .atEnd: + return NSLocalizedString("context.menu.new.tab.mode.at.end", value: "Add to the right of other tabs", comment: "Preferences > Tabs > At end of list") + case .nextToCurrent: + return NSLocalizedString("context.menu.new.tab.mode.next.to.current", value: "Add to the right of the current tab", comment: "Preferences > Tabs > Next to current tab") + } + } + static func homeButtonMode(for position: HomeButtonPosition) -> String { switch position { case .hidden: diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index 92e2f23e0b..5891b891a5 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -89,6 +89,9 @@ public struct UserDefaultsWrapper { case currentThemeName = "com.duckduckgo.macos.currentThemeNameKey" case showFullURL = "preferences.appearance.show-full-url" case showAutocompleteSuggestions = "preferences.appearance.show-autocomplete-suggestions" + case preferNewTabsToWindows = "preferences.tabs.prefer-new-tabs-to-windows" + case switchToNewTabWhenOpened = "preferences.tabs.switch-to-new-tab-when-opened" + case newTabPosition = "preferences.tabs.new-tab-position" case defaultPageZoom = "preferences.appearance.default-page-zoom" case bookmarksBarAppearance = "preferences.appearance.bookmarks-bar" diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index b226cf84c7..71b681ff31 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -11469,6 +11469,66 @@ } } }, + "close.all.other.tabs" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle anderen Tabs schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close All Other Tabs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar todas las demás pestañas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer tous les autres onglets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi tutte le altre schede" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle andere tabbladen sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij wszystkie inne karty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar todos os outros separadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть все остальные вкладки" + } + } + } + }, "close.other.tabs" : { "comment" : "Menu item", "extractionState" : "extracted_with_value", @@ -11709,6 +11769,66 @@ } } }, + "close.tabs.to.the.left" : { + "comment" : "Menu item", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabs auf der linken Seite schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Close Tabs to the Left" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar las pestañas a la izquierda" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer les onglets à gauche" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi le schede a sinistra" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabbladen links sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij karty po lewej stronie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar separadores à esquerda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть вкладки слева" + } + } + } + }, "close.tabs.to.the.right" : { "comment" : "Menu item", "extractionState" : "extracted_with_value", @@ -12002,6 +12122,126 @@ } } }, + "context.menu.new.tab.mode.at.end" : { + "comment" : "Preferences > Tabs > At end of list", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechts neben anderen Tabs hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to the right of other tabs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir a la derecha de otras pestañas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter à droite des autres onglets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi a destra di altre schede" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toevoegen aan de rechterkant van andere tabbladen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj po prawej stronie innych kart" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar à direita dos outros separadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить справа от остальных вкладок" + } + } + } + }, + "context.menu.new.tab.mode.next.to.current" : { + "comment" : "Preferences > Tabs > Next to current tab", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechts neben dem aktuellen Tab hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add to the right of the current tab" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir a la derecha de la pestaña actual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter à droite de l'onglet actuel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi a destra della scheda attuale" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toevoegen aan de rechterkant van het huidige tabblad" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj po prawej stronie bieżącej karty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar à direita do separador atual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить справа от текущей вкладки" + } + } + } + }, "copy" : { "comment" : "Copy button", "extractionState" : "extracted_with_value", @@ -43652,6 +43892,246 @@ } } }, + "preferences-tabs.new.tab.position.title" : { + "comment" : "Title for new tab positioning", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Erstellen eines neuen Tabs" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When creating a new tab" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al crear una nueva pestaña" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lors de la création d'un nouvel onglet" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando si crea una nuova scheda" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bij het maken van een nieuw tabblad" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podczas tworzenia nowej karty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ao criar um novo separador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При создании новой вкладки" + } + } + } + }, + "preferences-tabs.prefer.new.tabs.to.windows" : { + "comment" : "Option to prefer opening new tabs instead of windows when opening links", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links nach Möglichkeit in neuen Tabs statt in neuen Fenstern öffnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open links in new tabs instead of new windows whenever possible" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir enlaces en nuevas pestañas en lugar de nuevas ventanas siempre que sea posible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les liens dans de nouveaux onglets plutôt que dans de nouvelles fenêtres si possible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri i collegamenti in nuove schede anziché in nuove finestre quando possibile" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links waar mogelijk openen in nieuwe tabbladen in plaats van in nieuwe vensters" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwieraj linki w nowych kartach zamiast w nowych oknach, gdy tylko jest to możliwe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir links em separadores novos em vez de novas janelas sempre que possível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По возможности открывать ссылки в новых вкладках, а не окнах" + } + } + } + }, + "preferences-tabs.switch.tab.when.opened" : { + "comment" : "Option to switch to a new tab/window when it is opened", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Öffnen von Links sofort zu einem neuen Tab oder Fenster wechseln" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "When opening links, switch to the new tab or window immediately" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al abrir enlaces, cambiar a la nueva pestaña o ventana inmediatamente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À l'ouverture des liens, basculer immédiatement sur le nouvel onglet ou la nouvelle fenêtre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando apri i collegamenti, passa immediatamente alla nuova scheda o finestra" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meteen overschakelen naar het nieuwe tabblad of venster bij het openen van links" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwierając linki, natychmiast przełącz na nową kartę lub nowe okno" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ao abrir links, mudar imediatamente para o novo separador ou janela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При открытии ссылки сразу переключаться на ее вкладку или окно" + } + } + } + }, + "preferences-tabs.title" : { + "comment" : "Title for tabs section in settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabs" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tabs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pestañas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onglets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schede" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabbladen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вкладки" + } + } + } + }, "preferences.about" : { "comment" : "Title of the option to show the About screen", "extractionState" : "extracted_with_value", @@ -46533,7 +47013,56 @@ } }, "Privacy Pro" : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy Pro" + } + } + } }, "private.search.explenation" : { "comment" : "feature explanation in settings", @@ -50814,7 +51343,7 @@ "it" : { "stringUnit" : { "state" : "translated", - "value" : "Ohne Titel" + "value" : "Senza titolo" } }, "nl" : { @@ -54335,4 +54864,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/DuckDuckGo/Preferences/Model/TabsPreferences.swift b/DuckDuckGo/Preferences/Model/TabsPreferences.swift new file mode 100644 index 0000000000..f407cc2db1 --- /dev/null +++ b/DuckDuckGo/Preferences/Model/TabsPreferences.swift @@ -0,0 +1,73 @@ +// +// TabsPreferences.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol TabsPreferencesPersistor { + var switchToNewTabWhenOpened: Bool { get set } + var preferNewTabsToWindows: Bool { get set } + var newTabPosition: NewTabPosition { get set } +} + +struct TabsPreferencesUserDefaultsPersistor: TabsPreferencesPersistor { + @UserDefaultsWrapper(key: .preferNewTabsToWindows, defaultValue: true) + var preferNewTabsToWindows: Bool + + @UserDefaultsWrapper(key: .switchToNewTabWhenOpened, defaultValue: false) + var switchToNewTabWhenOpened: Bool + + @UserDefaultsWrapper(key: .newTabPosition, defaultValue: .atEnd) + var newTabPosition: NewTabPosition +} + +final class TabsPreferences: ObservableObject, PreferencesTabOpening { + + static let shared = TabsPreferences() + + @Published var preferNewTabsToWindows: Bool { + didSet { + persistor.preferNewTabsToWindows = preferNewTabsToWindows + } + } + + @Published var switchToNewTabWhenOpened: Bool { + didSet { + persistor.switchToNewTabWhenOpened = switchToNewTabWhenOpened + } + } + + @Published var newTabPosition: NewTabPosition { + didSet { + persistor.newTabPosition = newTabPosition + } + } + + init(persistor: TabsPreferencesPersistor = TabsPreferencesUserDefaultsPersistor()) { + self.persistor = persistor + preferNewTabsToWindows = persistor.preferNewTabsToWindows + switchToNewTabWhenOpened = persistor.switchToNewTabWhenOpened + newTabPosition = persistor.newTabPosition + } + + private var persistor: TabsPreferencesPersistor +} + +enum NewTabPosition: String, CaseIterable { + case atEnd + case nextToCurrent +} diff --git a/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift b/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift index 7d22bc8867..41e92b6886 100644 --- a/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift @@ -28,6 +28,7 @@ extension Preferences { @ObservedObject var startupModel: StartupPreferences @ObservedObject var downloadsModel: DownloadsPreferences @ObservedObject var searchModel: SearchPreferences + @ObservedObject var tabsModel: TabsPreferences @ObservedObject var dataClearingModel: DataClearingPreferences @State private var showingCustomHomePageSheet = false @@ -60,7 +61,26 @@ extension Preferences { } } - // SECTION 2: Home Page + // SECTION 2: Tabs + PreferencePaneSection(UserText.tabs) { + PreferencePaneSubSection { + ToggleMenuItem(UserText.preferNewTabsToWindows, isOn: $tabsModel.preferNewTabsToWindows) + ToggleMenuItem(UserText.switchToNewTabWhenOpened, isOn: $tabsModel.switchToNewTabWhenOpened) + } + + PreferencePaneSubSection { + HStack { + Picker(UserText.newTabPositionTitle, selection: $tabsModel.newTabPosition) { + ForEach(NewTabPosition.allCases, id: \.self) { position in + Text(UserText.newTabPositionMode(for: position)).tag(position) + } + } + .fixedSize() + } + } + } + + // SECTION 3: Home Page PreferencePaneSection(UserText.homePage) { PreferencePaneSubSection { @@ -104,12 +124,12 @@ extension Preferences { CustomHomePageSheet(startupModel: startupModel, isSheetPresented: $showingCustomHomePageSheet) } - // SECTION 3: Search Settings + // SECTION 4: Search Settings PreferencePaneSection(UserText.privateSearch) { ToggleMenuItem(UserText.showAutocompleteSuggestions, isOn: $searchModel.showAutocompleteSuggestions).accessibilityIdentifier("PreferencesGeneralView.showAutocompleteSuggestions") } - // SECTION 4: Downloads + // SECTION 5: Downloads PreferencePaneSection(UserText.downloads) { PreferencePaneSubSection { ToggleMenuItem(UserText.downloadsOpenPopupOnCompletion, diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index 8cecf62db6..1f4fc03135 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -88,6 +88,7 @@ enum Preferences { GeneralView(startupModel: StartupPreferences.shared, downloadsModel: DownloadsPreferences.shared, searchModel: SearchPreferences.shared, + tabsModel: TabsPreferences.shared, dataClearingModel: DataClearingPreferences.shared) case .sync: SyncView() diff --git a/DuckDuckGo/Tab/Model/NewWindowPolicy.swift b/DuckDuckGo/Tab/Model/NewWindowPolicy.swift index ca9e575d29..80e237929e 100644 --- a/DuckDuckGo/Tab/Model/NewWindowPolicy.swift +++ b/DuckDuckGo/Tab/Model/NewWindowPolicy.swift @@ -20,14 +20,15 @@ import Foundation import WebKit enum NewWindowPolicy { - case tab(selected: Bool, burner: Bool) + case tab(selected: Bool, burner: Bool, contextMenuInitiated: Bool = false) case popup(origin: NSPoint?, size: NSSize?) case window(active: Bool, burner: Bool) - init(_ windowFeatures: WKWindowFeatures, shouldSelectNewTab: Bool = false, isBurner: Bool) { + init(_ windowFeatures: WKWindowFeatures, shouldSelectNewTab: Bool = false, isBurner: Bool, contextMenuInitiated: Bool = false) { if windowFeatures.toolbarsVisibility?.boolValue == true { self = .tab(selected: shouldSelectNewTab, - burner: isBurner) + burner: isBurner, + contextMenuInitiated: contextMenuInitiated) } else if windowFeatures.width != nil { self = .popup(origin: windowFeatures.origin, size: windowFeatures.size) @@ -37,7 +38,7 @@ enum NewWindowPolicy { // See https://app.asana.com/0/1177771139624306/1205690527704551/f. if #available(macOS 14.1, *), windowFeatures.statusBarVisibility == nil && windowFeatures.menuBarVisibility == nil { - self = .tab(selected: shouldSelectNewTab, burner: isBurner) + self = .tab(selected: shouldSelectNewTab, burner: isBurner, contextMenuInitiated: contextMenuInitiated) } else { self = .window(active: true, burner: isBurner) @@ -49,10 +50,30 @@ enum NewWindowPolicy { return false } var isSelectedTab: Bool { - if case .tab(selected: true, burner: _) = self { return true } + if case .tab(selected: true, burner: _, contextMenuInitiated: _) = self { return true } return false } + /** + * Replaces `.tab` with `.window` when user prefers windows over tabs. + */ + func preferringTabsToWindows(_ prefersTabsToWindows: Bool) -> NewWindowPolicy { + guard case .tab(_, let isBurner, contextMenuInitiated: false) = self, !prefersTabsToWindows else { + return self + } + return .window(active: true, burner: isBurner) + } + + /** + * Forces selecting a tab if `true` is passed as argument. + */ + func preferringSelectedTabs(_ prefersSelectedTabs: Bool) -> NewWindowPolicy { + guard case .tab(selected: false, burner: let isBurner, contextMenuInitiated: let contextMenuInitiated) = self, prefersSelectedTabs else { + return self + } + return .tab(selected: true, burner: isBurner, contextMenuInitiated: contextMenuInitiated) + } + } extension WKWindowFeatures { diff --git a/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift b/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift index fd2e4e8349..81b12ca0b0 100644 --- a/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift +++ b/DuckDuckGo/Tab/Model/Tab+UIDelegate.swift @@ -85,11 +85,12 @@ extension Tab: WKUIDelegate, PrintingUserScriptDelegate { windowFeatures: WKWindowFeatures, completionHandler: @escaping (WKWebView?) -> Void) { - switch newWindowPolicy(for: navigationAction) { + switch newWindowPolicy(for: navigationAction)?.preferringTabsToWindows(tabsPreferences.preferNewTabsToWindows) { // popup kind is known, action doesn‘t require Popup Permission case .allow(let targetKind): // proceed to web view creation - completionHandler(self.createWebView(from: webView, with: configuration, for: navigationAction, of: targetKind)) + completionHandler(self.createWebView(from: webView, with: configuration, + for: navigationAction, of: targetKind.preferringSelectedTabs(tabsPreferences.switchToNewTabWhenOpened))) return case .cancel: // navigation action was handled before and cancelled @@ -99,9 +100,10 @@ extension Tab: WKUIDelegate, PrintingUserScriptDelegate { break } - let shouldSelectNewTab = !NSApp.isCommandPressed // this is actually not correct, to be fixed later + let shouldSelectNewTab = !NSApp.isCommandPressed || tabsPreferences.switchToNewTabWhenOpened // this is actually not correct, to be fixed later // try to guess popup kind from provided windowFeatures let targetKind = NewWindowPolicy(windowFeatures, shouldSelectNewTab: shouldSelectNewTab, isBurner: burnerMode.isBurner) + .preferringTabsToWindows(tabsPreferences.preferNewTabsToWindows) // action doesn‘t require Popup Permission as it‘s user-initiated // TO BE FIXED: this also opens a new window when a popup ad is shown on click simultaneously with the main frame navigation: diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index 031cbbe851..1fd8c17493 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -70,6 +70,7 @@ protocol NewWindowPolicyDecisionMaker { private let webViewConfiguration: WKWebViewConfiguration let startupPreferences: StartupPreferences + let tabsPreferences: TabsPreferences private var extensions: TabExtensions // accesing TabExtensions‘ Public Protocols projecting tab.extensions.extensionName to tab.extensionName @@ -108,7 +109,8 @@ protocol NewWindowPolicyDecisionMaker { webViewSize: CGSize = CGSize(width: 1024, height: 768), startupPreferences: StartupPreferences = StartupPreferences.shared, certificateTrustEvaluator: CertificateTrustEvaluating = CertificateTrustEvaluator(), - tunnelController: NetworkProtectionIPCTunnelController? = TunnelControllerProvider.shared.tunnelController + tunnelController: NetworkProtectionIPCTunnelController? = TunnelControllerProvider.shared.tunnelController, + tabsPreferences: TabsPreferences = TabsPreferences.shared ) { let duckPlayer = duckPlayer @@ -150,7 +152,8 @@ protocol NewWindowPolicyDecisionMaker { webViewSize: webViewSize, startupPreferences: startupPreferences, certificateTrustEvaluator: certificateTrustEvaluator, - tunnelController: tunnelController) + tunnelController: tunnelController, + tabsPreferences: tabsPreferences) } @MainActor @@ -183,7 +186,8 @@ protocol NewWindowPolicyDecisionMaker { webViewSize: CGSize, startupPreferences: StartupPreferences, certificateTrustEvaluator: CertificateTrustEvaluating, - tunnelController: NetworkProtectionIPCTunnelController? + tunnelController: NetworkProtectionIPCTunnelController?, + tabsPreferences: TabsPreferences ) { self.content = content @@ -200,6 +204,7 @@ protocol NewWindowPolicyDecisionMaker { self.interactionState = interactionStateData.map(InteractionState.loadCachedFromTabContent) ?? .none self.lastSelectedAt = lastSelectedAt self.startupPreferences = startupPreferences + self.tabsPreferences = tabsPreferences let configuration = webViewConfiguration ?? WKWebViewConfiguration() configuration.applyStandardConfiguration(contentBlocking: privacyFeatures.contentBlocking, diff --git a/DuckDuckGo/Tab/TabExtensions/ContextMenuManager.swift b/DuckDuckGo/Tab/TabExtensions/ContextMenuManager.swift index e966c8b7a0..57174a6ec7 100644 --- a/DuckDuckGo/Tab/TabExtensions/ContextMenuManager.swift +++ b/DuckDuckGo/Tab/TabExtensions/ContextMenuManager.swift @@ -24,6 +24,16 @@ import WebKit enum NavigationDecision { case allow(NewWindowPolicy) case cancel + + /** + * Replaces `.tab` with `.window` when user prefers windows over tabs. + */ + func preferringTabsToWindows(_ prefersTabsToWindows: Bool) -> NavigationDecision { + guard case .allow(let targetKind) = self, !prefersTabsToWindows else { + return self + } + return .allow(targetKind.preferringTabsToWindows(prefersTabsToWindows)) + } } @MainActor @@ -35,6 +45,8 @@ final class ContextMenuManager: NSObject { private var selectedText: String? private var linkURL: String? + private var tabsPreferences: TabsPreferences + private var isEmailAddress: Bool { guard let linkURL, let url = URL(string: linkURL) else { return false @@ -52,7 +64,9 @@ final class ContextMenuManager: NSObject { fileprivate weak var webView: WKWebView? @MainActor - init(contextMenuScriptPublisher: some Publisher) { + init(contextMenuScriptPublisher: some Publisher, + tabsPreferences: TabsPreferences = TabsPreferences.shared) { + self.tabsPreferences = tabsPreferences super.init() userScriptCancellable = contextMenuScriptPublisher.sink { [weak self] contextMenuScript in @@ -360,8 +374,8 @@ private extension ContextMenuManager { return } - onNewWindow = { _ in - .allow(.tab(selected: false, burner: burner)) + onNewWindow = { [weak self] _ in + .allow(.tab(selected: self?.tabsPreferences.switchToNewTabWhenOpened ?? false, burner: burner, contextMenuInitiated: true)) } NSApp.sendAction(action, to: originalItem.target, from: originalItem) } diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 9c2f65619f..dde4a10a29 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -547,7 +547,8 @@ final class BrowserTabViewController: NSViewController { shouldLoadInBackground: true, burnerMode: tabCollectionViewModel.burnerMode, webViewSize: view.frame.size) - tabCollectionViewModel.append(tab: tab, selected: true) + + tabCollectionViewModel.insertOrAppend(tab: tab, selected: true) } // MARK: - Browser Tabs @@ -834,7 +835,7 @@ extension BrowserTabViewController: TabDelegate { case .window(active: let active, let isBurner): assert(isBurner == childTab.burnerMode.isBurner) WindowsManager.openNewWindow(with: childTab, showWindow: active) - case .tab(selected: let selected, _): + case .tab(selected: let selected, _, _): self.tabCollectionViewModel.insert(childTab, after: parentTab, selected: selected) } } diff --git a/DuckDuckGo/TabBar/Model/TabCollection.swift b/DuckDuckGo/TabBar/Model/TabCollection.swift index 94b632fa86..87c15fe1a6 100644 --- a/DuckDuckGo/TabBar/Model/TabCollection.swift +++ b/DuckDuckGo/TabBar/Model/TabCollection.swift @@ -78,6 +78,11 @@ final class TabCollection: NSObject { tabs = tab.map { [$0] } ?? [] } + func removeTabs(before index: Int) { + tabsWillClose(range: 0..() + private var tabsPreferences: TabsPreferences private var startupPreferences: StartupPreferences private var homePage: Tab.TabContent { var homePage: Tab.TabContent = .newtab @@ -117,12 +118,14 @@ final class TabCollectionViewModel: NSObject { selectionIndex: Int = 0, pinnedTabsManager: PinnedTabsManager?, burnerMode: BurnerMode = .regular, - startupPreferences: StartupPreferences = StartupPreferences.shared + startupPreferences: StartupPreferences = StartupPreferences.shared, + tabsPreferences: TabsPreferences = TabsPreferences.shared ) { self.tabCollection = tabCollection self.pinnedTabsManager = pinnedTabsManager self.burnerMode = burnerMode self.startupPreferences = startupPreferences + self.tabsPreferences = tabsPreferences super.init() subscribeToTabs() @@ -321,6 +324,10 @@ final class TabCollectionViewModel: NSObject { delegate?.tabCollectionViewModelDidMultipleChanges(self) } + func insertNewTab(after parentTab: Tab, with content: Tab.TabContent = .newtab, selected: Bool = true) { + insert(Tab(content: content, shouldLoadInBackground: true, burnerMode: burnerMode), after: parentTab, selected: selected) + } + func insert(_ tab: Tab, at index: TabIndex, selected: Bool = true) { guard changesEnabled else { return } guard let tabCollection = tabCollection(for: index) else { @@ -363,6 +370,18 @@ final class TabCollectionViewModel: NSObject { } } + func insertOrAppendNewTab(_ content: Tab.TabContent = .newtab, selected: Bool = true) { + insertOrAppend(tab: Tab(content: content, shouldLoadInBackground: true, burnerMode: burnerMode), selected: selected) + } + + func insertOrAppend(tab: Tab, selected: Bool) { + if tabsPreferences.newTabPosition == .nextToCurrent, let selectionIndex { + self.insert(tab, at: selectionIndex.makeNextUnpinned(), selected: selected) + } else { + append(tab: tab, selected: selected) + } + } + // MARK: - Removal func removeAll(with content: Tab.TabContent) { @@ -489,6 +508,22 @@ final class TabCollectionViewModel: NSObject { delegate?.tabCollectionViewModelDidMultipleChanges(self) } + func removeTabs(before index: Int) { + guard changesEnabled else { return } + + tabCollection.removeTabs(before: index) + + if let currentSelection = selectionIndex, currentSelection.isUnpinnedTab { + if currentSelection.item < index { + selectionIndex = .unpinned(0) + } else { + selectionIndex = .unpinned(currentSelection.item - index) + } + } + + delegate?.tabCollectionViewModelDidMultipleChanges(self) + } + func removeTabs(after index: Int) { guard changesEnabled else { return } diff --git a/DuckDuckGo/TabBar/ViewModel/TabIndex.swift b/DuckDuckGo/TabBar/ViewModel/TabIndex.swift index c55bd59a7f..7a2675d1bb 100644 --- a/DuckDuckGo/TabBar/ViewModel/TabIndex.swift +++ b/DuckDuckGo/TabBar/ViewModel/TabIndex.swift @@ -64,6 +64,15 @@ enum TabIndex: Equatable, Comparable { } } + func makeNextUnpinned() -> TabIndex { + switch self { + case .pinned: + return .unpinned(0) + case let .unpinned(index): + return .unpinned(index + 1) + } + } + func isInSameSection(as other: TabIndex) -> Bool { switch (self, other) { case (.pinned, .unpinned), (.unpinned, .pinned): diff --git a/UnitTests/Preferences/TabsPreferencesTests.swift b/UnitTests/Preferences/TabsPreferencesTests.swift new file mode 100644 index 0000000000..4faab128cc --- /dev/null +++ b/UnitTests/Preferences/TabsPreferencesTests.swift @@ -0,0 +1,53 @@ +// +// TabsPreferencesTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +class MockTabsPreferencesPersistor: TabsPreferencesPersistor { + var preferNewTabsToWindows: Bool = false + var switchToNewTabWhenOpened: Bool = false + var newTabPosition: NewTabPosition = .atEnd +} + +final class TabsPreferencesTests: XCTestCase { + + func testWhenInitializedThenItLoadsPersistedValues() { + let mockPersistor = MockTabsPreferencesPersistor() + mockPersistor.preferNewTabsToWindows = true + mockPersistor.switchToNewTabWhenOpened = true + mockPersistor.newTabPosition = .nextToCurrent + let tabsPreferences = TabsPreferences(persistor: mockPersistor) + + XCTAssertTrue(tabsPreferences.preferNewTabsToWindows) + XCTAssertTrue(tabsPreferences.switchToNewTabWhenOpened) + XCTAssertEqual(tabsPreferences.newTabPosition, .nextToCurrent) + } + + func testWhenPreferencesUpdatedThenPersistorUpdates() { + let mockPersistor = MockTabsPreferencesPersistor() + let tabsPreferences = TabsPreferences(persistor: mockPersistor) + tabsPreferences.preferNewTabsToWindows = true + tabsPreferences.switchToNewTabWhenOpened = true + tabsPreferences.newTabPosition = .nextToCurrent + + XCTAssertTrue(mockPersistor.preferNewTabsToWindows) + XCTAssertTrue(mockPersistor.switchToNewTabWhenOpened) + XCTAssertEqual(mockPersistor.newTabPosition, .nextToCurrent) + } +} diff --git a/UnitTests/TabBar/Model/TabIndexTests.swift b/UnitTests/TabBar/Model/TabIndexTests.swift index 662eb70f99..2402d42eed 100644 --- a/UnitTests/TabBar/Model/TabIndexTests.swift +++ b/UnitTests/TabBar/Model/TabIndexTests.swift @@ -51,6 +51,13 @@ final class TabIndexTests: XCTestCase { XCTAssertEqual(TabIndex.pinned(16).makeNext(), TabIndex.pinned(17)) } + func testMakeNextUnpinned() { + XCTAssertEqual(TabIndex.unpinned(0).makeNextUnpinned(), TabIndex.unpinned(1)) + XCTAssertEqual(TabIndex.unpinned(41).makeNextUnpinned(), TabIndex.unpinned(42)) + XCTAssertEqual(TabIndex.pinned(0).makeNextUnpinned(), TabIndex.unpinned(0)) + XCTAssertEqual(TabIndex.pinned(2).makeNextUnpinned(), TabIndex.unpinned(0)) + } + func testWhenViewModelHasNoPinnedTabsThenFirstTabIsUnpinned() { let tabCollectionViewModel = TabCollectionViewModel( tabCollection: tabCollection(tabsCount: 1), diff --git a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift index 9157000830..63b4a7fc97 100644 --- a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift +++ b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift @@ -24,6 +24,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { var mockedCurrentTab: Tab? var canBookmarkAllOpenTabs = false + var hasItemsToTheLeft = false var hasItemsToTheRight = false var audioState: WKWebView.AudioState? @@ -45,6 +46,10 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } + func tabBarViewItemCloseToTheLeftAction(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) { + + } + func tabBarViewItemCloseToTheRightAction(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) { } @@ -106,7 +111,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } func otherTabBarViewItemsState(for tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem) -> DuckDuckGo_Privacy_Browser.OtherTabBarViewItemsState { - OtherTabBarViewItemsState(hasItemsToTheLeft: false, hasItemsToTheRight: hasItemsToTheRight) + OtherTabBarViewItemsState(hasItemsToTheLeft: hasItemsToTheLeft, hasItemsToTheRight: hasItemsToTheRight) } func clear() { diff --git a/UnitTests/TabBar/View/TabBarViewItemTests.swift b/UnitTests/TabBar/View/TabBarViewItemTests.swift index 821ca0b5cc..19505467d6 100644 --- a/UnitTests/TabBar/View/TabBarViewItemTests.swift +++ b/UnitTests/TabBar/View/TabBarViewItemTests.swift @@ -52,8 +52,16 @@ final class TabBarViewItemTests: XCTestCase { XCTAssertTrue(menu.item(at: 7)?.isSeparatorItem ?? false) XCTAssertEqual(menu.item(at: 8)?.title, UserText.closeTab) XCTAssertEqual(menu.item(at: 9)?.title, UserText.closeOtherTabs) - XCTAssertEqual(menu.item(at: 10)?.title, UserText.closeTabsToTheRight) - XCTAssertEqual(menu.item(at: 11)?.title, UserText.moveTabToNewWindow) + XCTAssertEqual(menu.item(at: 10)?.title, UserText.moveTabToNewWindow) + + // Check "Close Other Tabs" submenu + guard let submenu = menu.item(at: 9)?.submenu else { + XCTFail("\"Close Other Tabs\" menu item should have a submenu") + return + } + XCTAssertEqual(submenu.item(at: 0)?.title, UserText.closeTabsToTheLeft) + XCTAssertEqual(submenu.item(at: 1)?.title, UserText.closeTabsToTheRight) + XCTAssertEqual(submenu.item(at: 2)?.title, UserText.closeAllOtherTabs) } func testThatMuteIsShownWhenCurrentAudioStateIsUnmuted() { @@ -77,7 +85,8 @@ final class TabBarViewItemTests: XCTestCase { func testWhenOneTabCloseThenOtherTabsItemIsDisabled() { tabBarViewItem.menuNeedsUpdate(menu) - let item = menu.items .first { $0.title == UserText.closeOtherTabs } + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeAllOtherTabs } XCTAssertFalse(item?.isEnabled ?? true) } @@ -85,7 +94,8 @@ final class TabBarViewItemTests: XCTestCase { delegate.hasItemsToTheRight = true tabBarViewItem.menuNeedsUpdate(menu) - let item = menu.items .first { $0.title == UserText.closeOtherTabs } + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeAllOtherTabs } XCTAssertTrue(item?.isEnabled ?? false) } @@ -104,10 +114,28 @@ final class TabBarViewItemTests: XCTestCase { XCTAssertTrue(item?.isEnabled ?? false) } + func testWhenNoTabsToTheLeftThenCloseTabsToTheLeftIsDisabled() { + tabBarViewItem.menuNeedsUpdate(menu) + + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeTabsToTheLeft } + XCTAssertFalse(item?.isEnabled ?? true) + } + + func testWhenTabsToTheLeftThenCloseTabsToTheLeftIsEnabled() { + delegate.hasItemsToTheLeft = true + tabBarViewItem.menuNeedsUpdate(menu) + + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeTabsToTheLeft } + XCTAssertTrue(item?.isEnabled ?? false) + } + func testWhenNoTabsToTheRightThenCloseTabsToTheRightIsDisabled() { tabBarViewItem.menuNeedsUpdate(menu) - let item = menu.items .first { $0.title == UserText.closeTabsToTheRight } + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeTabsToTheRight } XCTAssertFalse(item?.isEnabled ?? true) } @@ -115,7 +143,8 @@ final class TabBarViewItemTests: XCTestCase { delegate.hasItemsToTheRight = true tabBarViewItem.menuNeedsUpdate(menu) - let item = menu.items .first { $0.title == UserText.closeTabsToTheRight } + let submenu = menu.items .first { $0.title == UserText.closeOtherTabs } + let item = submenu?.submenu?.items .first { $0.title == UserText.closeTabsToTheRight } XCTAssertTrue(item?.isEnabled ?? false) } diff --git a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+PinnedTabs.swift b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+PinnedTabs.swift index 7a8394baef..07932afb14 100644 --- a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+PinnedTabs.swift +++ b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+PinnedTabs.swift @@ -110,6 +110,16 @@ extension TabCollectionViewModelTests { // MARK: - Insert + func test_WithPinnedTabsManager_WhenInsertNewTabIsCalledThenNewTabIsAlsoSelected() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModelWithPinnedTab() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertNewTab(after: tabCollectionViewModel.selectedTabViewModel!.tab) + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + } + func test_WithPinnedTabs_WhenInsertChildOfPinnedTabAndNoOtherChildTabIsNearParent_ThenTabIsInsertedAsFirstUnpinnedTab() { let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModelWithPinnedTab() @@ -138,6 +148,34 @@ extension TabCollectionViewModelTests { XCTAssertIdentical(tab, tabCollectionViewModel.tabViewModel(at: 3)?.tab) } + // MARK: - Insert or Append + + func test_WithPinnedTabs_WhenInsertOrAppendCalledPreferencesAreRespected() { + let persistor = MockTabsPreferencesPersistor() + var tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: PinnedTabsManager(), + tabsPreferences: TabsPreferences(persistor: persistor)) + tabCollectionViewModel.appendPinnedTab() + + let index = tabCollectionViewModel.tabCollection.tabs.count + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: index)) + + persistor.newTabPosition = .nextToCurrent + tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: PinnedTabsManager(), + tabsPreferences: TabsPreferences(persistor: persistor)) + tabCollectionViewModel.appendPinnedTab() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + + tabCollectionViewModel.select(at: .pinned(0)) + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 0)) + } + // MARK: - Remove func test_WithPinnedTabs_WhenRemoveIsCalledWithIndexOutOfBoundsThenNoTabIsRemoved() { diff --git a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+WithoutPinnedTabsManager.swift b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+WithoutPinnedTabsManager.swift index 875c7746a4..2319fbaf17 100644 --- a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+WithoutPinnedTabsManager.swift +++ b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests+WithoutPinnedTabsManager.swift @@ -231,6 +231,16 @@ extension TabCollectionViewModelTests { // MARK: - Insert + func test_WithoutPinnedTabsManager_WhenInsertNewTabIsCalledThenNewTabIsAlsoSelected() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertNewTab(after: tabCollectionViewModel.selectedTabViewModel!.tab) + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + } + func test_WithoutPinnedTabsManager_WhenInsertChildAndParentIsntPartOfTheTabCollection_ThenNoChildIsInserted() { let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() @@ -284,6 +294,28 @@ extension TabCollectionViewModelTests { XCTAssert(tab === tabCollectionViewModel.tabViewModel(at: 2)?.tab) } + // MARK: - Insert or Append + + func test_WithoutPinnedTabsManager_WhenInsertOrAppendCalledPreferencesAreRespected() { + let persistor = MockTabsPreferencesPersistor() + var tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: nil, + tabsPreferences: TabsPreferences(persistor: persistor)) + + let index = tabCollectionViewModel.tabCollection.tabs.count + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: index)) + + persistor.newTabPosition = .nextToCurrent + tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: nil, + tabsPreferences: TabsPreferences(persistor: persistor)) + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + } + // MARK: - Remove func test_WithoutPinnedTabsManager_WhenRemoveIsCalledWithIndexOutOfBoundsThenNoTabIsRemoved() { diff --git a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift index 3415fd4439..5401520d36 100644 --- a/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift +++ b/UnitTests/TabBar/ViewModel/TabCollectionViewModelTests.swift @@ -222,6 +222,16 @@ final class TabCollectionViewModelTests: XCTestCase { // MARK: - Insert + func testWhenInsertNewTabIsCalledThenNewTabIsAlsoSelected() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertNewTab(after: tabCollectionViewModel.selectedTabViewModel!.tab) + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + } + func testWhenInsertChildAndParentIsntPartOfTheTabCollection_ThenNoChildIsInserted() { let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() @@ -275,6 +285,28 @@ final class TabCollectionViewModelTests: XCTestCase { XCTAssert(tab === tabCollectionViewModel.tabViewModel(at: 2)?.tab) } + // MARK: - Insert or Append + + func testWhenInsertOrAppendCalledPreferencesAreRespected() { + let persistor = MockTabsPreferencesPersistor() + var tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: PinnedTabsManager(), + tabsPreferences: TabsPreferences(persistor: persistor)) + + let index = tabCollectionViewModel.tabCollection.tabs.count + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: index)) + + persistor.newTabPosition = .nextToCurrent + tabCollectionViewModel = TabCollectionViewModel(tabCollection: TabCollection(), pinnedTabsManager: PinnedTabsManager(), + tabsPreferences: TabsPreferences(persistor: persistor)) + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(0)) + XCTAssertNotNil(tabCollectionViewModel.selectedTabViewModel) + tabCollectionViewModel.insertOrAppendNewTab() + XCTAssert(tabCollectionViewModel.selectedTabViewModel === tabCollectionViewModel.tabViewModel(at: 1)) + } + // MARK: - Remove func testWhenRemoveIsCalledWithIndexOutOfBoundsThenNoTabIsRemoved() { @@ -318,6 +350,71 @@ final class TabCollectionViewModelTests: XCTestCase { XCTAssertEqual(firstTab, tabCollectionViewModel.selectedTabViewModel?.tab) } + func testWhenTabsToTheLeftAreRemovedAndSelectionIsRemoved_ThenSelectionIsCorrectlyUpdated() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + + tabCollectionViewModel.select(at: .unpinned(1)) + tabCollectionViewModel.removeTabs(before: 2) + + XCTAssertEqual(tabCollectionViewModel.selectionIndex?.item, 0) + } + + func testWhenTabsToTheLeftAreRemovedAndSelectionRemains_ThenSelectionIsCorrectlyUpdated() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(3)) + tabCollectionViewModel.removeTabs(before: 3) + + XCTAssertEqual(tabCollectionViewModel.selectionIndex?.item, 0) + } + + func testWhenTabsToTheLeftAreRemovedAndSelectionRemainsAndIsToTheRight_ThenSelectionIsCorrectlyUpdated() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(4)) + tabCollectionViewModel.removeTabs(before: 2) + + XCTAssertEqual(tabCollectionViewModel.selectionIndex?.item, 2) + } + + func testWhenTabsToTheRightAreRemovedAndSelectionIsRemoved_ThenSelectionIsCorrectlyUpdated() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + + tabCollectionViewModel.select(at: .unpinned(1)) + tabCollectionViewModel.removeTabs(after: 0) + + XCTAssertEqual(tabCollectionViewModel.selectionIndex?.item, 0) + } + + func testWhenTabsToTheRightAreRemovedAndSelectionRemains_ThenSelectionIsCorrectlyUpdated() { + let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel() + + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.appendNewTab() + tabCollectionViewModel.select(at: .unpinned(1)) + tabCollectionViewModel.removeTabs(after: 1) + + XCTAssertEqual(tabCollectionViewModel.selectionIndex?.item, 1) + } + func testWhenLastTabIsRemoved_ThenSelectionIsNil() { let tabCollectionViewModel = TabCollectionViewModel.aTabCollectionViewModel()