From b1f3b2106b05da2e18f83d3d55bb7ed44e1b61af Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira Date: Tue, 30 Jul 2024 10:01:12 -0300 Subject: [PATCH] Feature: Search and sort bookmarks in bookmarks panel (#3022) --- DuckDuckGo.xcodeproj/project.pbxproj | 46 +- .../Bookmarks-Search-Empty.pdf | Bin 0 -> 3557 bytes .../Contents.json | 12 + .../BookmarkSortAsc.imageset/Contents.json | 15 + .../BookmarkSortAsc.imageset/sort-asc.svg | 7 + .../BookmarkSortDesc.imageset/Contents.json | 15 + .../BookmarkSortDesc.imageset/sort-desc.svg | 7 + .../Search-Bookmarks.imageset/Contents.json | 15 + .../search_bookmarks.svg | 10 + DuckDuckGo/Bookmarks/Model/Bookmark.swift | 13 + ...BookmarkListTreeControllerDataSource.swift | 64 +- ...rkListTreeControllerSearchDataSource.swift | 44 ++ .../Bookmarks/Model/BookmarkManager.swift | 2 +- DuckDuckGo/Bookmarks/Model/BookmarkNode.swift | 11 + .../Model/BookmarkOutlineViewDataSource.swift | 42 +- .../Model/BookmarkSidebarTreeController.swift | 2 +- .../Model/BookmarkTreeController.swift | 56 +- .../Model/BookmarksSearchAndSortMetrics.swift | 47 ++ .../Bookmarks/Services/ContextualMenu.swift | 54 +- .../Services/MenuItemSelectors.swift | 12 + .../View/BookmarkListViewController.swift | 348 ++++++++- ...kmarkManagementSidebarViewController.swift | 10 +- .../View/BookmarkOutlineCellView.swift | 18 +- .../Bookmarks/View/BookmarksOutlineView.swift | 31 + .../ViewModel/BookmarkSearchViewModel.swift | 41 - .../ViewModel/SortBookmarksViewModel.swift | 170 ++++ DuckDuckGo/Common/Localizables/UserText.swift | 10 + DuckDuckGo/InfoPlist.xcstrings | 144 ++++ DuckDuckGo/Localizable.xcstrings | 738 +++++++++++++++++- DuckDuckGo/Statistics/GeneralPixel.swift | 21 + .../Model/BaseBookmarkEntityTests.swift | 45 ++ .../Bookmarks/Model/BookmarkNodeTests.swift | 9 + .../BookmarkOutlineViewDataSourceTests.swift | 32 +- .../BookmarkSidebarTreeControllerTests.swift | 9 +- .../Bookmarks/Model/ContextualMenuTests.swift | 55 ++ .../Model/LocalBookmarkManagerTests.swift | 16 + .../Bookmarks/Model/TreeControllerTests.swift | 33 +- .../BookmarkSearchViewModelTests.swift | 54 -- .../ViewModels/BookmarksSortModeTests.swift | 103 +++ .../SortBookmarksViewModelTests.swift | 154 ++++ 40 files changed, 2255 insertions(+), 260 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Bookmarks-Search-Empty.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/sort-asc.svg create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/sort-desc.svg create mode 100644 DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/search_bookmarks.svg create mode 100644 DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift create mode 100644 DuckDuckGo/Bookmarks/Model/BookmarksSearchAndSortMetrics.swift delete mode 100644 DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift create mode 100644 DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift delete mode 100644 UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift create mode 100644 UnitTests/Bookmarks/ViewModels/BookmarksSortModeTests.swift create mode 100644 UnitTests/Bookmarks/ViewModels/SortBookmarksViewModelTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 4c646b6edb..53cd0a5457 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2533,13 +2533,20 @@ B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; }; B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; }; - BB1597BB2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */; }; - BB1597BC2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */; }; - BB1597C02C35B666001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */; }; - BB1597C12C35B667001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */; }; BB5789722B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */; }; + BB7B5F982C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */; }; + BB7B5F992C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */; }; + BB80AD062C57EF2500E6AC16 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BB80AD052C57EF2500E6AC16 /* InfoPlist.xcstrings */; }; + BBB881882C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */; }; + BBB881892C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */; }; + BBBEE1BF2C4FF63600035ABA /* SortBookmarksViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBEE1BE2C4FF63600035ABA /* SortBookmarksViewModelTests.swift */; }; + BBBEE1C02C4FF63600035ABA /* SortBookmarksViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBEE1BE2C4FF63600035ABA /* SortBookmarksViewModelTests.swift */; }; BBDFDC5A2B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */; }; BBDFDC5D2B2B8E2100F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */; }; + BBFB727F2C48047C0088884C /* SortBookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */; }; + BBFB72802C48047C0088884C /* SortBookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */; }; + BBFF355D2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */; }; + BBFF355E2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */; }; BD384AC92BBC821A00EF3735 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; BD384ACA2BBC821A00EF3735 /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC72BBC821100EF3735 /* vpn-light-mode.json */; }; BD384ACB2BBC821B00EF3735 /* vpn-dark-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */; }; @@ -4215,10 +4222,14 @@ B6F9BDE32B45CD1900677B33 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = ""; }; - BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModel.swift; sourceTree = ""; }; - BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModelTests.swift; sourceTree = ""; }; BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionSubscriptionEventHandler.swift; sourceTree = ""; }; + BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksSearchAndSortMetrics.swift; sourceTree = ""; }; + BB80AD052C57EF2500E6AC16 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListTreeControllerSearchDataSource.swift; sourceTree = ""; }; + BBBEE1BE2C4FF63600035ABA /* SortBookmarksViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortBookmarksViewModelTests.swift; sourceTree = ""; }; BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionExternalWaitlistPixels.swift; sourceTree = ""; }; + BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortBookmarksViewModel.swift; sourceTree = ""; }; + BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksSortModeTests.swift; sourceTree = ""; }; BD384AC72BBC821100EF3735 /* vpn-light-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = ""; }; BD384AC82BBC821100EF3735 /* vpn-dark-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-dark-mode.json"; sourceTree = ""; }; BDA7647B2BC497BE00D0400C /* DefaultVPNLocationFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultVPNLocationFormatter.swift; sourceTree = ""; }; @@ -6661,7 +6672,8 @@ 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */, 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */, 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */, - BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */, + BBFF355C2C4AF26200DA3289 /* BookmarksSortModeTests.swift */, + BBBEE1BE2C4FF63600035ABA /* SortBookmarksViewModelTests.swift */, ); path = ViewModels; sourceTree = ""; @@ -7488,7 +7500,7 @@ 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */, 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */, 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */, - BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */, + BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -7598,6 +7610,8 @@ 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */, 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, + BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, + BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, ); path = Model; sourceTree = ""; @@ -8327,6 +8341,7 @@ isa = PBXGroup; children = ( 56A51CE72BE65B340098722D /* InfoPlist.xcstrings */, + BB80AD052C57EF2500E6AC16 /* InfoPlist.xcstrings */, B6E6BA212BA2E4FB008AA7E1 /* Info.plist */, B6E6B9F52BA1FD90008AA7E1 /* SandboxTestTool.swift */, B6E6BA222BA2EDDE008AA7E1 /* FileReadResult.swift */, @@ -9388,6 +9403,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + BB80AD062C57EF2500E6AC16 /* InfoPlist.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9878,6 +9894,7 @@ 3706FA93293F65D500E42796 /* WKWebView+Download.swift in Sources */, 3706FA94293F65D500E42796 /* TabShadowConfig.swift in Sources */, 3706FA97293F65D500E42796 /* WindowDraggingView.swift in Sources */, + BB7B5F992C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */, B60D644A2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 1D01A3D52B88CF7700FE8150 /* AccessibilityPreferences.swift in Sources */, 3706FA98293F65D500E42796 /* SecureVaultSorting.swift in Sources */, @@ -10017,6 +10034,7 @@ 4BF0E5062AD2551A00FFEC9E /* NetworkProtectionPixelEvent.swift in Sources */, 3706FAF8293F65D500E42796 /* URLEventHandler.swift in Sources */, 9FBD84742BB3E15D00220859 /* InstallationAttributionPixelHandler.swift in Sources */, + BBFB72802C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, 37197EA72942443D00394917 /* AuthenticationAlert.swift in Sources */, 3706FEC3293F6F0600E42796 /* BWCommunicator.swift in Sources */, 3706FAFA293F65D500E42796 /* CleanThisHistoryMenuItem.swift in Sources */, @@ -10077,6 +10095,7 @@ 3706FB22293F65D500E42796 /* NSTextViewExtension.swift in Sources */, 3706FB23293F65D500E42796 /* DownloadsCellView.swift in Sources */, 3706FB25293F65D500E42796 /* PublishedAfter.swift in Sources */, + BBB881892C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */, 3706FEC1293F6EFF00E42796 /* BWCredential.swift in Sources */, 3706FEC9293F6F7500E42796 /* BWManagement.swift in Sources */, 3706FB27293F65D500E42796 /* DeviceAuthenticationService.swift in Sources */, @@ -10100,7 +10119,6 @@ 3706FB35293F65D500E42796 /* FlatButton.swift in Sources */, 3706FB36293F65D500E42796 /* PinnedTabView.swift in Sources */, 3706FB37293F65D500E42796 /* DataEncryption.swift in Sources */, - BB1597BC2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */, 56BA1E762BAAF70F001CF69F /* SSLErrorPageTabExtension.swift in Sources */, 4B9DB0362A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, 37197EA82942443D00394917 /* BrowserTabViewController.swift in Sources */, @@ -10776,6 +10794,7 @@ 56A0542E2C201DAA007D8FAB /* MockContentBlocking.swift in Sources */, 3706FE1F293F661700E42796 /* AppStateChangePublisherTests.swift in Sources */, 9FA5A0B12BC9039300153786 /* BookmarkFolderStoreMock.swift in Sources */, + BBBEE1C02C4FF63600035ABA /* SortBookmarksViewModelTests.swift in Sources */, 9F0660792BECC81C00B8EEF1 /* PixelCapturedParameters.swift in Sources */, 3706FE20293F661700E42796 /* CLLocationManagerMock.swift in Sources */, B6656E0E2B29C733008798A1 /* FileImportViewLocalizationTests.swift in Sources */, @@ -10799,7 +10818,6 @@ 4BBEE8DE2BFEDE3E00E5E111 /* SurveyRemoteMessageTests.swift in Sources */, 562532A12BC069190034D316 /* ZoomPopoverViewModelTests.swift in Sources */, 3706FE28293F661700E42796 /* BookmarkTests.swift in Sources */, - BB1597C12C35B667001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */, 3706FE29293F661700E42796 /* SuggestionContainerViewModelTests.swift in Sources */, 37DB56F02C3B31CD0093D4DC /* MockRemoteMessagingAvailabilityProvider.swift in Sources */, 1D8C2FEB2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, @@ -10860,6 +10878,7 @@ 3706FE49293F661700E42796 /* BookmarkNodePathTests.swift in Sources */, 1DE03425298BC7F000CAB3D7 /* InternalUserDeciderStoreMock.swift in Sources */, 3706FE4A293F661700E42796 /* BookmarkManagedObjectTests.swift in Sources */, + BBFF355E2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */, EEC8EB402982CD550065AA39 /* JSAlertViewModelTests.swift in Sources */, BDA764922BC4E57200D0400C /* MockVPNLocationFormatter.swift in Sources */, 3706FE4B293F661700E42796 /* BookmarksHTMLImporterTests.swift in Sources */, @@ -11323,6 +11342,7 @@ AA80EC54256BE3BC007083E7 /* UserText.swift in Sources */, B61EF3EC266F91E700B4D78F /* WKWebView+Download.swift in Sources */, 311B262728E73E0A00FD181A /* TabShadowConfig.swift in Sources */, + BB7B5F982C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */, B6BCC54A2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */, B6DB3AEF278D5C370024C5C4 /* URLSessionExtension.swift in Sources */, 4B7A60A1273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift in Sources */, @@ -11414,7 +11434,6 @@ B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, B69B503B2726A12500758A2B /* Atb.swift in Sources */, 37A6A8F12AFCC988008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, - BB1597BB2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */, 7BEC20452B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift in Sources */, B6C0BB6A29AF1C7000AE8E3C /* BrowserTabView.swift in Sources */, B6B1E88026D5DA9B0062C350 /* DownloadsViewController.swift in Sources */, @@ -11653,6 +11672,7 @@ B6BCC54F2AFE4F7D002C5499 /* DataImportTypePicker.swift in Sources */, AAEEC6A927088ADB008445F7 /* FireCoordinator.swift in Sources */, B655369B268442EE00085A79 /* GeolocationProvider.swift in Sources */, + BBB881882C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift in Sources */, C1B1CBE12BE1915100B6049C /* DataImportShortcutsViewModel.swift in Sources */, B6C0B23C26E87D900031CB7F /* NSAlert+ActiveDownloadsTermination.swift in Sources */, AAECA42024EEA4AC00EFA63A /* IndexPathExtension.swift in Sources */, @@ -11722,6 +11742,7 @@ B6E6B9E32BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */, B6CC266C2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, 3158B14D2B0BF74D00AF130C /* DataBrokerProtectionManager.swift in Sources */, + BBFB727F2C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, 4BA1A6A5258B07DF00F6F690 /* EncryptedValueTransformer.swift in Sources */, B634DBE1293C8FD500C3C99E /* Tab+Dialogs.swift in Sources */, 4B92929F26670D2A00AD2C21 /* PasteboardBookmark.swift in Sources */, @@ -12241,6 +12262,7 @@ 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, 9F872D9D2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */, + BBFF355D2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */, 9F3910622B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */, 4B9DB0562A983B55000927DB /* MockNotificationService.swift in Sources */, 4B02199C25E063DE00ED7DEA /* FireproofDomainsTests.swift in Sources */, @@ -12250,6 +12272,7 @@ F1B8EC7A2C29957A00D395F5 /* SubscriptionFeatureAvailabilityMock.swift in Sources */, 9F872DA02B90644800138637 /* ContextualMenuTests.swift in Sources */, 4B9292BE2667103100AD2C21 /* PasteboardFolderTests.swift in Sources */, + BBBEE1BF2C4FF63600035ABA /* SortBookmarksViewModelTests.swift in Sources */, 4B9292C52667104B00AD2C21 /* CoreDataTestUtilities.swift in Sources */, 4B723E1926B000DC00E14D75 /* TemporaryFileCreator.swift in Sources */, 98EB5D1027516A4800681FE6 /* AppPrivacyConfigurationTests.swift in Sources */, @@ -12411,7 +12434,6 @@ 317295D22AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, B6C843DA2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */, B693956326F1C2A40015B914 /* FileDownloadManagerMock.swift in Sources */, - BB1597C02C35B666001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */, B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */, B63ED0D826AE729600A9DAD1 /* PermissionModelTests.swift in Sources */, B69B504B2726CA2900758A2B /* MockStatisticsStore.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Bookmarks-Search-Empty.pdf b/DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Bookmarks-Search-Empty.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0fe0ff0ef1c12fa715c927ce569e548c3a5bf72b GIT binary patch literal 3557 zcma)9X*`tc8$L>6VyrQU-a(cUV=$IPOib2@I%r{-p<$Sek+EkTV=K!ElXXNmgc6a| zAlV8-j8qa4LMThB^Pg!s=XCyGx<9<{^IpIEzMt#5pZCM{?6Ey;wqHX{2L#dpU;x3* z3j_cL27tOLnS`<@p-I?pU)o<^?q~p}28VG1Ff?Rm{$Hq@YW;F1{FgH|e>qd*S27I^ zIqb2%7>-;J{3qRJI6MhU1k}y&Z0(1!?gR|>n@)8rtgk2OETG9wKgNdG9sEf|EZPS| z=9{Mo*5vfs+E%}UeU^E9N4g7(`DeF- zryoatS$&xQ<#X@DqYD6|}OVaELF-Z3$C(S&jx z{Usj1Rd7aGh{&gKe~SSt+<7{SU$A|6AFFJG3r}23>9Q5k6yfK4p1Lu`&)nwSr+={n zuXOMT?EMGOl$ur4lcWSmdZZNt5#!=~?Lj8bfPMAKJ0WEeWE3NMUT2#qyOmJIit95$ z2Fs6h=^U6EQ&L1RcAYIre=_v+?5rX@*bK(U!xJ5+g+)H+(KeRsUte9a5_$r`s~@DSunGc_V?z6n5INop4TV zHErvk!h4SF*m%eFLc7X*33oo>l{wM|Njp;v2*;;+TngAQM6`%7OFgyBs;?V4U874n zjH=;Y^<(uUmVv|^nNtw2?p#VRW(ILV0TQ=;^r-AB$L)gcnFf=CpOQl|6rFdn35mF! zYp#iWXBjn_4i_sCUod@4*X3~&y`xw41IOKwbui15qe??D}> z8S7F}0kTP(P{@H4a8FWUIn){@7=1{j(pWq!Ik^MFv_@;G2%E!Tbjndr%ICAdCHnru zt2micj`~}=VkuCF8~-i#c_{5RhAg>;cA_xcs@~58k>im_n8L29wxbl+H{&&*f_USa z&oCxEWYS%Qw71iuUdsrp=wBR-vu4CyIZlN#UNTmt*)&#p!7lNx-H_%4YVWpp{-BL{ z-i0XWxUR77Y>6vz-H&)q0nF&j+~TNo{GkG5!L?oEUw;Qoj%Uj~8OE}(Bv-f8+JLQ_ z8M_pw6t2&W6tv)hANXRhEqxKJGnd6JZ{!Qs+NRmYz4_NJh8-6k>Xonogi6oERu@a? z3+5-f1l|tEM=x8-hi9Mj=!^BqT|=6=uAnF0;xuiXX2cugF1LS z)`AJ6?InbYo|(8$rDN}LpY@ZJEZ$3PhB@D}Dvn&Xocs7Fm)TPHcoQmLJtHyJ6hj-h z^8n}F2xSiB_+|yKOlaUV$DdBwNmx{G~Ya(Dr2{Ce#jd94uXg#BeEIk#rs z3xwU!n!7rmAm9;p@d;{ieu}!;le&l){KH#O(O~OFzmH>c$vCAKp!OePz5k**zwl~2 z4EKUs^kTurs(k<1#E8}mY5zw#BRft?WxGo|6;(^-FVh4CymnlbJ6D*~^(mLv)c!6{ za#s{=!P#3Gm+qkbMpdigLtS^AHxe>{7W}PIMG@6chbAdq2gYQ1vjxl_l0;e?UbsET3uhj1|!x6yEUIo`-C!-a zp0>M$Zh;2J)4<>^{~Y_bxRc2=sfb_{1D zu<%AAw=MHeitt9?q=HglX}O^jjA!hC=lFq@OByi|X^JOHiD_mtt&(<=Jdf-vCxrG+ zW$}h3EzZp>g|{a0+qeNbH(v;OM8+6TfS9#tt4FblDZ%OM{+Ep>wb!`IOZAXyKKnW^ zTjV_yr|y1^S#G*RRWh(mx42@CElxA_K(jJRLoT}6$MhZ?Q6bD(1`T znYi-jaiHPIgb8~U`&~uIqUQU_=Dl`H-1?BD&ft)ZB#6Ou`Tey70gLVM{g<5Rt1 zR6WLP?y6^}w+<;v_#b_laF{h~XnBISX~(+!AN+bGT5k8}J)UyeF`Y(R0xCo+yN(wP zz&}9xn7&} zO{et4^#%Aij3nxvnh$!* zOd-7iIXxHZ53U;i>p?-2Z?Wc~`C)-Bbde5~6aghmYlzk;bJ-mO&P%Z5qgd@h61oHY zZ!aYu-D25WC*c4d=7pNqM@u4$E}JI5@AqnV8oJKY$&!fR>3VALJaq{nRLE=b_C_5G z>W+w;rf?IV)W)@4>JboJd>WlDXmozzZWQafv?|);S);}_LFk4Wm zWyH~fo|pp#AhpRMyr1ByJU!|H>Hf%$Qh`k=Wuw_-DpgJ` zRqGw;Y>k-kzNrUD9V_+7oVmRsY8fc(d2^dH+jAXx56YY$ix3pttB~4UH4i8k49w7NJUMY=%024c0 zm6e;d%Wb(l8spXkCC~E6@Cb}-xJcLat9}kd_rdQKHgH}LNK#F z4vt8?a?7XwrWBC5C|(8?Dzk5`gtZnR0F-9?A?O?yP`5>U{&2R| zSPTwrLLdVt+4lacyNCU=&_n!ybl^W$KRO-5`V#_(?pS|-b9vynVs@w_3BDw(FMCIh zv;G0vqKRw_(Ebkn%YSpAf9@TYI1HOAz)^4v_^}U?5Wue0_&@&sY~){^5I=R|2=xDZ zLk_WT!jWha8c*>2xs*)AdVpYn4(OYF&J#drYinu)Z1{U8l(~O-*gko;{{V&He`kDC#c& literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Contents.json new file mode 100644 index 0000000000..af6adc382a --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarkEmptySearch.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Bookmarks-Search-Empty.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/Contents.json new file mode 100644 index 0000000000..a5e151c20f --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sort-asc.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/sort-asc.svg b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/sort-asc.svg new file mode 100644 index 0000000000..89b922f2a2 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortAsc.imageset/sort-asc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/Contents.json new file mode 100644 index 0000000000..a338c75ce9 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sort-desc.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/sort-desc.svg b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/sort-desc.svg new file mode 100644 index 0000000000..0c40784402 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarkSortDesc.imageset/sort-desc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/Contents.json new file mode 100644 index 0000000000..a9424ef2ed --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "search_bookmarks.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/search_bookmarks.svg b/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/search_bookmarks.svg new file mode 100644 index 0000000000..4cc9e0fedd --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Search-Bookmarks.imageset/search_bookmarks.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/DuckDuckGo/Bookmarks/Model/Bookmark.swift b/DuckDuckGo/Bookmarks/Model/Bookmark.swift index 4d3cf399d8..f4cd289182 100644 --- a/DuckDuckGo/Bookmarks/Model/Bookmark.swift +++ b/DuckDuckGo/Bookmarks/Model/Bookmark.swift @@ -275,3 +275,16 @@ final class Bookmark: BaseBookmarkEntity { } } + +extension Array where Element == BaseBookmarkEntity { + func sorted(by sortMode: BookmarksSortMode) -> [BaseBookmarkEntity] { + switch sortMode { + case .manual: + return self + case .nameAscending: + return self.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + case .nameDescending: + return self.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedDescending } + } + } +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerDataSource.swift index 13b1e163df..acef6ed832 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerDataSource.swift @@ -26,35 +26,37 @@ final class BookmarkListTreeControllerDataSource: BookmarkTreeControllerDataSour self.bookmarkManager = bookmarkManager } - func treeController(treeController: BookmarkTreeController, childNodesFor node: BookmarkNode) -> [BookmarkNode] { - return node.isRoot ? childNodesForRootNode(node) : childNodes(node) + func treeController(childNodesFor node: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] { + return node.isRoot ? childNodesForRootNode(node, for: sortMode) : childNodes(node, for: sortMode) } // MARK: - Private - private func childNodesForRootNode(_ node: BookmarkNode) -> [BookmarkNode] { - let topLevelNodes = bookmarkManager.list?.topLevelEntities.compactMap { (item) -> BookmarkNode? in - if let folder = item as? BookmarkFolder { - let itemNode = node.createChildNode(item) - itemNode.canHaveChildNodes = !folder.children.isEmpty - - return itemNode - } else if item is Bookmark { - let itemNode = node.findOrCreateChildNode(with: item) - itemNode.canHaveChildNodes = false - return itemNode - } else { - assertionFailure("\(#file): Tried to display non-bookmark type in bookmark list") - return nil - } - } ?? [] + private func childNodesForRootNode(_ node: BookmarkNode, for sortMode: BookmarksSortMode) -> [BookmarkNode] { + let topLevelNodes = bookmarkManager.list?.topLevelEntities + .sorted(by: sortMode) + .compactMap { (item) -> BookmarkNode? in + if let folder = item as? BookmarkFolder { + let itemNode = node.createChildNode(item) + itemNode.canHaveChildNodes = !folder.children.isEmpty + + return itemNode + } else if item is Bookmark { + let itemNode = node.findOrCreateChildNode(with: item) + itemNode.canHaveChildNodes = false + return itemNode + } else { + assertionFailure("\(#file): Tried to display non-bookmark type in bookmark list") + return nil + } + } ?? [] return topLevelNodes } - private func childNodes(_ node: BookmarkNode) -> [BookmarkNode] { + private func childNodes(_ node: BookmarkNode, for sortMode: BookmarksSortMode) -> [BookmarkNode] { if let folder = node.representedObject as? BookmarkFolder { - return childNodes(for: folder, parentNode: node) + return childNodes(for: folder, parentNode: node, sortMode: sortMode) } return [] @@ -72,20 +74,22 @@ final class BookmarkListTreeControllerDataSource: BookmarkTreeControllerDataSour return node } - private func childNodes(for folder: BookmarkFolder, parentNode: BookmarkNode) -> [BookmarkNode] { + private func childNodes(for folder: BookmarkFolder, parentNode: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] { var updatedChildNodes = [BookmarkNode]() - folder.children.forEach { representedObject in - if let existingNode = parentNode.childNodeRepresenting(object: representedObject) { - if !updatedChildNodes.contains(existingNode) { - updatedChildNodes += [existingNode] - return + folder.children + .sorted(by: sortMode) + .forEach { representedObject in + if let existingNode = parentNode.childNodeRepresenting(object: representedObject) { + if !updatedChildNodes.contains(existingNode) { + updatedChildNodes += [existingNode] + return + } } - } - let newNode = self.createNode(representedObject, parent: parentNode) - updatedChildNodes += [newNode] - } + let newNode = self.createNode(representedObject, parent: parentNode) + updatedChildNodes += [newNode] + } return updatedChildNodes } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift new file mode 100644 index 0000000000..c7f9afbcd1 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift @@ -0,0 +1,44 @@ +// +// BookmarkListTreeControllerSearchDataSource.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 + +final class BookmarkListTreeControllerSearchDataSource: BookmarkTreeControllerSearchDataSource { + private let bookmarkManager: BookmarkManager + + init(bookmarkManager: BookmarkManager) { + self.bookmarkManager = bookmarkManager + } + + func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { + let searchResults = bookmarkManager.search(by: searchQuery) + + return rebuildChildNodes(for: searchResults.sorted(by: sortMode)) + } + + private func rebuildChildNodes(for results: [BaseBookmarkEntity]) -> [BookmarkNode] { + let rootNode = BookmarkNode.genericRootNode() + let nodes = results.compactMap { (item) -> BookmarkNode in + let itemNode = rootNode.createChildNode(item) + itemNode.canHaveChildNodes = false + return itemNode + } + + return nodes + } +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index cbbff0ca40..1b2c3a0d05 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -413,7 +413,7 @@ final class LocalBookmarkManager: BookmarkManager { while !queue.isEmpty { let current = queue.removeFirst() - if current.title.lowercased().contains(query) { + if current.title.lowercased().contains(query.lowercased()) { result.append(current) } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift index 4f6b58c10f..8ef9a2ac86 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift @@ -106,6 +106,17 @@ final class BookmarkNode: Hashable { return false } + /// Checks if two nodes represent the same base bookmark entity based only on their ID + func representedObjectHasSameId(_ otherRepresentedObject: AnyObject) -> Bool { + if let entity = otherRepresentedObject as? BaseBookmarkEntity, + let nodeEntity = self.representedObject as? BaseBookmarkEntity, + entity.id == nodeEntity.id { + return true + } + + return false + } + func findOrCreateChildNode(with representedObject: AnyObject) -> BookmarkNode { if let node = childNodeRepresenting(object: representedObject) { return node diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift index 93a404fa95..20adffa41e 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift @@ -30,9 +30,15 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS @Published var selectedFolders: [BookmarkFolder] = [] let treeController: BookmarkTreeController - private(set) var expandedNodesIDs = Set() private let contentMode: ContentMode + private(set) var expandedNodesIDs = Set() + private(set) var isSearching = false + + /// When a drag and drop to a folder happens while in search, we need to stor the destination folder + /// so we can expand the tree to the destination folder once the drop finishes. + private(set) var dragDestinationFolderInSearchMode: BookmarkFolder? + private let bookmarkManager: BookmarkManager private let showMenuButtonOnHover: Bool private let onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? @@ -45,6 +51,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS contentMode: ContentMode, bookmarkManager: BookmarkManager, treeController: BookmarkTreeController, + sortMode: BookmarksSortMode, showMenuButtonOnHover: Bool = true, onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? = nil, presentFaviconsFetcherOnboarding: (() -> Void)? = nil @@ -58,13 +65,25 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS super.init() - reloadData() + reloadData(with: sortMode) + } + + func reloadData(with sortMode: BookmarksSortMode) { + isSearching = false + dragDestinationFolderInSearchMode = nil + setFolderCount() + treeController.rebuild(for: sortMode) + } + + func reloadData(for searchQuery: String, and sortMode: BookmarksSortMode) { + isSearching = true + setFolderCount() + treeController.rebuild(for: searchQuery, sortMode: sortMode) } - func reloadData() { + private func setFolderCount() { favoritesPseudoFolder.count = bookmarkManager.list?.favoriteBookmarks.count ?? 0 bookmarksPseudoFolder.count = bookmarkManager.list?.totalBookmarks ?? 0 - treeController.rebuild() } // MARK: - Private @@ -133,7 +152,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS cell.delegate = self if let bookmark = node.representedObject as? Bookmark { - cell.update(from: bookmark) + cell.update(from: bookmark, isSearch: isSearching) if bookmark.favicon(.small) == nil { presentFaviconsFetcherOnboarding?() @@ -142,7 +161,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } if let folder = node.representedObject as? BookmarkFolder { - cell.update(from: folder) + cell.update(from: folder, isSearch: isSearching) return cell } @@ -181,6 +200,15 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS return .none } + if isSearching { + if let destinationFolder = destinationNode.representedObject as? BookmarkFolder { + self.dragDestinationFolderInSearchMode = destinationFolder + return .move + } + + return .none + } + let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: info.draggingPasteboard) let folders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard) @@ -243,7 +271,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS let containsDescendantOfDestination = draggedFolders.contains { draggedFolder in let folder = BookmarkFolder(id: draggedFolder.id, title: draggedFolder.name, parentFolderUUID: draggedFolder.parentFolderUUID, children: draggedFolder.children) - guard let draggedNode = treeController.node(representing: folder) else { + guard let draggedNode = treeController.findNodeWithId(representing: folder) else { return false } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift index b8549cad89..7c168ff5f9 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift @@ -20,7 +20,7 @@ import Foundation final class BookmarkSidebarTreeController: BookmarkTreeControllerDataSource { - func treeController(treeController: BookmarkTreeController, childNodesFor node: BookmarkNode) -> [BookmarkNode] { + func treeController(childNodesFor node: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] { return node.isRoot ? childNodesForRootNode(node) : childNodes(for: node) } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift index 5f8fc62a36..ecf145ba66 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift @@ -20,8 +20,12 @@ import Foundation protocol BookmarkTreeControllerDataSource: AnyObject { - func treeController(treeController: BookmarkTreeController, childNodesFor: BookmarkNode) -> [BookmarkNode] + func treeController(childNodesFor: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] +} + +protocol BookmarkTreeControllerSearchDataSource: AnyObject { + func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] } final class BookmarkTreeController { @@ -29,22 +33,33 @@ final class BookmarkTreeController { let rootNode: BookmarkNode private weak var dataSource: BookmarkTreeControllerDataSource? + private weak var searchDataSource: BookmarkTreeControllerSearchDataSource? - init(dataSource: BookmarkTreeControllerDataSource, rootNode: BookmarkNode) { + init(dataSource: BookmarkTreeControllerDataSource, + sortMode: BookmarksSortMode, + searchDataSource: BookmarkTreeControllerSearchDataSource? = nil, + rootNode: BookmarkNode) { self.dataSource = dataSource + self.searchDataSource = searchDataSource self.rootNode = rootNode - rebuild() + rebuild(for: sortMode) } - convenience init(dataSource: BookmarkTreeControllerDataSource) { - self.init(dataSource: dataSource, rootNode: BookmarkNode.genericRootNode()) + convenience init(dataSource: BookmarkTreeControllerDataSource, + sortMode: BookmarksSortMode, + searchDataSource: BookmarkTreeControllerSearchDataSource? = nil) { + self.init(dataSource: dataSource, sortMode: sortMode, searchDataSource: searchDataSource, rootNode: BookmarkNode.genericRootNode()) } // MARK: - Public - func rebuild() { - rebuildChildNodes(node: rootNode) + func rebuild(for searchQuery: String, sortMode: BookmarksSortMode) { + rootNode.childNodes = searchDataSource?.nodes(for: searchQuery, sortMode: sortMode) ?? [] + } + + func rebuild(for sortMode: BookmarksSortMode) { + rebuildChildNodes(node: rootNode, sortMode: sortMode) } func visitNodes(with visitBlock: (BookmarkNode) -> Void) { @@ -52,21 +67,27 @@ final class BookmarkTreeController { } func node(representing object: AnyObject) -> BookmarkNode? { - return nodeInArrayRepresentingObject(nodes: [rootNode], representedObject: object) + return nodeInArrayRepresentingObject(nodes: [rootNode]) { $0.representedObjectEquals(object) } + } + + func findNodeWithId(representing object: AnyObject) -> BookmarkNode? { + return nodeInArrayRepresentingObject(nodes: [rootNode]) { $0.representedObjectHasSameId(object) } } // MARK: - Private - private func nodeInArrayRepresentingObject(nodes: [BookmarkNode], representedObject: AnyObject) -> BookmarkNode? { - for node in nodes { - if node.representedObjectEquals(representedObject) { + private func nodeInArrayRepresentingObject(nodes: [BookmarkNode], match: (BookmarkNode) -> Bool) -> BookmarkNode? { + var stack: [BookmarkNode] = nodes + + while !stack.isEmpty { + let node = stack.removeLast() + + if match(node) { return node } if node.canHaveChildNodes { - if let foundNode = nodeInArrayRepresentingObject(nodes: node.childNodes, representedObject: representedObject) { - return foundNode - } + stack.append(contentsOf: node.childNodes) } } @@ -79,12 +100,12 @@ final class BookmarkTreeController { } @discardableResult - private func rebuildChildNodes(node: BookmarkNode) -> Bool { + private func rebuildChildNodes(node: BookmarkNode, sortMode: BookmarksSortMode = .manual) -> Bool { guard node.canHaveChildNodes else { return false } - let childNodes: [BookmarkNode] = dataSource?.treeController(treeController: self, childNodesFor: node) ?? [] + let childNodes: [BookmarkNode] = dataSource?.treeController(childNodesFor: node, sortMode: sortMode) ?? [] var childNodesDidChange = childNodes != node.childNodes if childNodesDidChange { @@ -92,12 +113,11 @@ final class BookmarkTreeController { } childNodes.forEach { childNode in - if rebuildChildNodes(node: childNode) { + if rebuildChildNodes(node: childNode, sortMode: sortMode) { childNodesDidChange = true } } return childNodesDidChange } - } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarksSearchAndSortMetrics.swift b/DuckDuckGo/Bookmarks/Model/BookmarksSearchAndSortMetrics.swift new file mode 100644 index 0000000000..31d8fc3b6e --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/BookmarksSearchAndSortMetrics.swift @@ -0,0 +1,47 @@ +// +// BookmarksSearchAndSortMetrics.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 +import PixelKit + +enum BookmarkOperationOrigin: String { + case panel + case manager +} + +struct BookmarksSearchAndSortMetrics { + func fireSortButtonClicked(origin: BookmarkOperationOrigin) { + PixelKit.fire(GeneralPixel.bookmarksSortButtonClicked(origin: origin.rawValue)) + } + + func fireSortButtonDismissed(origin: BookmarkOperationOrigin) { + PixelKit.fire(GeneralPixel.bookmarksSortButtonDismissed(origin: origin.rawValue)) + } + + func fireSortByName(origin: BookmarkOperationOrigin) { + PixelKit.fire(GeneralPixel.bookmarksSortByName(origin: origin.rawValue)) + } + + func fireSearchExecuted(origin: BookmarkOperationOrigin) { + PixelKit.fire(GeneralPixel.bookmarksSearchExecuted(origin: origin.rawValue), frequency: .daily) + } + + func fireSearchResultClicked(origin: BookmarkOperationOrigin) { + PixelKit.fire(GeneralPixel.bookmarksSearchResultClicked(origin: origin.rawValue)) + } +} diff --git a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift b/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift index eb77fd956f..4ac1d9b902 100644 --- a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift +++ b/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift @@ -20,16 +20,17 @@ import AppKit enum ContextualMenu { - static func menu(for objects: [Any]?) -> NSMenu? { - menu(for: objects, target: nil) + static func menu(for objects: [Any]?, forSearch: Bool = false) -> NSMenu? { + menu(for: objects, target: nil, forSearch: forSearch) } /// Creates an instance of NSMenu for the specified Objects and target. /// - Parameters: /// - objects: The objects to create the menu for. /// - target: The target to associate to the `NSMenuItem` + /// - forSearch: Boolean that indicates if a bookmark search is currently happening. /// - Returns: An instance of NSMenu or nil if `objects` is not a `Bookmark` or a `Folder`. - static func menu(for objects: [Any]?, target: AnyObject?) -> NSMenu? { + static func menu(for objects: [Any]?, target: AnyObject?, forSearch: Bool = false) -> NSMenu? { guard let objects = objects, objects.count > 0 else { return menuForNoSelection() @@ -45,7 +46,7 @@ enum ContextualMenu { guard let object else { return nil } - let menu = menu(for: object, parentFolder: parentFolder) + let menu = menu(for: object, parentFolder: parentFolder, forSearch: forSearch) menu?.items.forEach { item in item.target = target @@ -59,14 +60,15 @@ enum ContextualMenu { /// - Parameters: /// - entity: The bookmark entity to create the menu for. /// - parentFolder: An optional `BookmarkFolder`. + /// - forSearch: Boolean that indicates if a bookmark search is currently happening. /// - Returns: An instance of NSMenu or nil if `entity` is not a `Bookmark` or a `Folder`. - static func menu(for entity: BaseBookmarkEntity, parentFolder: BookmarkFolder?) -> NSMenu? { + static func menu(for entity: BaseBookmarkEntity, parentFolder: BookmarkFolder?, forSearch: Bool = false) -> NSMenu? { let menu: NSMenu? if let bookmark = entity as? Bookmark { - menu = self.menu(for: bookmark, parent: parentFolder, isFavorite: bookmark.isFavorite) + menu = self.menu(for: bookmark, parent: parentFolder, isFavorite: bookmark.isFavorite, forSearch: forSearch) } else if let folder = entity as? BookmarkFolder { // When the user edits a folder we need to show the parent in the folder picker. Folders directly child of PseudoFolder `Bookmarks` have nil parent because their parent is not an instance of `BookmarkFolder` - menu = self.menu(for: folder, parent: parentFolder) + menu = self.menu(for: folder, parent: parentFolder, forSearch: forSearch) } else { menu = nil } @@ -101,16 +103,16 @@ private extension ContextualMenu { NSMenu(items: [addFolderMenuItem(folder: nil)]) } - static func menu(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool) -> NSMenu { - NSMenu(items: menuItems(for: bookmark, parent: parent, isFavorite: isFavorite)) + static func menu(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool, forSearch: Bool = false) -> NSMenu { + NSMenu(items: menuItems(for: bookmark, parent: parent, isFavorite: isFavorite, forSearch: forSearch)) } - static func menu(for folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenu { - NSMenu(items: menuItems(for: folder, parent: parent)) + static func menu(for folder: BookmarkFolder?, parent: BookmarkFolder?, forSearch: Bool) -> NSMenu { + NSMenu(items: menuItems(for: folder, parent: parent, forSearch: forSearch)) } - static func menuItems(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool) -> [NSMenuItem] { - [ + static func menuItems(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool, forSearch: Bool = false) -> [NSMenuItem] { + var items = [ openBookmarkInNewTabMenuItem(bookmark: bookmark), openBookmarkInNewWindowMenuItem(bookmark: bookmark), NSMenuItem.separator(), @@ -124,10 +126,17 @@ private extension ContextualMenu { addFolderMenuItem(folder: parent), manageBookmarksMenuItem(), ] + + if forSearch { + let showInFolderItem = showInFolderMenuItem(bookmark: bookmark, parent: parent) + items.insert(showInFolderItem, at: 5) + } + + return items } - static func menuItems(for folder: BookmarkFolder?, parent: BookmarkFolder?) -> [NSMenuItem] { - [ + static func menuItems(for folder: BookmarkFolder?, parent: BookmarkFolder?, forSearch: Bool = false) -> [NSMenuItem] { + var items = [ openInNewTabsMenuItem(folder: folder), openAllInNewWindowMenuItem(folder: folder), NSMenuItem.separator(), @@ -138,6 +147,13 @@ private extension ContextualMenu { addFolderMenuItem(folder: folder), manageBookmarksMenuItem(), ] + + if forSearch { + let showInFolderItem = showInFolderMenuItem(folder: folder, parent: parent) + items.insert(showInFolderItem, at: 3) + } + + return items } static func menuItem(_ title: String, _ action: Selector, _ representedObject: Any? = nil) -> NSMenuItem { @@ -192,6 +208,10 @@ private extension ContextualMenu { return menuItem(UserText.bookmarksBarContextMenuMoveToEnd, #selector(BookmarkMenuItemSelectors.moveToEnd(_:)), bookmarkEntityInfo) } + static func showInFolderMenuItem(bookmark: Bookmark?, parent: BookmarkFolder?) -> NSMenuItem { + return menuItem(UserText.showInFolder, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), bookmark) + } + // MARK: - Bookmark Folder Menu Items static func openInNewTabsMenuItem(folder: BookmarkFolder?) -> NSMenuItem { @@ -206,6 +226,10 @@ private extension ContextualMenu { menuItem(UserText.addFolder, #selector(FolderMenuItemSelectors.newFolder(_:)), folder) } + static func showInFolderMenuItem(folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenuItem { + return menuItem(UserText.showInFolder, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), folder) + } + static func editFolderMenuItem(folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenuItem { let folderEntityInfo = folder.flatMap { BookmarkEntityInfo(entity: $0, parent: parent) } return menuItem(UserText.editBookmark, #selector(FolderMenuItemSelectors.editFolder(_:)), folderEntityInfo) diff --git a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift index 393b22de8e..9f54209b45 100644 --- a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift +++ b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift @@ -44,3 +44,15 @@ import AppKit func openAllInNewWindow(_ sender: NSMenuItem) } + +@objc protocol BookmarkSearchMenuItemSelectors { + + func showInFolder(_ sender: NSMenuItem) +} + +@objc protocol BookmarkSortMenuItemSelectors { + + func manualSort(_ sender: NSMenuItem) + func sortByNameAscending(_ sender: NSMenuItem) + func sortByNameDescending(_ sender: NSMenuItem) +} diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift index 44221d76e8..4d6d3f480a 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -26,8 +26,40 @@ protocol BookmarkListViewControllerDelegate: AnyObject { } -final class BookmarkListViewController: NSViewController { +private enum EmptyStateContent { + case noBookmarks + case noSearchResults + + var title: String { + switch self { + case .noBookmarks: return UserText.bookmarksEmptyStateTitle + case .noSearchResults: return UserText.bookmarksEmptySearchResultStateTitle + } + } + + var description: String { + switch self { + case .noBookmarks: return UserText.bookmarksEmptyStateMessage + case .noSearchResults: return UserText.bookmarksEmptySearchResultStateMessage + } + } + var image: NSImage { + switch self { + case .noBookmarks: return .bookmarksEmpty + case .noSearchResults: return .bookmarkEmptySearch + } + } + + var shouldHideImportButton: Bool { + switch self { + case .noBookmarks: return false + case .noSearchResults: return true + } + } +} + +final class BookmarkListViewController: NSViewController { static let preferredContentSize = CGSize(width: 420, height: 500) weak var delegate: BookmarkListViewControllerDelegate? @@ -38,6 +70,9 @@ final class BookmarkListViewController: NSViewController { private lazy var stackView = NSStackView() private lazy var newBookmarkButton = MouseOverButton(image: .addBookmark, target: self, action: #selector(newBookmarkButtonClicked)) private lazy var newFolderButton = MouseOverButton(image: .addFolder, target: self, action: #selector(newFolderButtonClicked)) + private lazy var searchBookmarksButton = MouseOverButton(image: .searchBookmarks, target: self, action: #selector(searchBookmarkButtonClicked)) + private lazy var sortBookmarksButton = MouseOverButton(image: .bookmarkSortAsc, target: self, action: #selector(sortBookmarksButtonClicked)) + private var isSearchVisible = false private lazy var buttonsDivider = NSBox() private lazy var manageBookmarksButton = MouseOverButton(title: UserText.bookmarksManage, target: self, action: #selector(openManagementInterface)) @@ -51,18 +86,26 @@ final class BookmarkListViewController: NSViewController { private lazy var emptyStateMessage = NSTextField() private lazy var emptyStateImageView = NSImageView(image: .bookmarksEmpty) private lazy var importButton = NSButton(title: UserText.importBookmarksButtonTitle, target: self, action: #selector(onImportClicked)) + private lazy var searchBar = NSSearchField() + private var boxDividerTopConstraint = NSLayoutConstraint() private var cancellables = Set() private let bookmarkManager: BookmarkManager private let treeControllerDataSource: BookmarkListTreeControllerDataSource + private let treeControllerSearchDataSource: BookmarkListTreeControllerSearchDataSource + private let sortBookmarksViewModel: SortBookmarksViewModel + private let bookmarkMetrics: BookmarksSearchAndSortMetrics - private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource) + private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource, + sortMode: sortBookmarksViewModel.selectedSortMode, + searchDataSource: treeControllerSearchDataSource) private lazy var dataSource: BookmarkOutlineViewDataSource = { BookmarkOutlineViewDataSource( contentMode: .bookmarksAndFolders, bookmarkManager: bookmarkManager, treeController: treeController, + sortMode: sortBookmarksViewModel.selectedSortMode, onMenuRequestedAction: { [weak self] cell in self?.showContextMenu(for: cell) }, @@ -90,9 +133,13 @@ final class BookmarkListViewController: NSViewController { return .init(syncService: syncService, syncBookmarksAdapter: syncBookmarksAdapter) }() - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + metrics: BookmarksSearchAndSortMetrics = BookmarksSearchAndSortMetrics()) { self.bookmarkManager = bookmarkManager self.treeControllerDataSource = BookmarkListTreeControllerDataSource(bookmarkManager: bookmarkManager) + self.treeControllerSearchDataSource = BookmarkListTreeControllerSearchDataSource(bookmarkManager: bookmarkManager) + self.bookmarkMetrics = metrics + self.sortBookmarksViewModel = SortBookmarksViewModel(metrics: metrics, origin: .panel) super.init(nibName: nil, bundle: nil) } @@ -129,6 +176,8 @@ final class BookmarkListViewController: NSViewController { stackView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(newBookmarkButton) stackView.addArrangedSubview(newFolderButton) + stackView.addArrangedSubview(sortBookmarksButton) + stackView.addArrangedSubview(searchBookmarksButton) stackView.addArrangedSubview(buttonsDivider) stackView.addArrangedSubview(manageBookmarksButton) @@ -148,6 +197,25 @@ final class BookmarkListViewController: NSViewController { newFolderButton.translatesAutoresizingMaskIntoConstraints = false newFolderButton.toolTip = UserText.newFolderTooltip + searchBookmarksButton.bezelStyle = .shadowlessSquare + searchBookmarksButton.cornerRadius = 4 + searchBookmarksButton.normalTintColor = .button + searchBookmarksButton.mouseDownColor = .buttonMouseDown + searchBookmarksButton.mouseOverColor = .buttonMouseOver + searchBookmarksButton.translatesAutoresizingMaskIntoConstraints = false + searchBookmarksButton.toolTip = UserText.bookmarksSearch + + sortBookmarksButton.bezelStyle = .shadowlessSquare + sortBookmarksButton.cornerRadius = 4 + sortBookmarksButton.normalTintColor = .button + sortBookmarksButton.mouseDownColor = .buttonMouseDown + sortBookmarksButton.mouseOverColor = .buttonMouseOver + sortBookmarksButton.translatesAutoresizingMaskIntoConstraints = false + sortBookmarksButton.toolTip = UserText.bookmarksSort + + searchBar.translatesAutoresizingMaskIntoConstraints = false + searchBar.delegate = self + buttonsDivider.boxType = .separator buttonsDivider.setContentHuggingPriority(.defaultHigh, for: .horizontal) buttonsDivider.translatesAutoresizingMaskIntoConstraints = false @@ -237,6 +305,7 @@ final class BookmarkListViewController: NSViewController { kern: -0.08) importButton.translatesAutoresizingMaskIntoConstraints = false + importButton.isHidden = true setupLayout() } @@ -245,7 +314,7 @@ final class BookmarkListViewController: NSViewController { titleTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) titleTextField.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: 12).isActive = true - titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 15).isActive = true + titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true newBookmarkButton.heightAnchor.constraint(equalToConstant: 28).isActive = true newBookmarkButton.widthAnchor.constraint(equalToConstant: 28).isActive = true @@ -253,6 +322,12 @@ final class BookmarkListViewController: NSViewController { newFolderButton.heightAnchor.constraint(equalToConstant: 28).isActive = true newFolderButton.widthAnchor.constraint(equalToConstant: 28).isActive = true + searchBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true + searchBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true + + sortBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true + sortBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true + buttonsDivider.widthAnchor.constraint(equalToConstant: 13).isActive = true buttonsDivider.heightAnchor.constraint(equalToConstant: 18).isActive = true @@ -265,7 +340,8 @@ final class BookmarkListViewController: NSViewController { stackView.centerYAnchor.constraint(equalTo: titleTextField.centerYAnchor).isActive = true view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 20).isActive = true - boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12).isActive = true + boxDividerTopConstraint = boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12) + boxDividerTopConstraint.isActive = true boxDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true view.trailingAnchor.constraint(equalTo: boxDivider.trailingAnchor).isActive = true @@ -314,10 +390,31 @@ final class BookmarkListViewController: NSViewController { FolderPasteboardWriter.folderUTIInternalType]) bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] list in - self?.reloadData() + guard let self else { return } + + self.reloadData() let isEmpty = list?.topLevelEntities.isEmpty ?? true - self?.emptyState.isHidden = !isEmpty - self?.outlineView.isHidden = isEmpty + + if isEmpty { + self.searchBookmarksButton.isHidden = true + self.showEmptyStateView(for: .noBookmarks) + } else { + self.searchBookmarksButton.isHidden = false + self.outlineView.isHidden = false + } + }.store(in: &cancellables) + + sortBookmarksViewModel.$selectedSortMode.sink { [weak self] newSortMode in + guard let self else { return } + + switch newSortMode { + case .nameDescending: + self.sortBookmarksButton.image = .bookmarkSortDesc + default: + self.sortBookmarksButton.image = .bookmarkSortAsc + } + + self.setupSort(mode: newSortMode) }.store(in: &cancellables) } @@ -327,13 +424,48 @@ final class BookmarkListViewController: NSViewController { reloadData() } + override func keyDown(with event: NSEvent) { + let commandKeyDown = event.modifierFlags.contains(.command) + if commandKeyDown && event.keyCode == 3 { // CMD + F + if isSearchVisible { + searchBar.makeMeFirstResponder() + } else { + showSearchBar() + } + } else { + super.keyDown(with: event) + } + } + private func reloadData() { - let selectedNodes = self.selectedNodes + if dataSource.isSearching { + if let destinationFolder = dataSource.dragDestinationFolderInSearchMode { + hideSearchBar() + updateSearchAndExpand(destinationFolder) + } else { + dataSource.reloadData(for: searchBar.stringValue, and: sortBookmarksViewModel.selectedSortMode) + outlineView.reloadData() + } + } else { + let selectedNodes = self.selectedNodes - dataSource.reloadData() - outlineView.reloadData() + dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) + outlineView.reloadData() - expandAndRestore(selectedNodes: selectedNodes) + expandAndRestore(selectedNodes: selectedNodes) + } + } + + private func updateSearchAndExpand(_ folder: BookmarkFolder) { + showTreeView() + expandFoldersAndScrollUntil(folder) + outlineView.scrollToAdjustedPositionInOutlineView(folder) + + guard let node = dataSource.treeController.node(representing: folder) else { + return + } + + outlineView.highlight(node) } @objc func newBookmarkButtonClicked(_ sender: AnyObject) { @@ -347,6 +479,54 @@ final class BookmarkListViewController: NSViewController { showDialog(view: view) } + @objc func searchBookmarkButtonClicked(_ sender: NSButton) { + isSearchVisible.toggle() + + if isSearchVisible { + showSearchBar() + } else { + hideSearchBar() + showTreeView() + } + } + + @objc func sortBookmarksButtonClicked(_ sender: NSButton) { + let menu = sortBookmarksViewModel.selectedSortMode.menu + bookmarkMetrics.fireSortButtonClicked(origin: .panel) + menu.delegate = sortBookmarksViewModel + menu.popUpAtMouseLocation(in: sortBookmarksButton) + } + + private func showSearchBar() { + view.addSubview(searchBar) + + boxDividerTopConstraint.isActive = false + searchBar.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 8).isActive = true + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true + view.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: 16).isActive = true + boxDivider.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 10).isActive = true + searchBar.makeMeFirstResponder() + searchBookmarksButton.backgroundColor = .buttonMouseDown + searchBookmarksButton.mouseOverColor = .buttonMouseDown + } + + private func hideSearchBar() { + searchBar.stringValue = "" + searchBar.removeFromSuperview() + boxDividerTopConstraint.isActive = true + isSearchVisible = false + searchBookmarksButton.backgroundColor = .clear + searchBookmarksButton.mouseOverColor = .buttonMouseOver + } + + private func setupSort(mode: BookmarksSortMode) { + hideSearchBar() + dataSource.reloadData(with: mode) + outlineView.reloadData() + sortBookmarksButton.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear + sortBookmarksButton.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver + } + @objc func openManagementInterface(_ sender: NSButton) { showManageBookmarks() } @@ -357,17 +537,75 @@ final class BookmarkListViewController: NSViewController { let item = sender.item(atRow: sender.clickedRow) if let node = item as? BookmarkNode, let bookmark = node.representedObject as? Bookmark { - WindowControllersManager.shared.open(bookmark: bookmark) - delegate?.popoverShouldClose(self) + onBookmarkClick(bookmark) + } else if let node = item as? BookmarkNode, let folder = node.representedObject as? BookmarkFolder, dataSource.isSearching { + bookmarkMetrics.fireSearchResultClicked(origin: .panel) + hideSearchBar() + updateSearchAndExpand(folder) } else { - if outlineView.isItemExpanded(item) { - outlineView.animator().collapseItem(item) - } else { - outlineView.animator().expandItem(item) + handleItemClickWhenNotInSearchMode(item: item) + } + } + + private func onBookmarkClick(_ bookmark: Bookmark) { + if dataSource.isSearching { + bookmarkMetrics.fireSearchResultClicked(origin: .panel) + } + + WindowControllersManager.shared.open(bookmark: bookmark) + delegate?.popoverShouldClose(self) + } + + private func handleItemClickWhenNotInSearchMode(item: Any?) { + if outlineView.isItemExpanded(item) { + outlineView.animator().collapseItem(item) + } else { + outlineView.animator().expandItem(item) + } + } + + private func expandFoldersAndScrollUntil(_ folder: BookmarkFolder) { + guard let folderNode = treeController.findNodeWithId(representing: folder) else { + return + } + + expandFoldersUntil(node: folderNode) + outlineView.scrollToAdjustedPositionInOutlineView(folderNode) + } + + private func expandFoldersUntil(node: BookmarkNode?) { + var nodes: [BookmarkNode?] = [] + var parent = node?.parent + nodes.append(node) + + while parent != nil { + nodes.append(parent) + parent = parent?.parent + } + + while !nodes.isEmpty { + if let current = nodes.removeLast() { + outlineView.animator().expandItem(current) } } } + private func showTreeView() { + emptyState.isHidden = true + outlineView.isHidden = false + dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) + outlineView.reloadData() + } + + private func showEmptyStateView(for mode: EmptyStateContent) { + emptyState.isHidden = false + outlineView.isHidden = true + emptyStateTitle.stringValue = mode.title + emptyStateMessage.stringValue = mode.description + emptyStateImageView.image = mode.image + importButton.isHidden = mode.shouldHideImportButton + } + @objc func onImportClicked(_ sender: NSButton) { DataImportView().show() } @@ -427,7 +665,7 @@ final class BookmarkListViewController: NSViewController { let row = outlineView.row(for: cell) guard let item = outlineView.item(atRow: row), - let contextMenu = ContextualMenu.menu(for: [item], target: self) + let contextMenu = ContextualMenu.menu(for: [item], target: self, forSearch: dataSource.isSearching) else { return } @@ -470,7 +708,7 @@ extension BookmarkListViewController: NSMenuDelegate { } if let item = outlineView.item(atRow: row) { - return ContextualMenu.menu(for: [item]) + return ContextualMenu.menu(for: [item], forSearch: dataSource.isSearching) } else { return nil } @@ -631,6 +869,76 @@ extension BookmarkListViewController: FolderMenuItemSelectors { } +extension BookmarkListViewController: BookmarkSearchMenuItemSelectors { + func showInFolder(_ sender: NSMenuItem) { + guard let baseBookmark = sender.representedObject as? BaseBookmarkEntity else { + assertionFailure("Failed to retrieve Bookmark from Show in Folder context menu item") + return + } + + hideSearchBar() + showTreeView() + + guard let node = dataSource.treeController.node(representing: baseBookmark) else { + return + } + + expandFoldersUntil(node: node) + outlineView.scrollToAdjustedPositionInOutlineView(node) + outlineView.highlight(node) + } +} + +extension BookmarkListViewController: BookmarkSortMenuItemSelectors { + + func manualSort(_ sender: NSMenuItem) { + sortBookmarksViewModel.setSort(mode: .manual) + } + + func sortByNameAscending(_ sender: NSMenuItem) { + sortBookmarksViewModel.setSort(mode: .nameAscending) + } + + func sortByNameDescending(_ sender: NSMenuItem) { + sortBookmarksViewModel.setSort(mode: .nameDescending) + } +} + +// MARK: - Search field delegate + +extension BookmarkListViewController: NSSearchFieldDelegate { + + func controlTextDidChange(_ obj: Notification) { + if let searchField = obj.object as? NSSearchField { + let searchQuery = searchField.stringValue + + if searchQuery.isBlank { + showTreeView() + } else { + showSearch(for: searchQuery) + } + + bookmarkMetrics.fireSearchExecuted(origin: .panel) + } + } + + private func showSearch(for searchQuery: String) { + dataSource.reloadData(for: searchQuery, and: sortBookmarksViewModel.selectedSortMode) + + if dataSource.treeController.rootNode.childNodes.isEmpty { + showEmptyStateView(for: .noSearchResults) + } else { + emptyState.isHidden = true + outlineView.isHidden = false + outlineView.reloadData() + + if let firstNode = dataSource.treeController.rootNode.childNodes.first { + outlineView.scrollTo(firstNode) + } + } + } +} + // MARK: - BookmarkListPopover final class BookmarkListPopover: NSPopover { diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift index 6621046849..5b8b3e01d0 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift @@ -50,8 +50,12 @@ final class BookmarkManagementSidebarViewController: NSViewController { private lazy var scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 232, height: 410)) private lazy var outlineView = BookmarksOutlineView(frame: scrollView.frame) - private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource) - private lazy var dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController, showMenuButtonOnHover: false) + private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource, sortMode: .manual) + private lazy var dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, + bookmarkManager: bookmarkManager, + treeController: treeController, + sortMode: .manual, + showMenuButtonOnHover: false) private var cancellables = Set() @@ -184,7 +188,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { private func reloadData() { let selectedNodes = self.selectedNodes - dataSource.reloadData() + dataSource.reloadData(with: .manual) outlineView.reloadData() expandAndRestore(selectedNodes: selectedNodes) diff --git a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift index 813e1ff8ec..fb79524c09 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift @@ -33,6 +33,7 @@ final class BookmarkOutlineCellView: NSTableCellView { private lazy var trackingArea: NSTrackingArea = { NSTrackingArea(rect: .zero, options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], owner: self, userInfo: nil) }() + private var leadingConstraint = NSLayoutConstraint() var shouldShowMenuButton = false @@ -117,10 +118,12 @@ final class BookmarkOutlineCellView: NSTableCellView { } private func setupLayout() { + leadingConstraint = faviconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5) + NSLayoutConstraint.activate([ faviconImageView.heightAnchor.constraint(equalToConstant: 16), faviconImageView.widthAnchor.constraint(equalToConstant: 16), - faviconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), + leadingConstraint, faviconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 10), @@ -132,7 +135,7 @@ final class BookmarkOutlineCellView: NSTableCellView { trailingAnchor.constraint(equalTo: countLabel.trailingAnchor), menuButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), + menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), menuButton.trailingAnchor.constraint(equalTo: trailingAnchor), menuButton.topAnchor.constraint(equalTo: topAnchor), menuButton.bottomAnchor.constraint(equalTo: bottomAnchor), @@ -162,14 +165,15 @@ final class BookmarkOutlineCellView: NSTableCellView { // MARK: - Public - func update(from bookmark: Bookmark) { + func update(from bookmark: Bookmark, isSearch: Bool = false) { faviconImageView.image = bookmark.favicon(.small) ?? .bookmarkDefaultFavicon titleLabel.stringValue = bookmark.title countLabel.stringValue = "" favoriteImageView.image = bookmark.isFavorite ? .favoriteFilledBorder : nil + updateConstraints(isSearch: isSearch) } - func update(from folder: BookmarkFolder) { + func update(from folder: BookmarkFolder, isSearch: Bool = false) { faviconImageView.image = .folder titleLabel.stringValue = folder.title favoriteImageView.image = nil @@ -180,6 +184,12 @@ final class BookmarkOutlineCellView: NSTableCellView { } else { countLabel.stringValue = "" } + + updateConstraints(isSearch: isSearch) + } + + private func updateConstraints(isSearch: Bool) { + leadingConstraint.constant = isSearch ? -8 : 5 } func update(from pseudoFolder: PseudoFolder) { diff --git a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift index a79fb14f6f..77bc4f2f71 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift @@ -69,4 +69,35 @@ final class BookmarksOutlineView: NSOutlineView { lastRow = rowView } + func scrollTo(_ item: Any, code: ((Int) -> Void)? = nil) { + let rowIndex = row(forItem: item) + + if rowIndex != -1 { + scrollRowToVisible(rowIndex) + code?(rowIndex) + } + } + + /// Scrolls to the passed node and tries to position it in the second row. + func scrollToAdjustedPositionInOutlineView(_ item: Any) { + scrollTo(item) { rowIndex in + if let enclosingScrollView = self.enclosingScrollView { + let rowRect = self.rect(ofRow: rowIndex) + let desiredTopPosition = rowRect.origin.y - self.rowHeight // Adjusted position one row height from the top. + let scrollPoint = NSPoint(x: 0, y: desiredTopPosition - enclosingScrollView.contentInsets.top) + enclosingScrollView.contentView.scroll(to: scrollPoint) + } + } + } + + func highlight(_ item: Any) { + let row = row(forItem: item) + guard let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView else { return } + + rowView.highlight = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + rowView.highlight = false + } + } } diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift deleted file mode 100644 index 4856df7ea8..0000000000 --- a/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// BookmarkSearchViewModel.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 - -struct BookmarkSearchViewModel { - - enum BookmarkSearchResult: Equatable { - case emptyQuery - case results([BaseBookmarkEntity]) - - static let noResults = Self.results([]) - } - - let manager: BookmarkManager - - func search(by query: String) -> BookmarkSearchResult { - if query.isBlank { - return .emptyQuery - } - - let filteredBookmarks = manager.search(by: query) - - return filteredBookmarks.isEmpty ? .noResults : .results(filteredBookmarks) - } -} diff --git a/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift new file mode 100644 index 0000000000..c55d020b4a --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift @@ -0,0 +1,170 @@ +// +// SortBookmarksViewModel.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 +import AppKit +import Combine + +enum BookmarksSortMode: Codable { + case manual + case nameAscending + case nameDescending + + var title: String { + switch self { + case .manual: + return UserText.bookmarksSortManual + case .nameAscending: + return UserText.bookmarksSortByNameAscending + case .nameDescending: + return UserText.bookmarksSortByNameDescending + } + } + + var action: Selector { + switch self { + case .manual: + return #selector(BookmarkSortMenuItemSelectors.manualSort(_:)) + case .nameAscending: + return #selector(BookmarkSortMenuItemSelectors.sortByNameAscending(_:)) + case .nameDescending: + return #selector(BookmarkSortMenuItemSelectors.sortByNameDescending(_:)) + } + } + + var shouldHighlightButton: Bool { + return self != .manual + } + + var menu: NSMenu { + switch self { + case .manual: + return NSMenu(items: [ + menuItem(for: .manual, state: .on), + sortByName(state: .off), + NSMenuItem.separator(), + menuItem(for: .nameAscending, state: .off, disabled: true), + menuItem(for: .nameDescending, state: .off, disabled: true) + ]) + case .nameAscending: + return NSMenu(items: [ + menuItem(for: .manual, state: .off), + sortByName(state: .on), + NSMenuItem.separator(), + menuItem(for: .nameAscending, state: .on), + menuItem(for: .nameDescending, state: .off) + ]) + case .nameDescending: + return NSMenu(items: [ + menuItem(for: .manual, state: .off), + sortByName(state: .on), + NSMenuItem.separator(), + menuItem(for: .nameAscending, state: .off), + menuItem(for: .nameDescending, state: .on) + ]) + } + } + + var isNameSorting: Bool { + return self == .nameAscending || self == .nameDescending + } + + private func menuItem(for mode: BookmarksSortMode, state: NSControl.StateValue, disabled: Bool = false) -> NSMenuItem { + return NSMenuItem(title: mode.title, action: disabled ? nil : mode.action, state: state) + } + + private func sortByName(state: NSControl.StateValue) -> NSMenuItem { + return NSMenuItem(title: UserText.bookmarksSortByName, action: BookmarksSortMode.nameAscending.action, state: state) + } +} + +protocol SortBookmarksRepository { + + var storedSortMode: BookmarksSortMode { get set } +} + +final class SortBookmarksUserDefaults: SortBookmarksRepository { + + private enum Keys { + static let sortMode = "com.duckduckgo.bookmarks.sort.mode" + } + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + var storedSortMode: BookmarksSortMode { + get { + if let data = userDefaults.data(forKey: Keys.sortMode), + let mode = try? JSONDecoder().decode(BookmarksSortMode.self, from: data) { + return mode + } + // Default value if not set + return .manual + } + set { + if let data = try? JSONEncoder().encode(newValue) { + userDefaults.set(data, forKey: Keys.sortMode) + } + } + } +} + +final class SortBookmarksViewModel: NSObject { + + private let metrics: BookmarksSearchAndSortMetrics + private let origin: BookmarkOperationOrigin + private var repository: SortBookmarksRepository + + @Published + private(set) var selectedSortMode: BookmarksSortMode = .manual + private var wasSortOptionSelected = false + + init(repository: SortBookmarksRepository = SortBookmarksUserDefaults(), + metrics: BookmarksSearchAndSortMetrics, + origin: BookmarkOperationOrigin) { + self.metrics = metrics + self.origin = origin + self.repository = repository + + selectedSortMode = repository.storedSortMode + } + + func setSort(mode: BookmarksSortMode) { + wasSortOptionSelected = true + selectedSortMode = mode + repository.storedSortMode = selectedSortMode + + if mode.isNameSorting { + metrics.fireSortByName(origin: origin) + } + } +} + +extension SortBookmarksViewModel: NSMenuDelegate { + + func menuDidClose(_ menu: NSMenu) { + if !wasSortOptionSelected { + metrics.fireSortButtonDismissed(origin: origin) + } + + wasSortOptionSelected = false + } +} diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 09cda0ba18..6439c198e2 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -215,6 +215,7 @@ struct UserText { static let showFolderContents = NSLocalizedString("show.folder.contents", value: "Show Folder Contents", comment: "Menu item that shows the content of a folder ") static let editBookmark = NSLocalizedString("menu.bookmarks.edit", value: "Edit…", comment: "Menu item to edit a bookmark or a folder") static let addFolder = NSLocalizedString("menu.add.folder", value: "Add Folder…", comment: "Menu item to add a folder") + static let showInFolder = NSLocalizedString("menu.show.in.folder", value: "Show in Folder", comment: "Menu item to show where a bookmark is located") static let tabHomeTitle = NSLocalizedString("tab.home.title", value: "New Tab", comment: "Tab home title") static let tabUntitledTitle = NSLocalizedString("tab.empty.title", value: "Untitled", comment: "Title for an empty tab without a title") @@ -996,10 +997,19 @@ struct UserText { static let newFolderTooltip = NSLocalizedString("tooltip.bookmarks.new-folder", value: "New folder", comment: "Tooltip for the New Folder button") static let manageBookmarksTooltip = NSLocalizedString("tooltip.bookmarks.manage-bookmarks", value: "Manage bookmarks", comment: "Tooltip for the Manage Bookmarks button") static let bookmarksManage = NSLocalizedString("bookmarks.manage", value: "Manage", comment: "Button for opening the bookmarks management interface") + static let bookmarksSearch = NSLocalizedString("tooltip.bookmarks.search", value: "Search bookmarks", comment: "Tooltip to activate the bookmark search") + static let bookmarksSort = NSLocalizedString("tooltip.bookmarks.sort", value: "Sort bookmarks", comment: "Tooltip to activate the bookmark sort") + static let bookmarksSortManual = NSLocalizedString("bookmarks.sort.manual", value: "Manual", comment: "Button to sort bookmarks by manual") + static let bookmarksSortByName = NSLocalizedString("bookmarks.sort.name", value: "Name", comment: "Button to sort bookmarks by name ascending") + static let bookmarksSortByNameAscending = NSLocalizedString("bookmarks.sort.name.asc", value: "Ascending", comment: "Button to sort bookmarks by name ascending") + static let bookmarksSortByNameDescending = NSLocalizedString("bookmarks.sort.name.desc", value: "Descending", comment: "Button to sort bookmarks by name descending") static let bookmarksEmptyStateTitle = NSLocalizedString("bookmarks.empty.state.title", value: "No bookmarks yet", comment: "Title displayed in Bookmark Manager when there is no bookmarks yet") static let bookmarksEmptyStateMessage = NSLocalizedString("bookmarks.empty.state.message", value: "If your bookmarks are saved in another browser, you can import them into DuckDuckGo.", comment: "Text displayed in Bookmark Manager when there is no bookmarks yet") + static let bookmarksEmptySearchResultStateTitle = NSLocalizedString("bookmarks.empty.search.resukt..state.title", value: "No bookmarks found", comment: "Title displayed in Bookmark Panel when there is no bookmarks that match the search query") + static let bookmarksEmptySearchResultStateMessage = NSLocalizedString("bookmarks.empty.search.result.state.message", value: "Try different search terms.", comment: "Text displayed in Bookmark Panel when there is no bookmarks that match the search query") + static let openDownloadsFolderTooltip = NSLocalizedString("tooltip.downloads.open-downloads-folder", value: "Open downloads folder", comment: "Tooltip for the Open Downloads Folder button") static let clearDownloadHistoryTooltip = NSLocalizedString("tooltip.downloads.clear-download-history", value: "Clear download history", comment: "Tooltip for the Clear Downloads button") diff --git a/DuckDuckGo/InfoPlist.xcstrings b/DuckDuckGo/InfoPlist.xcstrings index 5b95b546ba..ef75ed805d 100644 --- a/DuckDuckGo/InfoPlist.xcstrings +++ b/DuckDuckGo/InfoPlist.xcstrings @@ -125,11 +125,59 @@ "comment" : "Privacy - Desktop Folder Usage Description", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichere heruntergeladene Dateien in diesem Ordner." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Allows you to save downloaded files to this folder." } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Te permite guardar los archivos descargados en esta carpeta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permet d'enregistrer les fichiers téléchargés dans ce dossier." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consente di salvare in questa cartella i file scaricati." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiermee kun je gedownloade bestanden opslaan in deze map." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umożliwia zapisywanie pobranych plików w tym folderze." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite guardar ficheiros transferidos nesta pasta." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позволяет сохранять загруженные файлы в эту папку." + } } } }, @@ -137,11 +185,59 @@ "comment" : "Privacy - Documents Folder Usage Description", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichere heruntergeladene Dateien in diesem Ordner." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Allows you to save downloaded files to this folder." } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Te permite guardar los archivos descargados en esta carpeta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permet d'enregistrer les fichiers téléchargés dans ce dossier." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consente di salvare in questa cartella i file scaricati." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiermee kun je gedownloade bestanden opslaan in deze map." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umożliwia zapisywanie pobranych plików w tym folderze." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite guardar ficheiros transferidos nesta pasta." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позволяет сохранять загруженные файлы в эту папку." + } } } }, @@ -149,11 +245,59 @@ "comment" : "Privacy - Downloads Folder Usage Description", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichere heruntergeladene Dateien in diesem Ordner." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Allows you to save downloaded files to this folder." } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Te permite guardar los archivos descargados en esta carpeta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permet d'enregistrer les fichiers téléchargés dans ce dossier." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consente di salvare in questa cartella i file scaricati." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiermee kun je gedownloade bestanden opslaan in deze map." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umożliwia zapisywanie pobranych plików w tym folderze." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite guardar ficheiros transferidos nesta pasta." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позволяет сохранять загруженные файлы в эту папку." + } } } }, diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 3cabec4533..16c9a49a07 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -10718,6 +10718,126 @@ } } }, + "bookmarks.empty.search.resukt..state.title" : { + "comment" : "Title displayed in Bookmark Panel when there is no bookmarks that match the search query", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Lesezeichen gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No bookmarks found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se han encontrado marcadores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun signet trouvé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non sono stati trovati segnalibri" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen bladwijzers gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie znaleziono zakładek" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum marcador encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закладки не найдены" + } + } + } + }, + "bookmarks.empty.search.result.state.message" : { + "comment" : "Text displayed in Bookmark Panel when there is no bookmarks that match the search query", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Probiere verschiedene Suchbegriffe aus." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try different search terms." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prueba con diferentes términos de búsqueda." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essayez différents termes de recherche." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prova con altri termini di ricerca." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Probeer verschillende zoektermen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spróbuj z innymi wyszukiwanymi hasłami." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experimenta termos de pesquisa diferentes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробуйте другие поисковые запросы." + } + } + } + }, "bookmarks.empty.state.message" : { "comment" : "Text displayed in Bookmark Manager when there is no bookmarks yet", "extractionState" : "extracted_with_value", @@ -11318,6 +11438,246 @@ } } }, + "bookmarks.sort.manual" : { + "comment" : "Button to sort bookmarks by manual", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Manual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuale" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handmatig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ręcznie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручную" + } + } + } + }, + "bookmarks.sort.name" : { + "comment" : "Button to sort bookmarks by name ascending", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naam" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название" + } + } + } + }, + "bookmarks.sort.name.asc" : { + "comment" : "Button to sort bookmarks by name ascending", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufsteigend" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ascending" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascendente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascendant" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascendente" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oplopend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rosnąco" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ascendente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По возрастанию" + } + } + } + }, + "bookmarks.sort.name.desc" : { + "comment" : "Button to sort bookmarks by name descending", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absteigend" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Descending" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descendente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descendant" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discendente" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aflopend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malejąco" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descendente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По убыванию" + } + } + } + }, "Bring All to Front" : { "comment" : "Main Menu Window item", "localizations" : { @@ -17085,11 +17445,59 @@ "comment" : "New tab preference in settings", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffne Duck Player nach Möglichkeit in einem neuen Tab" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Open Duck Player in a new tab whenever possible" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre Duck Player en una pestaña nueva siempre que sea posible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvre Duck Player dans un nouvel onglet chaque fois que possible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apri sempre Duck Player in una nuova scheda quando possibile" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Duck Player waar mogelijk in een nieuw tabblad" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwieraj Duck Player w nowej karcie, gdy tylko jest to możliwe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir o Duck Player num novo separador sempre que possível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По возможности открывать Duck Player в новой вкладке" + } } } }, @@ -17097,11 +17505,59 @@ "comment" : "New Tab title in settings", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neuer Tab" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "New Tab" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva pestaña" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvel onglet" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuova scheda" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieuw tabblad" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nowa karta" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Novo separador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая вкладка" + } } } }, @@ -17109,11 +17565,59 @@ "comment" : "New tab preference extra info in settings", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Browsen auf YouTube im Internet" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "When browsing YouTube on the web" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al navegar en YouTube en la web" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lorsque vous naviguez sur YouTube sur le Web" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durante la navigazione di YouTube sul Web" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wanneer je YouTube op het web bekijkt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podczas przeglądania YouTube w Internecie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ao navegar no YouTube na Internet" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При просмотре YouTube в интернете" + } } } }, @@ -24204,55 +24708,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Öffne und entsperre **%2$s**\n%3$d Wähle **Datei → Tresor exportieren** in der Menüleiste\n%4$d Wähle das Dateiformat: **.csv**\n%5$d Gib dein Bitwarden Master-Passwort ein\n%6$d Klicke auf %7$@ und speichere die Datei an einem Ort, an dem du sie finden kannst (z. B. auf dem Desktop)\n%8$d %9$@" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden master password\n%6$d Click %7$@ and save the file someplace you can find it (e.g., Desktop)\n%8$d %9$@" + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select **File → Export vault** from the Menu Bar\n%4$d Select the File Format: **.csv**\n%5$d Enter your Bitwarden main password\n%6$d Click %7$@ and save the file someplace you can find it (e.g., Desktop)\n%8$d %9$@" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Abre y desbloquea **%2$s**\n%3$d Selecciona **Archivo → Exportar caja fuerte** en la barra de menú\n%4$d Selecciona el formato de archivo: **.csv**\n%5$d Introduce tu contraseña maestra de Bitwarden\n%6$d Haz clic en %7$@ y guarda el archivo donde puedas encontrarlo posteriormente (por ejemplo, en el escritorio)\n%8$d %9$@" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Ouvrez et déverrouillez **%2$s**\n%3$d Sélectionnez **Fichier → Exporter le coffre-fort** dans la barre de menu\n%4$d Sélectionnez le format de fichier : **.csv**\n%5$d Saisissez votre mot de passe Bitwarden principal\n%6$d Cliquez sur %7$@ et enregistrez le fichier à un endroit où le trouver facilement (par exemple, sur le bureau)\n%8$d %9$@" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Apri e sblocca **%2$s**\n%3$d Seleziona **File → Esporta cassaforte** dalla Barra dei menu\n%4$d Seleziona il Formato file: **.csv**\n%5$d Inserisci la tua master password Bitwarden\n%6$d Fai clic su %7$@ e salva il file dove puoi trovarlo (ad esempio, sul desktop)\n%8$d %9$@" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Open en ontgrendel **%2$s**\n%3$d Selecteer **Bestand → Kluis exporteren** in de menubalk\n%4$d Selecteer de bestandsindeling: **.csv**\n%5$d Voer je Bitwarden-masterwachtwoord in\n%6$d Klik op %7$@ en bewaar het bestand op een plek waar je het kunt vinden (bijv. het bureaublad)\n%8$d %9$@" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Otwórz i odblokuj aplikację **%2$s**\n%3$d Wybierz **Plik → Eksportuj sejf** na pasku menu\n%4$d Wybierz format pliku: **.csv**\n%5$d Wprowadź hasło główne aplikacji Bitwarden\n%6$d Kliknij %7$@ i zapisz plik w łatwo dostępnym miejscu (np. na biurku)\n%8$d %9$@" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Abre e desbloqueia o **%2$s**\n%3$d Seleciona **Ficheiro → Exportar cofre** na barra de menus\n%4$d Seleciona o formato do ficheiro: **.csv**\n%5$d Introduz a tua palavra-passe mestra do Bitwarden\n%6$d Clica em %7$@ e guarda o ficheiro onde consigas encontrá-lo (por exemplo, no ambiente de trabalho)\n%8$d %9$@" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Откройте и разблокируйте **%2$s**\n%3$d На панели меню выберите **Файл → Экспортировать хранилище** \n%4$d Выберите формат файла: **.csv**\n%5$d Введите основной пароль к Bitwarden\n%6$d Нажмите %7$@ и сохраните файл там, где вы легко его найдете (например, на рабочем столе)\n%8$d %9$@" } } @@ -24624,55 +25128,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Klicke auf das **%2$s** Symbol in deinem Browser und gib dein Master-Passwort ein\n%3$d Wähle **Meinen Tresor öffnen**\n%4$d Wähle in der Seitenleiste **Erweiterte Optionen → Exportieren**\n%5$d Gib dein LastPass Master-Passwort ein\n%6$d Wähle das Dateiformat: **Comma Delimited Text (.csv)**\n%7$d %8$@" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Click on the **%2$s** icon in your browser and enter your master password\n%3$d Select **Open My Vault**\n%4$d From the sidebar select **Advanced Options → Export**\n%5$d Enter your LastPass master password\n%6$d Select the File Format: **Comma Delimited Text (.csv)**\n%7$d %8$@" + "value" : "%1$d Click on the **%2$s** icon in your browser and enter your main password\n%3$d Select **Open My Vault**\n%4$d From the sidebar select **Advanced Options → Export**\n%5$d Enter your LastPass main password\n%6$d Select the File Format: **Comma Delimited Text (.csv)**\n%7$d %8$@" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Haz clic en el icono **%2$s** de tu navegador e introduce tu contraseña maestra\n%3$d Selecciona **Abrir mi caja fuerte**\n%4$d En la barra lateral, selecciona **Opciones avanzadas → Exportar**\n%5$d Introduce tu contraseña maestra de LastPass\n%6$d Selecciona el formato de archivo: **Texto delimitado por comas (.csv)**\n%7$d %8$@." } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Cliquez sur l'icône **%2$s** de votre navigateur et saisissez votre mot de passe principal\n%3$d Sélectionnez **Ouvrir mon coffre-fort**\n%4$d Dans la barre latérale, sélectionnez **Options avancées → Exporter**\n%5$d Saisissez votre mot de passe LastPass principal\n%6$d Sélectionnez le format de fichier** : **Texte séparé par des virgules (.csv)**\n%7$d %8$@" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Fai clic sull'icona **%2$s** nel browser e immetti la master password\n%3$d Seleziona **Apri la mia cassaforte**\n%4$d Dalla barra laterale seleziona **Opzioni avanzate → Esporta\n**%5$d Immetti la master password LastPass\n%6$d Seleziona il formato del file: **Testo delimitato da virgole (.csv)**\n%7$d %8$@" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Klik op het pictogram **%2$s** in je browser en voer je masterwachtwoord in\n%3$d Selecteer **Open mijn kluis**\n%4$d Klik in de zijbalk op **Geavanceerde opties → Exporteren**\n%5$d Voer je LastPass-masterwachtwoord in\n%6$d Selecteer de bestandsindeling: **Door komma's gescheiden tekst (.csv)**\n%7$d %8$@" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Kliknij ikonę **%2$s** w przeglądarce i wprowadź hasło główne\n%3$d Wybierz **Open My Vault**\n%4$d Na pasku bocznym wybierz **Advanced Options → Export**\n%5$d Wprowadź hasło główne narzędzia LastPass\n%6$d Wybierz format pliku: **Comma Delimited Text (.csv)**\n%7$d %8$@" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Clica no ícone da aplicação **%2$s** no teu navegador e introduz a tua palavra-passe mestra\n%3$d Seleciona **Abrir o meu cofre**\n%4$d Na barra lateral, seleciona **Opções avançadas → Exportar**\n%5$d Introduz a tua palavra-passe mestra da aplicação LastPass\n%6$d Seleciona o formato do ficheiro: **Texto delimitado por vírgula (.csv)**\n%7$d %8$@" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Нажмите значок **%2$s** в браузере и введите основной пароль\n%3$d Выберите меню **Открыть мое хранилище**\n%4$d На боковой панели выберите **Дополнительные опции → Экспортировать**\n%5$d Введите основной пароль к LastPass\n%6$d Сохраните файл в формате: **текст с разделяющими запятыми (.csv)**\n%7$d %8$@" } } @@ -24684,55 +25188,55 @@ "localizations" : { "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Öffne und entsperre **%2$s**\n%3$d Wähle den Tresor aus, den du exportieren möchtest (du kannst immer nur einen Tresor auf einmal exportieren)\n%4$d Wähle **Datei → Exportieren → Alle Elemente** aus der Menüleiste\n%5$d Gib dein 1Password Master- oder Account-Passwort ein\n%6$d Wähle das Dateiformat: **iCloud-Schlüsselbund (.csv)**\n%7$d Speichere die Passwortdatei an einem Ort, an dem du sie finden kannst (z. B. auf dem Desktop)\n%8$d %9$@" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to export (you can only export one vault at a time)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password master or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" + "value" : "%1$d Open and unlock **%2$s**\n%3$d Select the vault you want to export (you can only export one vault at a time)\n%4$d Select **File → Export → All Items** from the Menu Bar\n%5$d Enter your 1Password main or account password\n%6$d Select the File Format: **iCloud Keychain (.csv)**\n%7$d Save the passwords file someplace you can find it (e.g., Desktop)\n%8$d %9$@" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Abre y desbloquea **%2$s**\n%3$d Selecciona la caja fuerte que quieres exportar (solo puedes exportar una caja fuerte a la vez)\n%4$d Selecciona **Archivo → Exportar → Todos los elementos** en la barra de menú\n%5$d Introduce tu contraseña maestra o de cuenta de 1Password\n%6$d Selecciona el formato de archivo: **Llavero de iCloud (.csv) **\n%7$d Guarda el archivo de contraseñas donde puedas encontrarlo posteriormente (por ejemplo, en el escritorio)\n%8$d %9$@" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Ouvrez et déverrouillez **%2$s**\n%3$d Sélectionnez le coffre-fort à exporter (vous ne pouvez exporter qu'un seul coffre-fort à la fois)\n%4$d Sélectionnez **Fichier → Exporter → Tous les éléments** dans la barre de menu\n%5$d Saisissez votre mot de passe principal ou celui de votre compte 1Password\n%6$d Sélectionnez le format de fichier :**Trousseau iCloud (.csv)**\n%7$d Enregistrez le fichier des mots de passe à un endroit où le trouver facilement (par exemple, sur le bureau)\n%8$d %9$@" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Apri e sblocca **%2$s\n**%3$d Seleziona la cassaforte da esportare (è possibile esportare solo una cassaforte alla volta)\n%4$d Seleziona **File → Esporta → Tutti gli elementi** dalla Barra dei menu\n%5$d Inserisci la master password o quella dell'account 1Password\n%6$d Seleziona il formato file: **Keychain iCloud (.csv)**\n%7$d Salva il file delle password dove puoi trovarlo (ad esempio, sul desktop)\n%8$d %9$@" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Open en ontgrendel **%2$s**\n%3$d Selecteer de kluis die je wilt exporteren (je kunt slechts één kluis per keer exporteren)\n%4$d Selecteer **Bestand → Exporteren → Alle items** in de menubalk\n%5$d Voer je master- of accountwachtwoord van 1Password in\n%6$d Selecteer de bestandsindeling: **iCloud Keychain (.csv)**\n%7$d Bewaar het bestand met de wachtwoorden op een plek waar je het kunt vinden (bijv. het bureaublad)\n%8$d %9$@" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Otwórz i odblokuj aplikację **%2$s**\n%3$d Wybierz sejf, który chcesz wyeksportować (możesz eksportować tylko jeden sejf naraz)\n%4$d Na pasku menu wybierz **File → Export → All Items**\n%5$d Wprowadź hasło główne lub hasło konta 1Password\n%6$d Wybierz format pliku: **iCloud Keychain (.csv)**\n%7$d Zapisz plik haseł w łatwo dostępnym miejscu (np. na biurku)\n%8$d %9$@" } }, "pt" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Abre e desbloqueia a aplicação **%2$s**\n%3$d Seleciona o cofre que pretendes exportar (só podes exportar um cofre de cada vez)\n%4$d Seleciona **Ficheiro → Exportar → Todos os elementos** na barra de menus\n%5$d Introduz a tua palavra-passe mestra da 1Password ou a palavra-passe da conta\n%6$d Seleciona o formato do ficheiro: **Porta-chaves em iCloud (.csv)**\n%7$d Guarda o ficheiro de palavras-passe onde consigas encontrá-lo (por exemplo, no ambiente de trabalho)\n%8$d %9$@" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "%1$d Откройте и разблокируйте **%2$s**\n%3$d Выберите хранилище, которое вы хотите экспортировать (можно экспортировать только по одному хранилищу за раз)\n%4$d На панели меню выберите опцию **Файл → Экспортировать → Все элементы**\n%5$d Введите основной пароль или пароль к учетной записи 1Password\n%6$d Выберите формат файла: **iCloud Keychain (.csv)**\n%7$d Сохраните файл с паролями там, где вы легко его найдете (например, на рабочем столе)\n%8$d %9$@" } } @@ -30636,6 +31140,66 @@ } } }, + "menu.show.in.folder" : { + "comment" : "Menu item to show where a bookmark is located", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Ordner anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show in Folder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en la carpeta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher dans le dossier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra nella cartella" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weergeven in map" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż w folderze" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar na pasta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать в папке" + } + } + } + }, "Merge All Windows" : { "comment" : "Main Menu Window item", "localizations" : { @@ -54909,6 +55473,66 @@ } } }, + "tooltip.bookmarks.search" : { + "comment" : "Tooltip to activate the bookmark search", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lesezeichen durchsuchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search bookmarks" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar marcadores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des favoris" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca segnalibri" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bladwijzers zoeken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyszukaj zakładki" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pesquisar marcadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск по закладкам" + } + } + } + }, "tooltip.bookmarks.shortcut" : { "comment" : "Tooltip for the bookmarks shortcut", "extractionState" : "extracted_with_value", @@ -54969,6 +55593,66 @@ } } }, + "tooltip.bookmarks.sort" : { + "comment" : "Tooltip to activate the bookmark sort", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lesezeichen sortieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sort bookmarks" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordenar marcadores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trier les signets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordina i segnalibri" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bladwijzers sorteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sortuj zakładki" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ordenar marcadores" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сортировка закладок" + } + } + } + }, "tooltip.clearHistory" : { "comment" : "Tooltip for burn button where %@ is the domain", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index a16749efc5..f23bb61dea 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -364,6 +364,13 @@ enum GeneralPixel: PixelKitEventV2 { case bookmarksMigrationCouldNotRemoveOldStore case bookmarksMigrationCouldNotPrepareMultipleFavoriteFolders + // Bookmarks search and sort feature metrics + case bookmarksSortButtonClicked(origin: String) + case bookmarksSortButtonDismissed(origin: String) + case bookmarksSortByName(origin: String) + case bookmarksSearchExecuted(origin: String) + case bookmarksSearchResultClicked(origin: String) + case syncSentUnauthenticatedRequest case syncMetadataCouldNotLoadDatabase case syncBookmarksProviderInitializationFailed @@ -1000,6 +1007,13 @@ enum GeneralPixel: PixelKitEventV2 { case .secureVaultKeystoreEventL2KeyPasswordMigration: return "m_mac_secure_vault_keystore_event_l2-key-password-migration" case .compilationFailed: return "compilation_failed" + + // Bookmarks search and sort feature + case .bookmarksSortButtonClicked: return "m_mac_sort_bookmarks_button_clicked" + case .bookmarksSortButtonDismissed: return "m_mac_sort_bookmarks_button_dismissed" + case .bookmarksSortByName: return "m_mac_sort_bookmarks_by_name" + case .bookmarksSearchExecuted: return "m_mac_search_bookmarks_executed" + case .bookmarksSearchResultClicked: return "m_mac_search_result_clicked" } } @@ -1102,6 +1116,13 @@ enum GeneralPixel: PixelKitEventV2 { case .onboardingDuckplayerUsed5to7(let cohort): return [PixelKit.Parameters.experimentCohort: cohort] + case .bookmarksSortButtonClicked(let origin), + .bookmarksSortButtonDismissed(let origin), + .bookmarksSortByName(let origin), + .bookmarksSearchExecuted(let origin), + .bookmarksSearchResultClicked(let origin): + return ["origin": origin] + default: return nil } } diff --git a/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift b/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift index 11ffefab31..5951f89c92 100644 --- a/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift +++ b/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift @@ -244,4 +244,49 @@ final class BaseBookmarkEntityTests: XCTestCase { XCTAssertFalse(result) } + func testWhenSortingByManualModeThenBookmarksAreReturnedInOriginalOrder() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "Test 3", isFavorite: true) + let folder = BookmarkFolder(id: "2", title: "Test 1") + let bookmarkTwo = Bookmark(id: "3", url: URL.duckDuckGo.absoluteString, title: "Test 2", isFavorite: false) + + // WHEN + let sut = [bookmark, folder, bookmarkTwo].sorted(by: .manual) + + // THEN + XCTAssertEqual(sut[0], bookmark) + XCTAssertEqual(sut[1], folder) + XCTAssertEqual(sut[2], bookmarkTwo) + } + + func testWhenSortingByNameAscThenBookmarksAreReturnedByAscendingTitle() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "Test 3", isFavorite: true) + let folder = BookmarkFolder(id: "2", title: "Test 1") + let bookmarkTwo = Bookmark(id: "3", url: URL.duckDuckGo.absoluteString, title: "Test 2", isFavorite: false) + + // WHEN + let sut = [bookmark, folder, bookmarkTwo].sorted(by: .nameAscending) + + // THEN + XCTAssertEqual(sut[0], folder) + XCTAssertEqual(sut[1], bookmarkTwo) + XCTAssertEqual(sut[2], bookmark) + } + + func testWhenSortingByNameDescThenBookmarksAreReturnedByDescendingTitle() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "Test 3", isFavorite: true) + let folder = BookmarkFolder(id: "2", title: "Test 1") + let bookmarkTwo = Bookmark(id: "3", url: URL.duckDuckGo.absoluteString, title: "Test 2", isFavorite: false) + + // WHEN + let sut = [bookmark, folder, bookmarkTwo].sorted(by: .nameDescending) + + // THEN + XCTAssertEqual(sut[0], bookmark) + XCTAssertEqual(sut[1], bookmarkTwo) + XCTAssertEqual(sut[2], folder) + } + } diff --git a/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift b/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift index fd795b2ebb..4b5e5e84ed 100644 --- a/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift @@ -200,6 +200,15 @@ class BookmarkNodeTests: XCTestCase { XCTAssertFalse(node.representedObjectEquals(TestObject())) } + func testWhenCheckingRepresentedObjectEqualityForSearch_AndObjectsHaveSameId_ThenTrueIsReturned() { + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let firstFolder = BookmarkFolder(id: "2", title: "Folder", children: []) + let firstFolderWithDifferentChildren = BookmarkFolder(id: "2", title: "Folder", children: [bookmark]) + let node = BookmarkNode(representedObject: firstFolder, parent: nil) + + XCTAssertTrue(node.representedObjectHasSameId(firstFolderWithDifferentChildren)) + } + func testWhenFindingOrCreatingChildNode_AndChildExists_ThenChildIsReturned() { let childObject = TestObject() let rootNode = BookmarkNode(representedObject: TestObject(), parent: nil) diff --git a/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift b/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift index d1cc5fa4d8..148c98f870 100644 --- a/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift @@ -26,7 +26,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockFolder = BookmarkFolder.mock let treeController = createTreeController(with: [mockFolder]) let mockFolderNode = treeController.node(representing: mockFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let notification = Notification(name: NSOutlineView.itemDidExpandNotification, object: nil, userInfo: ["NSObject": mockFolderNode]) dataSource.outlineViewItemDidExpand(notification) @@ -38,7 +38,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockFolder = BookmarkFolder.mock let treeController = createTreeController(with: [mockFolder]) let mockFolderNode = treeController.node(representing: mockFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let expandNotification = Notification(name: NSOutlineView.itemDidExpandNotification, object: nil, userInfo: ["NSObject": mockFolderNode]) dataSource.outlineViewItemDidExpand(expandNotification) @@ -56,7 +56,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockOutlineView = NSOutlineView(frame: .zero) let treeController = createTreeController(with: [mockFolder]) let mockFolderNode = treeController.node(representing: mockFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let writer = dataSource.outlineView(mockOutlineView, pasteboardWriterForItem: mockFolderNode) as? FolderPasteboardWriter XCTAssertNotNil(writer) @@ -69,7 +69,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockFolder = BookmarkFolder.mock let mockOutlineView = NSOutlineView(frame: .zero) let treeController = createTreeController(with: [mockFolder]) - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let spacerNode = BookmarkNode(representedObject: SpacerNode.blank, parent: nil) let writer = dataSource.outlineView(mockOutlineView, pasteboardWriterForItem: spacerNode) as? FolderPasteboardWriter @@ -86,9 +86,9 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { bookmarkManager.loadBookmarks() let treeDataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: treeDataSource) + let treeController = BookmarkTreeController(dataSource: treeDataSource, sortMode: .manual) let mockDestinationNode = treeController.node(representing: mockDestinationFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let pasteboardBookmark = PasteboardBookmark(id: UUID().uuidString, url: "https://example.com", title: "Pasteboard Bookmark") let result = dataSource.validateDrop(for: [pasteboardBookmark], destination: mockDestinationNode) @@ -106,9 +106,9 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { bookmarkManager.loadBookmarks() let treeDataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: treeDataSource) + let treeController = BookmarkTreeController(dataSource: treeDataSource, sortMode: .manual) let mockDestinationNode = treeController.node(representing: mockDestinationFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let pasteboardFolder = PasteboardFolder(folder: .init(id: UUID().uuidString, title: "Pasteboard Folder")) let result = dataSource.validateDrop(for: [pasteboardFolder], destination: mockDestinationNode) @@ -126,8 +126,8 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { bookmarkManager.loadBookmarks() let treeDataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: treeDataSource) - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) + let treeController = BookmarkTreeController(dataSource: treeDataSource, sortMode: .manual) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let mockDestinationNode = treeController.node(representing: mockDestinationFolder)! let pasteboardFolder = PasteboardFolder(folder: mockDestinationFolder) @@ -148,8 +148,8 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { bookmarkManager.loadBookmarks() let treeDataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: treeDataSource) - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) + let treeController = BookmarkTreeController(dataSource: treeDataSource, sortMode: .manual) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) let mockDestinationNode = treeController.node(representing: childFolder)! // Simulate dragging the root folder onto the child folder: @@ -167,7 +167,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockFolderNode = treeController.node(representing: mockFolder)! var didFireClosure = false var capturedCell: BookmarkOutlineCellView? - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) { cell in + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual) { cell in didFireClosure = true capturedCell = cell } @@ -187,7 +187,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockOutlineView = NSOutlineView(frame: .zero) let treeController = createTreeController(with: [mockFolder]) let mockFolderNode = treeController.node(representing: mockFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .bookmarksAndFolders, bookmarkManager: LocalBookmarkManager(), treeController: treeController, showMenuButtonOnHover: true) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .bookmarksAndFolders, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual, showMenuButtonOnHover: true) // WHEN let cell = try XCTUnwrap(dataSource.outlineView(mockOutlineView, viewFor: nil, item: mockFolderNode) as? BookmarkOutlineCellView) @@ -202,7 +202,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockOutlineView = NSOutlineView(frame: .zero) let treeController = createTreeController(with: [mockFolder]) let mockFolderNode = treeController.node(representing: mockFolder)! - let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, showMenuButtonOnHover: false) + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController, sortMode: .manual, showMenuButtonOnHover: false) // WHEN let cell = try XCTUnwrap(dataSource.outlineView(mockOutlineView, viewFor: nil, item: mockFolderNode) as? BookmarkOutlineCellView) @@ -222,7 +222,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { bookmarkManager.loadBookmarks() let treeDataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - return BookmarkTreeController(dataSource: treeDataSource) + return BookmarkTreeController(dataSource: treeDataSource, sortMode: .manual) } } diff --git a/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift b/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift index 4b95d7ca90..0638af297e 100644 --- a/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift @@ -24,7 +24,7 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { func testWhenBookmarkStoreHasNoFolders_ThenOnlyDefaultNodesAreReturned() { let dataSource = BookmarkSidebarTreeController(bookmarkManager: LocalBookmarkManager()) - let treeController = BookmarkTreeController(dataSource: dataSource) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual) let defaultNodes = treeController.rootNode.childNodes let representedObjects = defaultNodes.representedObjects() @@ -48,7 +48,7 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { bookmarkManager.loadBookmarks() let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: dataSource) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual) let defaultNodes = treeController.rootNode.childNodes XCTAssertEqual(defaultNodes.count, 1) @@ -69,7 +69,7 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { bookmarkManager.loadBookmarks() let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: dataSource) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual) let defaultNodes = treeController.rootNode.childNodes XCTAssertEqual(defaultNodes.count, 1) @@ -92,7 +92,7 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { bookmarkManager.loadBookmarks() let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) - let treeController = BookmarkTreeController(dataSource: dataSource) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual) let defaultNodes = treeController.rootNode.childNodes XCTAssertEqual(defaultNodes.count, 1) @@ -109,5 +109,4 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { XCTAssertFalse(childFolderNode.canHaveChildNodes) XCTAssert(childFolderNode.representedObjectEquals(childFolder)) } - } diff --git a/UnitTests/Bookmarks/Model/ContextualMenuTests.swift b/UnitTests/Bookmarks/Model/ContextualMenuTests.swift index 89da2c186a..4dcfd599ef 100644 --- a/UnitTests/Bookmarks/Model/ContextualMenuTests.swift +++ b/UnitTests/Bookmarks/Model/ContextualMenuTests.swift @@ -235,6 +235,61 @@ final class ContextualMenuTests: XCTestCase { assertMenu(item: items[2], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), representedObject: [bookmark, folder]) } + func testWhenSearchIsHappeningThenMenuForBookmarksReturnsShowInFolder() throws { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false) + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark], forSearch: true) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 13) + assertMenu(item: items[5], withTitle: UserText.showInFolder, selector: #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), representedObject: bookmark) + } + + func testWhenSearchIsHappeningThenMenuForFoldersReturnsShowInFolder() throws { + // GIVEN + let folder = BookmarkFolder(id: "1", title: "Folder") + + // WHEN + let menu = ContextualMenu.menu(for: [folder], forSearch: true) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 10) + assertMenu(item: items[3], withTitle: UserText.showInFolder, selector: #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), representedObject: folder) + } + + func testWhenGettingContextalMenuForMoreThanOneBookmarkThenShowInFolderIsNotReturned() throws { + // GIVEN + let bookmark = Bookmark(id: "1", url: "", title: "Bookmark", isFavorite: true) + let folder = BookmarkFolder(id: "1", title: "Folder") + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark, folder]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 3) + + for menuItem in items { + XCTAssertNotEqual(menuItem.title, UserText.showInFolder) + XCTAssertNotEqual(menuItem.action, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:))) + } + } + + func testWhenGettingContextualMenuForItemThenShowInFolderIsNotReturned() throws { + // WHEN + let menu = ContextualMenu.menu(for: []) + + // THEN + XCTAssertEqual(menu?.items.count, 1) + let menuItem = try XCTUnwrap(menu?.items.first) + XCTAssertNotEqual(menuItem.title, UserText.showInFolder) + XCTAssertNotEqual(menuItem.action, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:))) + } + } private extension ContextualMenuTests { diff --git a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift index bf9b7d0d17..16a3350b1e 100644 --- a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift +++ b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift @@ -320,6 +320,22 @@ final class LocalBookmarkManagerTests: XCTestCase { XCTAssertFalse(results[1].isFolder) } + func testWhenASearchIsDoneThenItMatchesWithLowercaseResults() { + let bookmarkCapitalized = Bookmark(id: "1", url: "www.favorite.com", title: "Favorite bookmark", isFavorite: true) + let bookmarkNonCapitalized = Bookmark(id: "2", url: "www.favoritetwo.com", title: "favorite bookmark", isFavorite: true) + + let bookmarkStore = BookmarkStoreMock(bookmarks: [bookmarkCapitalized, bookmarkNonCapitalized]) + let sut = LocalBookmarkManager(bookmarkStore: bookmarkStore, faviconManagement: FaviconManagerMock()) + + sut.loadBookmarks() + + let resultsWhtCapitalizedQuery = sut.search(by: "Favorite") + let resultsWithNotCapitalizedQuery = sut.search(by: "favorite") + + XCTAssertTrue(resultsWhtCapitalizedQuery.count == 2) + XCTAssertTrue(resultsWithNotCapitalizedQuery.count == 2) + } + private func topLevelBookmarks() -> [BaseBookmarkEntity] { let topBookmark = Bookmark(id: "4", url: "www.favorite.com", title: "Favorite bookmark", isFavorite: true) let favoriteFolder = BookmarkFolder(id: "5", title: "Favorite folder", children: [topBookmark]) diff --git a/UnitTests/Bookmarks/Model/TreeControllerTests.swift b/UnitTests/Bookmarks/Model/TreeControllerTests.swift index 7c61bd0f3e..95012536d7 100644 --- a/UnitTests/Bookmarks/Model/TreeControllerTests.swift +++ b/UnitTests/Bookmarks/Model/TreeControllerTests.swift @@ -21,10 +21,17 @@ import XCTest private class MockTreeControllerDataSource: BookmarkTreeControllerDataSource { - func treeController(treeController: BookmarkTreeController, childNodesFor node: BookmarkNode) -> [BookmarkNode] { + func treeController(childNodesFor node: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] { return node.childNodes } +} + +private final class MockTreeControllerSearchDataSource: BookmarkTreeControllerSearchDataSource { + var returnNodes: [BookmarkNode] = [] + func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { + return returnNodes + } } class TreeControllerTests: XCTestCase { @@ -34,14 +41,14 @@ class TreeControllerTests: XCTestCase { func testWhenInitializingTreeControllerWithRootNode_ThenRootNodeIsSet() { let dataSource = MockTreeControllerDataSource() let node = BookmarkNode(representedObject: TestObject(), parent: nil) - let treeController = BookmarkTreeController(dataSource: dataSource, rootNode: node) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual, rootNode: node) XCTAssertEqual(treeController.rootNode, node) } func testWhenInitializingTreeControllerWithoutRootNode_ThenGenericRootNodeIsCreated() { let dataSource = MockTreeControllerDataSource() - let treeController = BookmarkTreeController(dataSource: dataSource) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual) XCTAssertTrue(treeController.rootNode.canHaveChildNodes) } @@ -57,7 +64,7 @@ class TreeControllerTests: XCTestCase { rootNode.childNodes = [firstChildNode, secondChildNode] let dataSource = MockTreeControllerDataSource() - let treeController = BookmarkTreeController(dataSource: dataSource, rootNode: rootNode) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual, rootNode: rootNode) let foundNode = treeController.node(representing: desiredObject) XCTAssertEqual(foundNode, secondChildNode) @@ -72,7 +79,7 @@ class TreeControllerTests: XCTestCase { rootNode.childNodes = [firstChildNode, secondChildNode] let dataSource = MockTreeControllerDataSource() - let treeController = BookmarkTreeController(dataSource: dataSource, rootNode: rootNode) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual, rootNode: rootNode) let foundNode = treeController.node(representing: TestObject()) XCTAssertNil(foundNode) @@ -87,7 +94,7 @@ class TreeControllerTests: XCTestCase { rootNode.childNodes = [firstChildNode, secondChildNode] let dataSource = MockTreeControllerDataSource() - let treeController = BookmarkTreeController(dataSource: dataSource, rootNode: rootNode) + let treeController = BookmarkTreeController(dataSource: dataSource, sortMode: .manual, rootNode: rootNode) var visitedNodes = Set() @@ -98,4 +105,18 @@ class TreeControllerTests: XCTestCase { XCTAssertEqual(visitedNodes, [rootNode.uniqueID, firstChildNode.uniqueID, secondChildNode.uniqueID]) } + func testWhenWeRebuildForSearch_ThenTheTreeIsCreatedWithNodesReturnedFromSearchDataSource() { + let firstNode = BookmarkNode(representedObject: TestObject(), parent: nil) + let secondNode = BookmarkNode(representedObject: TestObject(), parent: nil) + let thirdNode = BookmarkNode(representedObject: TestObject(), parent: nil) + let dataSource = MockTreeControllerDataSource() + let searchDataSource = MockTreeControllerSearchDataSource() + searchDataSource.returnNodes = [firstNode, secondNode, thirdNode] + let sut = BookmarkTreeController(dataSource: dataSource, sortMode: .manual, searchDataSource: searchDataSource) + + sut.rebuild(for: "some search query", sortMode: .manual) + + XCTAssertEqual(sut.rootNode.childNodes.count, 3) + } + } diff --git a/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift deleted file mode 100644 index 381369a428..0000000000 --- a/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// BookmarkSearchViewModelTests.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 - -final class BookmarkSearchViewModelTests: XCTestCase { - - func testWhenQueryIsEmpty_thenEmptyQueryIsReturned() { - let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) - let result = sut.search(by: "") - - XCTAssertEqual(result, .emptyQuery) - } - - func testWhenQueryIsBlank_thenEmptyQueryIsReturned() { - let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) - let result = sut.search(by: " ") - - XCTAssertEqual(result, .emptyQuery) - } - - func testWhenQueryIsNotBlankAndQueryDoesNotMatchBookmarks_thenNoResultsIsReturned() { - let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) - let result = sut.search(by: "abc") - - XCTAssertEqual(result, .noResults) - } - - func testWhenQueryIsNotBlankAndQueryMatchesBookmarks_thenResultsAreReturned() { - let bookmark = Bookmark(id: "1", url: "www.test.com", title: "Bookmark", isFavorite: false) - let manager = MockBookmarkManager() - manager.bookmarksReturnedForSearch = [bookmark] - let sut = BookmarkSearchViewModel(manager: manager) - let result = sut.search(by: "abc") - - XCTAssertEqual(result, .results([bookmark])) - } -} diff --git a/UnitTests/Bookmarks/ViewModels/BookmarksSortModeTests.swift b/UnitTests/Bookmarks/ViewModels/BookmarksSortModeTests.swift new file mode 100644 index 0000000000..08f31bf0e6 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/BookmarksSortModeTests.swift @@ -0,0 +1,103 @@ +// +// BookmarksSortModeTests.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 BookmarksSortModeTests: XCTestCase { + + func testTitle() { + XCTAssertEqual(BookmarksSortMode.manual.title, UserText.bookmarksSortManual) + XCTAssertEqual(BookmarksSortMode.nameAscending.title, UserText.bookmarksSortByNameAscending) + XCTAssertEqual(BookmarksSortMode.nameDescending.title, UserText.bookmarksSortByNameDescending) + } + + func testAction() { + XCTAssertEqual(BookmarksSortMode.manual.action, #selector(BookmarkSortMenuItemSelectors.manualSort(_:))) + XCTAssertEqual(BookmarksSortMode.nameAscending.action, #selector(BookmarkSortMenuItemSelectors.sortByNameAscending(_:))) + XCTAssertEqual(BookmarksSortMode.nameDescending.action, #selector(BookmarkSortMenuItemSelectors.sortByNameDescending(_:))) + } + + func testShouldHighlightButton() { + XCTAssertFalse(BookmarksSortMode.manual.shouldHighlightButton) + XCTAssertTrue(BookmarksSortMode.nameAscending.shouldHighlightButton) + XCTAssertTrue(BookmarksSortMode.nameDescending.shouldHighlightButton) + } + + func testMenuForManual() { + let manualMenu = BookmarksSortMode.manual.menu + + XCTAssertEqual(manualMenu.items.count, 5) + + XCTAssertEqual(manualMenu.items[0].title, UserText.bookmarksSortManual) + XCTAssertEqual(manualMenu.items[0].state, .on) + + XCTAssertEqual(manualMenu.items[1].title, UserText.bookmarksSortByName) + XCTAssertEqual(manualMenu.items[1].state, .off) + + XCTAssert(manualMenu.items[2].isSeparatorItem) + + XCTAssertEqual(manualMenu.items[3].title, UserText.bookmarksSortByNameAscending) + XCTAssertEqual(manualMenu.items[3].state, .off) + XCTAssertNil(manualMenu.items[3].action) + + XCTAssertEqual(manualMenu.items[4].title, UserText.bookmarksSortByNameDescending) + XCTAssertEqual(manualMenu.items[4].state, .off) + XCTAssertNil(manualMenu.items[4].action) + } + + func testMenuForNameAscending() { + let ascendingMenu = BookmarksSortMode.nameAscending.menu + + XCTAssertEqual(ascendingMenu.items.count, 5) + + XCTAssertEqual(ascendingMenu.items[0].title, UserText.bookmarksSortManual) + XCTAssertEqual(ascendingMenu.items[0].state, .off) + + XCTAssertEqual(ascendingMenu.items[1].title, UserText.bookmarksSortByName) + XCTAssertEqual(ascendingMenu.items[1].state, .on) + + XCTAssert(ascendingMenu.items[2].isSeparatorItem) + + XCTAssertEqual(ascendingMenu.items[3].title, UserText.bookmarksSortByNameAscending) + XCTAssertEqual(ascendingMenu.items[3].state, .on) + + XCTAssertEqual(ascendingMenu.items[4].title, UserText.bookmarksSortByNameDescending) + XCTAssertEqual(ascendingMenu.items[4].state, .off) + } + + func testMenuForNameDescending() { + let descendingMenu = BookmarksSortMode.nameDescending.menu + + XCTAssertEqual(descendingMenu.items.count, 5) + + XCTAssertEqual(descendingMenu.items[0].title, UserText.bookmarksSortManual) + XCTAssertEqual(descendingMenu.items[0].state, .off) + + XCTAssertEqual(descendingMenu.items[1].title, UserText.bookmarksSortByName) + XCTAssertEqual(descendingMenu.items[1].state, .on) + + XCTAssert(descendingMenu.items[2].isSeparatorItem) + + XCTAssertEqual(descendingMenu.items[3].title, UserText.bookmarksSortByNameAscending) + XCTAssertEqual(descendingMenu.items[3].state, .off) + + XCTAssertEqual(descendingMenu.items[4].title, UserText.bookmarksSortByNameDescending) + XCTAssertEqual(descendingMenu.items[4].state, .on) + } +} diff --git a/UnitTests/Bookmarks/ViewModels/SortBookmarksViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/SortBookmarksViewModelTests.swift new file mode 100644 index 0000000000..3c85fa0947 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/SortBookmarksViewModelTests.swift @@ -0,0 +1,154 @@ +// +// SortBookmarksViewModelTests.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 +import PixelKitTestingUtilities +@testable import PixelKit +@testable import DuckDuckGo_Privacy_Browser + +final class MockSortBookmarksRepository: SortBookmarksRepository { + var storedSortMode: BookmarksSortMode + + init(storedSortMode: BookmarksSortMode = .manual) { + self.storedSortMode = storedSortMode + } +} + +class SortBookmarksViewModelTests: XCTestCase { + let testUserDefault = UserDefaults(suiteName: #function)! + let repository = MockSortBookmarksRepository() + let metrics = BookmarksSearchAndSortMetrics() + + func testWhenSortingIsNameAscending_thenSortByNameMetricIsFired() async throws { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + let expectedPixel = GeneralPixel.bookmarksSortByName(origin: "panel") + + try await verify(expectedPixel: expectedPixel, for: { sut.setSort(mode: .nameAscending) }) + } + + func testWhenSortingIsNameDescending_thenSortByNameMetricIsFired() async throws { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + let expectedPixel = GeneralPixel.bookmarksSortByName(origin: "panel") + + try await verify(expectedPixel: expectedPixel, for: { sut.setSort(mode: .nameDescending) }) + } + + func testWhenSortingIsManual_thenSortByNameMetricIsNotFired() async throws { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + let notExpectedPixel = GeneralPixel.bookmarksSortByName(origin: "panel") + + try await verifyNotFired(pixel: notExpectedPixel, for: { sut.setSort(mode: .manual) }) + } + + func testWhenSortingIsManual_thenIsSavedToRepository() { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + + sut.setSort(mode: .manual) + + XCTAssertEqual(repository.storedSortMode, .manual) + } + + func testWhenSortingIsNameAscending_thenIsSavedToRepository() { + let repository = MockSortBookmarksRepository() + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + + sut.setSort(mode: .nameAscending) + + XCTAssertEqual(repository.storedSortMode, .nameAscending) + } + + func testWhenSortingIsNameDescending_thenIsSavedToRepository() { + let repository = MockSortBookmarksRepository() + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + + sut.setSort(mode: .nameDescending) + + XCTAssertEqual(repository.storedSortMode, .nameDescending) + } + + @MainActor + func testWhenMenuIsClosedAndNoOptionWasSelected_thenSortButtonDismissedIsFired() async throws { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + let expectedPixel = GeneralPixel.bookmarksSortButtonDismissed(origin: "panel") + + try await verify(expectedPixel: expectedPixel, for: { sut.menuDidClose(NSMenu()) }) + } + + @MainActor + func testWhenMenuIsClosedAndOptionWasSelected_thenSortButtonDismissedIsNotFired() async throws { + let sut = SortBookmarksViewModel(repository: repository, metrics: metrics, origin: .panel) + let notExpectedPixel = GeneralPixel.bookmarksSortButtonDismissed(origin: "panel") + + try await verifyNotFired(pixel: notExpectedPixel, for: { + sut.setSort(mode: .manual) + sut.menuDidClose(NSMenu()) + }) + } + + // MARK: - Pixel testing helper methods + + private func verify(expectedPixel: GeneralPixel, for code: () -> Void) async throws { + let pixelExpectation = expectation(description: "Pixel fired") + try await verify(pixel: expectedPixel, for: code, expectation: pixelExpectation) { + await fulfillment(of: [pixelExpectation], timeout: 1.0) + } + } + + private func verifyNotFired(pixel: GeneralPixel, for code: () -> Void) async throws { + let pixelExpectation = expectation(description: "Pixel not fired") + try await verify(pixel: pixel, for: code, expectation: pixelExpectation) { + let result = await XCTWaiter().fulfillment(of: [pixelExpectation], timeout: 1) + + if result == .timedOut { + pixelExpectation.fulfill() + } else { + XCTFail("Pixel was fired") + } + } + } + + private func verify(pixel: GeneralPixel, + for code: () -> Void, + expectation: XCTestExpectation, + verification: () async -> Void) async throws { + let pixelKit = createPixelKit(pixelNamePrefix: pixel.name, pixelExpectation: expectation) + + PixelKit.setSharedForTesting(pixelKit: pixelKit) + + code() + await verification() + + cleanUp(pixelKit: pixelKit) + } + + private func createPixelKit(pixelNamePrefix: String, pixelExpectation: XCTestExpectation) -> PixelKit { + return PixelKit(dryRun: false, + appVersion: "1.0.0", + defaultHeaders: [:], + defaults: testUserDefault) { pixelName, _, _, _, _, _ in + if pixelName.hasPrefix(pixelNamePrefix) { + pixelExpectation.fulfill() + } + } + } + + private func cleanUp(pixelKit: PixelKit) { + PixelKit.tearDown() + pixelKit.clearFrequencyHistoryForAllPixels() + } +}