diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1f07e8bad7..71f46f9bd0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -268,7 +268,6 @@ 3706FADC293F65D500E42796 /* FirefoxLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */; }; 3706FADD293F65D500E42796 /* AtbParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50382726A12400758A2B /* AtbParser.swift */; }; 3706FADE293F65D500E42796 /* PreferencesDuckPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F19A6428E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift */; }; - 3706FADF293F65D500E42796 /* AddBookmarkFolderModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CA2667123700AD2C21 /* AddBookmarkFolderModalView.swift */; }; 3706FAE0293F65D500E42796 /* BookmarkSidebarTreeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B92929426670D2A00AD2C21 /* BookmarkSidebarTreeController.swift */; }; 3706FAE1293F65D500E42796 /* HomePageFavoritesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E8627BBB8F20038AD11 /* HomePageFavoritesModel.swift */; }; 3706FAE2293F65D500E42796 /* SequenceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */; }; @@ -387,7 +386,6 @@ 3706FB6D293F65D500E42796 /* SuggestionListCharacteristics.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB8203B26B2DE0D00788AC3 /* SuggestionListCharacteristics.swift */; }; 3706FB6F293F65D500E42796 /* BookmarkListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */; }; 3706FB70293F65D500E42796 /* SecureVaultLoginImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF326B0002B00E14D75 /* SecureVaultLoginImporter.swift */; }; - 3706FB71293F65D500E42796 /* AddBookmarkModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CB2667123700AD2C21 /* AddBookmarkModalView.swift */; }; 3706FB72293F65D500E42796 /* RecentlyClosedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C1DD4285C780C0089850C /* RecentlyClosedCoordinator.swift */; }; 3706FB74293F65D500E42796 /* FaviconHostReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6197C5276B3168008396F0 /* FaviconHostReference.swift */; }; 3706FB76293F65D500E42796 /* ASN1Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93A26B48ADF00879451 /* ASN1Parser.swift */; }; @@ -1279,8 +1277,6 @@ 4B9292CF2667123700AD2C21 /* BookmarkManagementSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292C72667123700AD2C21 /* BookmarkManagementSidebarViewController.swift */; }; 4B9292D02667123700AD2C21 /* BookmarkManagementSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292C82667123700AD2C21 /* BookmarkManagementSplitViewController.swift */; }; 4B9292D12667123700AD2C21 /* BookmarkTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */; }; - 4B9292D22667123700AD2C21 /* AddBookmarkFolderModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CA2667123700AD2C21 /* AddBookmarkFolderModalView.swift */; }; - 4B9292D32667123700AD2C21 /* AddBookmarkModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CB2667123700AD2C21 /* AddBookmarkModalView.swift */; }; 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */; }; 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CD2667123700AD2C21 /* BookmarkManagementDetailViewController.swift */; }; 4B9292D92667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */; }; @@ -1391,7 +1387,6 @@ 4B9579B52AC7AE700062CA31 /* FirefoxLoginReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8AC93826B48A5100879451 /* FirefoxLoginReader.swift */; }; 4B9579B62AC7AE700062CA31 /* AtbParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B69B50382726A12400758A2B /* AtbParser.swift */; }; 4B9579B72AC7AE700062CA31 /* PreferencesDuckPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F19A6428E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift */; }; - 4B9579B82AC7AE700062CA31 /* AddBookmarkFolderModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CA2667123700AD2C21 /* AddBookmarkFolderModalView.swift */; }; 4B9579B92AC7AE700062CA31 /* BookmarkSidebarTreeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B92929426670D2A00AD2C21 /* BookmarkSidebarTreeController.swift */; }; 4B9579BA2AC7AE700062CA31 /* HomePageFavoritesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85589E8627BBB8F20038AD11 /* HomePageFavoritesModel.swift */; }; 4B9579BB2AC7AE700062CA31 /* SequenceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */; }; @@ -1561,7 +1556,6 @@ 4B957A692AC7AE700062CA31 /* BookmarkListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */; }; 4B957A6A2AC7AE700062CA31 /* SecureVaultLoginImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723DF326B0002B00E14D75 /* SecureVaultLoginImporter.swift */; }; 4B957A6B2AC7AE700062CA31 /* WKProcessPoolExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */; }; - 4B957A6C2AC7AE700062CA31 /* AddBookmarkModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292CB2667123700AD2C21 /* AddBookmarkModalView.swift */; }; 4B957A6D2AC7AE700062CA31 /* LoginItemsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D9AE86A2AA76CF90026E7DC /* LoginItemsManager.swift */; }; 4B957A6E2AC7AE700062CA31 /* PixelExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857E5AF42A79045800FC0FB4 /* PixelExperiment.swift */; }; 4B957A6F2AC7AE700062CA31 /* DuckPlayerTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */; }; @@ -2417,14 +2411,76 @@ 9DB6E7242AA0DC5800A17F3C /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 9DB6E7232AA0DC5800A17F3C /* LoginItems */; }; 9DC70B1A2AA1FA5B005A844B /* LoginItems in Frameworks */ = {isa = PBXBuildFile; productRef = 9DC70B192AA1FA5B005A844B /* LoginItems */; }; 9DEF97E12B06C4EE00764F03 /* Networking in Frameworks */ = {isa = PBXBuildFile; productRef = 9DEF97E02B06C4EE00764F03 /* Networking */; }; + 9F0A2CF82B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */; }; + 9F0A2CF92B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */; }; 9F180D0F2B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */; }; 9F180D102B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */; }; 9F180D122B69C665000D695F /* DownloadsTabExtensionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D112B69C665000D695F /* DownloadsTabExtensionMock.swift */; }; 9F180D132B69C665000D695F /* DownloadsTabExtensionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F180D112B69C665000D695F /* DownloadsTabExtensionMock.swift */; }; + 9F26060B2B85C20A00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */; }; + 9F26060C2B85C20B00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */; }; + 9F26060E2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */; }; + 9F26060F2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */; }; 9F3910622B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3910612B68C35600CB5112 /* DownloadsTabExtensionTests.swift */; }; 9F3910632B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3910612B68C35600CB5112 /* DownloadsTabExtensionTests.swift */; }; 9F3910692B68D87B00CB5112 /* ProgressExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3910682B68D87B00CB5112 /* ProgressExtensionTests.swift */; }; 9F39106A2B68D87B00CB5112 /* ProgressExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3910682B68D87B00CB5112 /* ProgressExtensionTests.swift */; }; + 9F514F912B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F514F902B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift */; }; + 9F514F922B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F514F902B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift */; }; + 9F514F932B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F514F902B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift */; }; + 9F56CFA92B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */; }; + 9F56CFAA2B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */; }; + 9F56CFAB2B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */; }; + 9F56CFAD2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */; }; + 9F56CFAE2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */; }; + 9F56CFAF2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */; }; + 9F56CFB12B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */; }; + 9F56CFB22B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */; }; + 9F56CFB32B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */; }; + 9F872D982B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D972B8DA9F800138637 /* Bookmarks+Tab.swift */; }; + 9F872D992B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D972B8DA9F800138637 /* Bookmarks+Tab.swift */; }; + 9F872D9A2B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D972B8DA9F800138637 /* Bookmarks+Tab.swift */; }; + 9F872D9D2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D9C2B9058D000138637 /* Bookmarks+TabTests.swift */; }; + 9F872D9E2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D9C2B9058D000138637 /* Bookmarks+TabTests.swift */; }; + 9F872DA02B90644800138637 /* ContextualMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */; }; + 9F872DA12B90644800138637 /* ContextualMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */; }; + 9F872DA32B90920F00138637 /* BookmarkFolderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */; }; + 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */; }; + 9F872DA52B90920F00138637 /* BookmarkFolderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */; }; + 9F982F0D2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; + 9F982F0E2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; + 9F982F0F2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; + 9F982F132B822B7B00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */; }; + 9F982F142B822C7400231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */; }; + 9FA173DA2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */; }; + 9FA173DB2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */; }; + 9FA173DC2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */; }; + 9FA173DF2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */; }; + 9FA173E02B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */; }; + 9FA173E12B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */; }; + 9FA173E32B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */; }; + 9FA173E42B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */; }; + 9FA173E52B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */; }; + 9FA173E72B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */; }; + 9FA173E82B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */; }; + 9FA173E92B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */; }; + 9FA173EB2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */; }; + 9FA173EC2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */; }; + 9FA173ED2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */; }; + 9FA75A3E2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */; }; + 9FA75A3F2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */; }; + 9FDA6C212B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */; }; + 9FDA6C222B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */; }; + 9FDA6C232B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */; }; + 9FEE98652B846870002E44E8 /* AddEditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */; }; + 9FEE98662B846870002E44E8 /* AddEditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */; }; + 9FEE98672B846870002E44E8 /* AddEditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */; }; + 9FEE98692B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */; }; + 9FEE986A2B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */; }; + 9FEE986B2B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */; }; + 9FEE986D2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */; }; + 9FEE986E2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */; }; + 9FEE986F2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */; }; AA06B6B72672AF8100F541C5 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = AA06B6B62672AF8100F541C5 /* Sparkle */; }; AA0877B826D5160D00B05660 /* SafariVersionReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0877B726D5160D00B05660 /* SafariVersionReaderTests.swift */; }; AA0877BA26D5161D00B05660 /* WebKitVersionProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0877B926D5161D00B05660 /* WebKitVersionProviderTests.swift */; }; @@ -3052,15 +3108,9 @@ B6F92BA32A691583002ABA6B /* UserDefaultsWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */; }; B6F92BAC2A6937B3002ABA6B /* OptionalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */; }; B6F92BAD2A6937B5002ABA6B /* OptionalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */; }; - B6F9BDD82B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDD72B45B7D900677B33 /* AddBookmarkModalViewModel.swift */; }; - B6F9BDD92B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDD72B45B7D900677B33 /* AddBookmarkModalViewModel.swift */; }; - B6F9BDDA2B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDD72B45B7D900677B33 /* AddBookmarkModalViewModel.swift */; }; B6F9BDDC2B45B7EE00677B33 /* WebsiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */; }; B6F9BDDD2B45B7EE00677B33 /* WebsiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */; }; B6F9BDDE2B45B7EE00677B33 /* WebsiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */; }; - B6F9BDE02B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDF2B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift */; }; - B6F9BDE12B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDF2B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift */; }; - B6F9BDE22B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDDF2B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift */; }; B6F9BDE42B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; B6F9BDE62B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; @@ -3703,8 +3753,6 @@ 4B9292C72667123700AD2C21 /* BookmarkManagementSidebarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkManagementSidebarViewController.swift; sourceTree = ""; }; 4B9292C82667123700AD2C21 /* BookmarkManagementSplitViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkManagementSplitViewController.swift; sourceTree = ""; }; 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkTableRowView.swift; sourceTree = ""; }; - 4B9292CA2667123700AD2C21 /* AddBookmarkFolderModalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkFolderModalView.swift; sourceTree = ""; }; - 4B9292CB2667123700AD2C21 /* AddBookmarkModalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkModalView.swift; sourceTree = ""; }; 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkListViewController.swift; sourceTree = ""; }; 4B9292CD2667123700AD2C21 /* BookmarkManagementDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkManagementDetailViewController.swift; sourceTree = ""; }; 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkListTreeControllerDataSource.swift; sourceTree = ""; }; @@ -4004,10 +4052,33 @@ 9D9AE9282AAA43EB0026E7DC /* DataBrokerProtectionBackgroundManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionBackgroundManager.swift; sourceTree = ""; }; 9D9AE92B2AAB84FF0026E7DC /* DBPMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBPMocks.swift; sourceTree = ""; }; 9DB6E7222AA0DA7A00A17F3C /* LoginItems */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = LoginItems; sourceTree = ""; }; + 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseBookmarkEntityTests.swift; sourceTree = ""; }; 9F180D0E2B69C553000D695F /* Tab+WKUIDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tab+WKUIDelegateTests.swift"; sourceTree = ""; }; 9F180D112B69C665000D695F /* DownloadsTabExtensionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtensionMock.swift; sourceTree = ""; }; + 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogViewModelTests.swift; sourceTree = ""; }; + 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogCoordinatorViewModelTests.swift; sourceTree = ""; }; 9F3910612B68C35600CB5112 /* DownloadsTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtensionTests.swift; sourceTree = ""; }; 9F3910682B68D87B00CB5112 /* ProgressExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressExtensionTests.swift; sourceTree = ""; }; + 9F514F902B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogView.swift; sourceTree = ""; }; + 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderView.swift; sourceTree = ""; }; + 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogViewModel.swift; sourceTree = ""; }; + 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDialogViewFactory.swift; sourceTree = ""; }; + 9F872D972B8DA9F800138637 /* Bookmarks+Tab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmarks+Tab.swift"; sourceTree = ""; }; + 9F872D9C2B9058D000138637 /* Bookmarks+TabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmarks+TabTests.swift"; sourceTree = ""; }; + 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualMenuTests.swift; sourceTree = ""; }; + 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFolderInfo.swift; sourceTree = ""; }; + 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogViewModel.swift; sourceTree = ""; }; + 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkFolderDialogViewModelTests.swift; sourceTree = ""; }; + 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogContainerView.swift; sourceTree = ""; }; + 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogButtonsView.swift; sourceTree = ""; }; + 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogFolderManagementView.swift; sourceTree = ""; }; + 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDialogStackedContentView.swift; sourceTree = ""; }; + 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogView.swift; sourceTree = ""; }; + 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuFactoryTests.swift; sourceTree = ""; }; + 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFavoriteView.swift; sourceTree = ""; }; + 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkView.swift; sourceTree = ""; }; + 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDialogViewModel.swift; sourceTree = ""; }; + 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogCoordinatorViewModel.swift; sourceTree = ""; }; AA0877B726D5160D00B05660 /* SafariVersionReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariVersionReaderTests.swift; sourceTree = ""; }; AA0877B926D5161D00B05660 /* WebKitVersionProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebKitVersionProviderTests.swift; sourceTree = ""; }; AA0F3DB6261A566C0077F2D9 /* SuggestionLoadingMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionLoadingMock.swift; sourceTree = ""; }; @@ -4464,9 +4535,7 @@ B6F56566299A414300A04298 /* WKWebViewMockingExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKWebViewMockingExtension.swift; sourceTree = ""; }; B6F7127D29F6779000594A45 /* QRSharingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRSharingService.swift; sourceTree = ""; }; B6F7128029F681EB00594A45 /* QuickLookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookUI.framework; path = System/Library/Frameworks/QuickLookUI.framework; sourceTree = SDKROOT; }; - B6F9BDD72B45B7D900677B33 /* AddBookmarkModalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkModalViewModel.swift; sourceTree = ""; }; B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteInfo.swift; sourceTree = ""; }; - B6F9BDDF2B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkFolderModalViewModel.swift; sourceTree = ""; }; B6F9BDE32B45CD1900677B33 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; B6FA893C269C423100588ECD /* PrivacyDashboard.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PrivacyDashboard.storyboard; sourceTree = ""; }; B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; @@ -5851,6 +5920,7 @@ children = ( 4B9292AE26670F5300AD2C21 /* NSOutlineViewExtensions.swift */, B6C0BB6629AEFF8100AE8E3C /* BookmarkExtension.swift */, + 9F872D972B8DA9F800138637 /* Bookmarks+Tab.swift */, ); path = Extensions; sourceTree = ""; @@ -6674,6 +6744,49 @@ path = DuckDuckGoDBPBackgroundAgent; sourceTree = ""; }; + 9F872D9B2B9058B000138637 /* Extensions */ = { + isa = PBXGroup; + children = ( + 9F872D9C2B9058D000138637 /* Bookmarks+TabTests.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 9F982F102B82264400231028 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 9F982F112B82268F00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift */, + 9F2606092B85C20400819292 /* AddEditBookmarkDialogViewModelTests.swift */, + 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 9FA173DD2B7A0ECE00EE4E6E /* Dialog */ = { + isa = PBXGroup; + children = ( + 9FA173D92B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift */, + 9FA173E62B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift */, + 9FA173DE2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift */, + 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */, + 9FA173E22B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift */, + 9FA173EA2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift */, + 9F514F902B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift */, + 9F56CFA82B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift */, + 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */, + 9F56CFB02B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift */, + ); + path = Dialog; + sourceTree = ""; + }; + 9FA75A3C2BA00DF500DA5FA6 /* Factory */ = { + isa = PBXGroup; + children = ( + 9FA75A3D2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift */, + ); + path = Factory; + sourceTree = ""; + }; AA0877B626D515EE00B05660 /* UserAgent */ = { isa = PBXGroup; children = ( @@ -7021,6 +7134,9 @@ AA652CAB25DD820D009059CC /* Bookmarks */ = { isa = PBXGroup; children = ( + 9F872D9B2B9058B000138637 /* Extensions */, + 9FA75A3C2BA00DF500DA5FA6 /* Factory */, + 9F982F102B82264400231028 /* ViewModels */, AA652CAE25DD8228009059CC /* Model */, AA652CAF25DD822C009059CC /* Services */, ); @@ -7042,6 +7158,8 @@ AA652CCD25DD9071009059CC /* BookmarkListTests.swift */, AA652CD225DDA6E9009059CC /* LocalBookmarkManagerTests.swift */, 98A95D87299A2DF900B9B81A /* BookmarkMigrationTests.swift */, + 9F872D9F2B90644800138637 /* ContextualMenuTests.swift */, + 9F0A2CF72B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift */, ); path = Model; sourceTree = ""; @@ -7430,8 +7548,10 @@ B69A14F12B4D6FE800B9417D /* AddBookmarkFolderPopoverViewModel.swift */, B69A14F52B4D701F00B9417D /* AddBookmarkPopoverViewModel.swift */, AAB549DE25DAB8F80058460B /* BookmarkViewModel.swift */, - B6F9BDD72B45B7D900677B33 /* AddBookmarkModalViewModel.swift */, - B6F9BDDF2B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift */, + 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */, + 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */, + 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */, + 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -7502,9 +7622,8 @@ AAC5E4C125D6A6C3007F5990 /* View */ = { isa = PBXGroup; children = ( - 4B9292CA2667123700AD2C21 /* AddBookmarkFolderModalView.swift */, + 9FA173DD2B7A0ECE00EE4E6E /* Dialog */, 7BEC20412B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift */, - 4B9292CB2667123700AD2C21 /* AddBookmarkModalView.swift */, AAC5E4C425D6A6E8007F5990 /* AddBookmarkPopover.swift */, 7BEC20402B0F505F00243D3E /* AddBookmarkPopoverView.swift */, B69A14F92B4D705D00B9417D /* BookmarkFolderPicker.swift */, @@ -7542,6 +7661,7 @@ AAC5E4CE25D6A709007F5990 /* BookmarkManager.swift */, 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */, + 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, ); path = Model; sourceTree = ""; @@ -9778,6 +9898,7 @@ 4B41EDA82B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */, 3706FA7B293F65D500E42796 /* FaviconUserScript.swift in Sources */, 3706FA7E293F65D500E42796 /* LottieAnimationCache.swift in Sources */, + 9F982F0E2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */, 3706FA7F293F65D500E42796 /* TabIndex.swift in Sources */, 3706FA80293F65D500E42796 /* TabLazyLoaderDataSource.swift in Sources */, 3706FA81293F65D500E42796 /* LoginImport.swift in Sources */, @@ -9793,6 +9914,7 @@ 3706FA89293F65D500E42796 /* CrashReportPromptPresenter.swift in Sources */, 3706FA8B293F65D500E42796 /* PreferencesRootView.swift in Sources */, 3706FA8C293F65D500E42796 /* AppStateChangedPublisher.swift in Sources */, + 9FEE986E2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */, 3706FA8D293F65D500E42796 /* BookmarkTableCellView.swift in Sources */, 3706FA8E293F65D500E42796 /* BookmarkManagementSidebarViewController.swift in Sources */, 3706FA8F293F65D500E42796 /* NSStackViewExtension.swift in Sources */, @@ -9843,6 +9965,7 @@ 4B9DB0332A983B24000927DB /* EnableWaitlistFeatureView.swift in Sources */, 3706FABA293F65D500E42796 /* BookmarkOutlineViewDataSource.swift in Sources */, 3706FABB293F65D500E42796 /* PasswordManagementBitwardenItemView.swift in Sources */, + 9FA173E42B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */, 3706FABD293F65D500E42796 /* NSNotificationName+PasswordManager.swift in Sources */, 3706FABE293F65D500E42796 /* RulesCompilationMonitor.swift in Sources */, 3706FABF293F65D500E42796 /* CrashReportReader.swift in Sources */, @@ -9884,7 +10007,6 @@ 3706FADE293F65D500E42796 /* PreferencesDuckPlayerView.swift in Sources */, EEC4A65E2B277E8D00F7C0AA /* NetworkProtectionVPNCountryLabelsModel.swift in Sources */, B66260E729ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift in Sources */, - 3706FADF293F65D500E42796 /* AddBookmarkFolderModalView.swift in Sources */, 3706FAE0293F65D500E42796 /* BookmarkSidebarTreeController.swift in Sources */, 3706FAE1293F65D500E42796 /* HomePageFavoritesModel.swift in Sources */, 3706FAE2293F65D500E42796 /* SequenceExtensions.swift in Sources */, @@ -9904,6 +10026,7 @@ 3706FAF1293F65D500E42796 /* PreferencesAboutView.swift in Sources */, 3706FAF2293F65D500E42796 /* ContentBlocking.swift in Sources */, 31F2D2002AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, + 9FA173E02B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */, 3706FAF3293F65D500E42796 /* LocalAuthenticationService.swift in Sources */, 1D36E659298AA3BA00AA485D /* InternalUserDeciderStore.swift in Sources */, B6BCC5242AFCDABB002C5499 /* DataImportSourceViewModel.swift in Sources */, @@ -10045,7 +10168,6 @@ 3706FB5E293F65D500E42796 /* EncryptionKeyStore.swift in Sources */, 3706FB60293F65D500E42796 /* PasswordManagementIdentityItemView.swift in Sources */, 3706FB61293F65D500E42796 /* ProgressExtension.swift in Sources */, - B6F9BDD92B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */, 3706FB62293F65D500E42796 /* CSVParser.swift in Sources */, 3706FB64293F65D500E42796 /* PixelDataModel.xcdatamodeld in Sources */, B626A75B29921FAA00053070 /* NavigationActionPolicyExtension.swift in Sources */, @@ -10058,6 +10180,7 @@ 3706FB69293F65D500E42796 /* NavigationBarBadgeAnimationView.swift in Sources */, 1D1A334A2A6FEB170080ACED /* BurnerMode.swift in Sources */, B603971B29BA084C00902A34 /* JSAlertController.swift in Sources */, + 9FEE986A2B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */, 3706FB6A293F65D500E42796 /* AddressBarButton.swift in Sources */, 4B41EDA42B1543B9001EEDF4 /* VPNPreferencesModel.swift in Sources */, 3706FB6C293F65D500E42796 /* FaviconStore.swift in Sources */, @@ -10066,7 +10189,6 @@ 4B4D60C62A0C849600BCD287 /* NetworkProtectionInviteCodeViewModel.swift in Sources */, 3706FB6F293F65D500E42796 /* BookmarkListViewController.swift in Sources */, 3706FB70293F65D500E42796 /* SecureVaultLoginImporter.swift in Sources */, - 3706FB71293F65D500E42796 /* AddBookmarkModalView.swift in Sources */, 3706FB72293F65D500E42796 /* RecentlyClosedCoordinator.swift in Sources */, 3706FB74293F65D500E42796 /* FaviconHostReference.swift in Sources */, B69A14F32B4D6FE800B9417D /* AddBookmarkFolderPopoverViewModel.swift in Sources */, @@ -10075,6 +10197,7 @@ 37CBCA9B2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */, 3707C72A294B5D2900682A9F /* URLExtension.swift in Sources */, 3706FB76293F65D500E42796 /* ASN1Parser.swift in Sources */, + 9F56CFAA2B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */, 37FD78122A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */, 987799F42999993C005D8EB6 /* LegacyBookmarksStoreMigration.swift in Sources */, 3706FB7A293F65D500E42796 /* FileDownloadManager.swift in Sources */, @@ -10134,7 +10257,6 @@ 3706FB97293F65D500E42796 /* ActionSpeech.swift in Sources */, 3706FB99293F65D500E42796 /* PrivacySecurityPreferences.swift in Sources */, B6AFE6BD29A5D621002FF962 /* HTTPSUpgradeTabExtension.swift in Sources */, - B6F9BDE12B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */, 3706FB9A293F65D500E42796 /* FireproofDomainsStore.swift in Sources */, 3706FB9B293F65D500E42796 /* PrivacyDashboardPermissionHandler.swift in Sources */, 3706FB9C293F65D500E42796 /* TabCollectionViewModel.swift in Sources */, @@ -10147,6 +10269,7 @@ 3706FB9E293F65D500E42796 /* AboutModel.swift in Sources */, 3706FB9F293F65D500E42796 /* PasswordManagementCreditCardItemView.swift in Sources */, 3706FBA0293F65D500E42796 /* NSTextFieldExtension.swift in Sources */, + 9FA173E82B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, 3706FBA1293F65D500E42796 /* FireproofDomainsContainer.swift in Sources */, 3706FBA2293F65D500E42796 /* GeolocationService.swift in Sources */, 4B4D60C42A0C849600BCD287 /* NetworkProtectionInvitePresenter.swift in Sources */, @@ -10222,6 +10345,7 @@ 3706FBD9293F65D500E42796 /* NSAppearanceExtension.swift in Sources */, 3706FBDA293F65D500E42796 /* PermissionManager.swift in Sources */, 3706FBDB293F65D500E42796 /* DefaultBrowserPreferences.swift in Sources */, + 9FEE98662B846870002E44E8 /* AddEditBookmarkView.swift in Sources */, 3706FBDC293F65D500E42796 /* Permissions.xcdatamodeld in Sources */, 4B41EDB52B168C55001EEDF4 /* VPNFeedbackFormViewModel.swift in Sources */, 3706FBDD293F65D500E42796 /* PaddedImageButton.swift in Sources */, @@ -10236,6 +10360,7 @@ EEC4A6722B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 3706FBE0293F65D500E42796 /* NSException+Catch.m in Sources */, 3706FBE1293F65D500E42796 /* AppStateRestorationManager.swift in Sources */, + 9FA173EC2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */, 3706FBE2293F65D500E42796 /* ClickToLoadUserScript.swift in Sources */, 3706FBE3293F65D500E42796 /* WindowControllersManager.swift in Sources */, 37197EAA2942443D00394917 /* ModalSheetCancellable.swift in Sources */, @@ -10257,6 +10382,7 @@ 3706FBF0293F65D500E42796 /* PasswordManagementItemModel.swift in Sources */, 3706FBF2293F65D500E42796 /* FindInPageModel.swift in Sources */, 1D9A4E5B2B43213B00F449E2 /* TabSnapshotExtension.swift in Sources */, + 9F56CFAE2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */, 3706FBF3293F65D500E42796 /* PseudoFolder.swift in Sources */, 1D26EBAD2B74BECB0002A93F /* NSImageSendable.swift in Sources */, 3706FBF5293F65D500E42796 /* PixelDataStore.swift in Sources */, @@ -10278,6 +10404,7 @@ 3706FC01293F65D500E42796 /* ChromiumBookmarksReader.swift in Sources */, 3706FC02293F65D500E42796 /* Downloads.xcdatamodeld in Sources */, B60C6F7829B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */, + 9F56CFB22B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */, 3707C720294B5D2900682A9F /* WKWebsiteDataStoreExtension.swift in Sources */, 3706FC03293F65D500E42796 /* TabPreviewViewController.swift in Sources */, 4B9754EC2984300100D7B834 /* EmailManagerExtension.swift in Sources */, @@ -10301,6 +10428,7 @@ 3706FC0C293F65D500E42796 /* NSAttributedStringExtension.swift in Sources */, C1DAF3B62B9A44860059244F /* AutofillPopoverPresenter.swift in Sources */, 3706FC0D293F65D500E42796 /* AnimationView.swift in Sources */, + 9FA173DB2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */, 3706FC0E293F65D500E42796 /* NSRectExtension.swift in Sources */, 3706FC0F293F65D500E42796 /* YoutubeOverlayUserScript.swift in Sources */, 3775913729AB9A1C00E26367 /* SyncManagementDialogViewController.swift in Sources */, @@ -10385,12 +10513,14 @@ 3706FC50293F65D500E42796 /* FeedbackWindow.swift in Sources */, 3706FC51293F65D500E42796 /* RecentlyVisitedView.swift in Sources */, B645D8F729FA95440024461F /* WKProcessPoolExtension.swift in Sources */, + 9F514F922B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */, 3706FC52293F65D500E42796 /* MouseOverAnimationButton.swift in Sources */, B60293E72BA19ECD0033186B /* NetPPopoverManagerMock.swift in Sources */, 3706FC53293F65D500E42796 /* TabBarScrollView.swift in Sources */, 3706FC54293F65D500E42796 /* BookmarkListTreeControllerDataSource.swift in Sources */, 3706FC55293F65D500E42796 /* AddressBarViewController.swift in Sources */, 3706FC56293F65D500E42796 /* Permissions.swift in Sources */, + 9F872D992B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */, B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 3706FC57293F65D500E42796 /* TabPreviewWindowController.swift in Sources */, 3706FC58293F65D500E42796 /* NSSizeExtension.swift in Sources */, @@ -10415,6 +10545,7 @@ 3706FC65293F65D500E42796 /* HomePageViewController.swift in Sources */, 3706FC67293F65D500E42796 /* OperatingSystemVersionExtension.swift in Sources */, B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */, + 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, 3706FC68293F65D500E42796 /* ToggleableScrollView.swift in Sources */, 3706FC69293F65D500E42796 /* UserScripts.swift in Sources */, @@ -10432,6 +10563,7 @@ 3706FC73293F65D500E42796 /* AddressBarButtonsViewController.swift in Sources */, 3706FC76293F65D500E42796 /* PixelDataRecord.swift in Sources */, 7BFE955A2A9DF4550081ABE9 /* NetworkProtectionWaitlistFeatureFlagOverridesMenu.swift in Sources */, + 9FDA6C222B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, 4BF0E5132AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, 3706FC78293F65D500E42796 /* SecureVaultErrorReporter.swift in Sources */, @@ -10508,6 +10640,7 @@ 3706FDDE293F661700E42796 /* SuggestionViewModelTests.swift in Sources */, 3706FDDF293F661700E42796 /* BookmarkSidebarTreeControllerTests.swift in Sources */, 3706FDE0293F661700E42796 /* TabIndexTests.swift in Sources */, + 9F26060F2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift in Sources */, 3706FDE1293F661700E42796 /* AdjacentItemEnumeratorTests.swift in Sources */, 3706FDE2293F661700E42796 /* PixelArgumentsTests.swift in Sources */, 4B9DB0572A983B55000927DB /* MockNotificationService.swift in Sources */, @@ -10536,6 +10669,7 @@ 3706FDF8293F661700E42796 /* FileStoreTests.swift in Sources */, 5603D90729B7B746007F9F01 /* MockTabViewItemDelegate.swift in Sources */, 3706FDF9293F661700E42796 /* TabViewModelTests.swift in Sources */, + 9F872DA12B90644800138637 /* ContextualMenuTests.swift in Sources */, 3706FDFA293F661700E42796 /* DefaultBrowserPreferencesTests.swift in Sources */, 3706FDFB293F661700E42796 /* DispatchQueueExtensionsTests.swift in Sources */, 9F180D102B69C553000D695F /* Tab+WKUIDelegateTests.swift in Sources */, @@ -10599,6 +10733,7 @@ 3706FE26293F661700E42796 /* TemporaryFileCreator.swift in Sources */, 3706FE27293F661700E42796 /* AppPrivacyConfigurationTests.swift in Sources */, B626A7652992506A00053070 /* SerpHeadersNavigationResponderTests.swift in Sources */, + 9F26060C2B85C20B00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */, 3706FE28293F661700E42796 /* BookmarkTests.swift in Sources */, 3706FE29293F661700E42796 /* SuggestionContainerViewModelTests.swift in Sources */, 1D8C2FEB2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, @@ -10652,6 +10787,7 @@ 3706FE4A293F661700E42796 /* BookmarkManagedObjectTests.swift in Sources */, EEC8EB402982CD550065AA39 /* JSAlertViewModelTests.swift in Sources */, 3706FE4B293F661700E42796 /* BookmarksHTMLImporterTests.swift in Sources */, + 9FA75A3F2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */, 56D145E929E6BB6300E3488A /* CapturingDataImportProvider.swift in Sources */, 3706FE4C293F661700E42796 /* CSVParserTests.swift in Sources */, 3706FE4D293F661700E42796 /* OnboardingTests.swift in Sources */, @@ -10665,6 +10801,7 @@ 3706FE54293F661700E42796 /* PasteboardBookmarkTests.swift in Sources */, 3706FE55293F661700E42796 /* CBRCompileTimeReporterTests.swift in Sources */, 566B196429CDB824007E38F4 /* MoreOptionsMenuTests.swift in Sources */, + 9F0A2CF92B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */, 3706FE56293F661700E42796 /* FaviconManagerMock.swift in Sources */, 3706FE57293F661700E42796 /* LocalPinningManagerTests.swift in Sources */, 3706FE58293F661700E42796 /* HistoryStoreTests.swift in Sources */, @@ -10688,6 +10825,7 @@ 3706FE64293F661700E42796 /* DownloadListStoreTests.swift in Sources */, 3706FE65293F661700E42796 /* ContentBlockingUpdatingTests.swift in Sources */, 3706FE67293F661700E42796 /* EncryptionMocks.swift in Sources */, + 9F872D9E2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */, 3706FE68293F661700E42796 /* DuckPlayerURLExtensionTests.swift in Sources */, 3706FE6A293F661700E42796 /* FirefoxKeyReaderTests.swift in Sources */, 3706FE6B293F661700E42796 /* AppKitPrivateMethodsAvailabilityTests.swift in Sources */, @@ -10719,6 +10857,7 @@ 3706FE7B293F661700E42796 /* HistoryStoringMock.swift in Sources */, 562984702AC4610100AC20EB /* SyncPreferencesTests.swift in Sources */, 3706FE7C293F661700E42796 /* LocalBookmarkStoreTests.swift in Sources */, + 9F982F142B822C7400231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */, B6CA4825298CE4B70067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */, 3707C72D294B5D4100682A9F /* EmptyAttributionRulesProver.swift in Sources */, 376E2D2629428353001CD31B /* PrivacyReferenceTestHelper.swift in Sources */, @@ -10968,6 +11107,7 @@ 4B9579552AC7AE700062CA31 /* Logging.swift in Sources */, 4B9579562AC7AE700062CA31 /* CrashReportPromptPresenter.swift in Sources */, B6B4D1CD2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, + 9FA173ED2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */, 4B9579572AC7AE700062CA31 /* BWCredential.swift in Sources */, 4B9579582AC7AE700062CA31 /* PreferencesRootView.swift in Sources */, 4B9579592AC7AE700062CA31 /* AppStateChangedPublisher.swift in Sources */, @@ -10977,6 +11117,7 @@ 4B95795D2AC7AE700062CA31 /* OptionalExtension.swift in Sources */, 4B95795E2AC7AE700062CA31 /* PasswordManagementLoginItemView.swift in Sources */, 4B95795F2AC7AE700062CA31 /* UserText.swift in Sources */, + 9F872D9A2B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */, 4B9579602AC7AE700062CA31 /* WKWebView+Download.swift in Sources */, 4B9579612AC7AE700062CA31 /* TabShadowConfig.swift in Sources */, 4B9579622AC7AE700062CA31 /* URLSessionExtension.swift in Sources */, @@ -10997,6 +11138,8 @@ 4B95796E2AC7AE700062CA31 /* LegacyBookmarkStore.swift in Sources */, 4B95796F2AC7AE700062CA31 /* NSAlert+DataImport.swift in Sources */, 4B9579702AC7AE700062CA31 /* MainWindow.swift in Sources */, + 9F872DA52B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, + 9FEE986B2B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */, 4B9579712AC7AE700062CA31 /* CrashReportPromptViewController.swift in Sources */, 4B9579722AC7AE700062CA31 /* BookmarksCleanupErrorHandling.swift in Sources */, 4B9579732AC7AE700062CA31 /* ContextMenuManager.swift in Sources */, @@ -11009,6 +11152,7 @@ 4B9579792AC7AE700062CA31 /* BWRequest.swift in Sources */, 4B95797A2AC7AE700062CA31 /* WKWebViewConfigurationExtensions.swift in Sources */, 4B95797B2AC7AE700062CA31 /* HomePageDefaultBrowserModel.swift in Sources */, + 9F514F932B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */, 4B95797C2AC7AE700062CA31 /* CrashReporter.swift in Sources */, 4B95797D2AC7AE700062CA31 /* AddressBarTextSelectionNavigation.swift in Sources */, 4B37EE7D2B4CFF8300A89A61 /* SurveyURLBuilder.swift in Sources */, @@ -11069,7 +11213,6 @@ 4B9579B52AC7AE700062CA31 /* FirefoxLoginReader.swift in Sources */, 4B9579B62AC7AE700062CA31 /* AtbParser.swift in Sources */, 4B9579B72AC7AE700062CA31 /* PreferencesDuckPlayerView.swift in Sources */, - 4B9579B82AC7AE700062CA31 /* AddBookmarkFolderModalView.swift in Sources */, 4B41EDB62B169883001EEDF4 /* VPNFeedbackFormViewController.swift in Sources */, 4B9579B92AC7AE700062CA31 /* BookmarkSidebarTreeController.swift in Sources */, 4B9579BA2AC7AE700062CA31 /* HomePageFavoritesModel.swift in Sources */, @@ -11123,6 +11266,7 @@ 4B9579E42AC7AE700062CA31 /* PopUpWindow.swift in Sources */, 4B9579E52AC7AE700062CA31 /* Favicons.xcdatamodeld in Sources */, 4B9579E62AC7AE700062CA31 /* Publisher.asVoid.swift in Sources */, + 9FEE986F2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */, 4B9579E72AC7AE700062CA31 /* Waitlist.swift in Sources */, 3158B1582B0BF76000AF130C /* DataBrokerProtectionFeatureVisibility.swift in Sources */, 4B9579E82AC7AE700062CA31 /* NavigationButtonMenuDelegate.swift in Sources */, @@ -11186,6 +11330,7 @@ 4B957A202AC7AE700062CA31 /* CancellableExtension.swift in Sources */, 4B957A212AC7AE700062CA31 /* PinnedTabsHostingView.swift in Sources */, 4B957A222AC7AE700062CA31 /* FirefoxBookmarksReader.swift in Sources */, + 9F982F0F2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */, 4B0526622B1D55320054955A /* VPNFeedbackSender.swift in Sources */, 4B957A232AC7AE700062CA31 /* DeviceIdleStateDetector.swift in Sources */, 85D0327D2B8E3D090041D1FB /* HistoryCoordinatorExtension.swift in Sources */, @@ -11209,6 +11354,7 @@ 4B957A332AC7AE700062CA31 /* Favicon.swift in Sources */, 1E2AE4CA2ACB21A000684E0A /* NetworkProtectionRemoteMessage.swift in Sources */, 4B957A342AC7AE700062CA31 /* SuggestionContainerViewModel.swift in Sources */, + 9F56CFAF2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */, 4B957A352AC7AE700062CA31 /* FirePopoverWrapperViewController.swift in Sources */, 4B957A362AC7AE700062CA31 /* NSPasteboardItemExtension.swift in Sources */, 4B957A372AC7AE700062CA31 /* AutofillPreferencesModel.swift in Sources */, @@ -11246,6 +11392,7 @@ 4B957A542AC7AE700062CA31 /* VisitMenuItem.swift in Sources */, 4B957A552AC7AE700062CA31 /* EncryptionKeyStore.swift in Sources */, 4B957A562AC7AE700062CA31 /* TabExtensionsBuilder.swift in Sources */, + 9F56CFB32B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */, 1E2AE4C82ACB216B00684E0A /* HoverTrackingArea.swift in Sources */, 4B957A582AC7AE700062CA31 /* PasswordManagementIdentityItemView.swift in Sources */, 4B957A592AC7AE700062CA31 /* ProgressExtension.swift in Sources */, @@ -11270,7 +11417,6 @@ 4B957A692AC7AE700062CA31 /* BookmarkListViewController.swift in Sources */, 4B957A6A2AC7AE700062CA31 /* SecureVaultLoginImporter.swift in Sources */, 4B957A6B2AC7AE700062CA31 /* WKProcessPoolExtension.swift in Sources */, - 4B957A6C2AC7AE700062CA31 /* AddBookmarkModalView.swift in Sources */, 4B957A6D2AC7AE700062CA31 /* LoginItemsManager.swift in Sources */, 4B957A6E2AC7AE700062CA31 /* PixelExperiment.swift in Sources */, 4B957A6F2AC7AE700062CA31 /* DuckPlayerTabExtension.swift in Sources */, @@ -11359,6 +11505,7 @@ 4B957AB62AC7AE700062CA31 /* FireproofDomains.xcdatamodeld in Sources */, 3158B14F2B0BF74F00AF130C /* DataBrokerProtectionManager.swift in Sources */, 4B957AB82AC7AE700062CA31 /* HomePageView.swift in Sources */, + 9FEE98672B846870002E44E8 /* AddEditBookmarkView.swift in Sources */, 4B957AB92AC7AE700062CA31 /* SerpHeadersNavigationResponder.swift in Sources */, 4B957ABA2AC7AE700062CA31 /* HomePageContinueSetUpModel.swift in Sources */, 4B957ABB2AC7AE700062CA31 /* WebKitDownloadTask.swift in Sources */, @@ -11375,12 +11522,12 @@ 4B957AC42AC7AE700062CA31 /* BWVault.swift in Sources */, 4B957AC52AC7AE700062CA31 /* NSViewExtension.swift in Sources */, BBDFDC5C2B2B8D7000F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift in Sources */, + 9FA173E52B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */, 4B957AC72AC7AE700062CA31 /* DownloadListViewModel.swift in Sources */, 4B957AC82AC7AE700062CA31 /* BookmarkManagementDetailViewController.swift in Sources */, 4B957AC92AC7AE700062CA31 /* CSVImporter.swift in Sources */, 4B957ACA2AC7AE700062CA31 /* StartupPreferences.swift in Sources */, 4B957ACB2AC7AE700062CA31 /* UserDefaults+NetworkProtectionWaitlist.swift in Sources */, - B6F9BDE22B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */, 4B957ACC2AC7AE700062CA31 /* MainMenu.swift in Sources */, 4B957ACE2AC7AE700062CA31 /* BrowserTabViewController.swift in Sources */, 4B957ACF2AC7AE700062CA31 /* CallToAction.swift in Sources */, @@ -11395,6 +11542,7 @@ 4B957AD72AC7AE700062CA31 /* CustomRoundedCornersShape.swift in Sources */, 4B957AD82AC7AE700062CA31 /* LocaleExtension.swift in Sources */, 4B957AD92AC7AE700062CA31 /* SavePaymentMethodViewController.swift in Sources */, + 9FA173E92B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, 4B957ADA2AC7AE700062CA31 /* BWStatus.swift in Sources */, 4B957ADB2AC7AE700062CA31 /* WebKitVersionProvider.swift in Sources */, B6BCC54D2AFDF24B002C5499 /* TaskWithProgress.swift in Sources */, @@ -11422,6 +11570,7 @@ 4B957AF02AC7AE700062CA31 /* NSException+Catch.m in Sources */, 4B957AF12AC7AE700062CA31 /* AppStateRestorationManager.swift in Sources */, 4B957AF22AC7AE700062CA31 /* DailyPixel.swift in Sources */, + 9FDA6C232B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, 4B957AF32AC7AE700062CA31 /* NavigationHotkeyHandler.swift in Sources */, 1EA7B8DA2B7E1283000330A4 /* SubscriptionFeatureAvailability.swift in Sources */, 4B957AF42AC7AE700062CA31 /* ClickToLoadUserScript.swift in Sources */, @@ -11462,7 +11611,6 @@ B66CA4212AD910B300447CF0 /* DataImportView.swift in Sources */, 4B957B162AC7AE700062CA31 /* Downloads.xcdatamodeld in Sources */, 4B957B172AC7AE700062CA31 /* TabPreviewViewController.swift in Sources */, - B6F9BDDA2B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */, 4B957B182AC7AE700062CA31 /* PreferencesPrivacyView.swift in Sources */, 4B957B192AC7AE700062CA31 /* NSPasteboardExtension.swift in Sources */, 4B957B1A2AC7AE700062CA31 /* OnboardingViewModel.swift in Sources */, @@ -11548,6 +11696,7 @@ 4B957B612AC7AE700062CA31 /* HomePage.swift in Sources */, 4B957B622AC7AE700062CA31 /* RoundedSelectionRowView.swift in Sources */, B6A22B652B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, + 9FA173E12B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */, 4B957B632AC7AE700062CA31 /* LocalStatisticsStore.swift in Sources */, 4B957B642AC7AE700062CA31 /* BackForwardListItem.swift in Sources */, 4B957B672AC7AE700062CA31 /* AtbAndVariantCleanup.swift in Sources */, @@ -11623,6 +11772,8 @@ 4B957BA12AC7AE700062CA31 /* UserDefaults+NetworkProtectionShared.swift in Sources */, 4B957BA22AC7AE700062CA31 /* NavigationActionPolicyExtension.swift in Sources */, 4B957BA32AC7AE700062CA31 /* CIImageExtension.swift in Sources */, + 9F56CFAB2B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */, + 9FA173DC2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */, 4B957BA42AC7AE700062CA31 /* NSMenuExtension.swift in Sources */, 4B957BA52AC7AE700062CA31 /* MainWindowController.swift in Sources */, 4B957BA62AC7AE700062CA31 /* Tab.swift in Sources */, @@ -11783,7 +11934,6 @@ 1D1A33492A6FEB170080ACED /* BurnerMode.swift in Sources */, 14505A08256084EF00272CC6 /* UserAgent.swift in Sources */, 987799F12999993C005D8EB6 /* LegacyBookmarkStore.swift in Sources */, - B6F9BDE02B45C1A800677B33 /* AddBookmarkFolderModalViewModel.swift in Sources */, 4B8AC93526B3B2FD00879451 /* NSAlert+DataImport.swift in Sources */, AA7412BD24D2BEEE00D22FE0 /* MainWindow.swift in Sources */, AAD6D8882696DF6D002393B3 /* CrashReportPromptViewController.swift in Sources */, @@ -11862,7 +12012,6 @@ 4B8AC93926B48A5100879451 /* FirefoxLoginReader.swift in Sources */, B69B503E2726A12500758A2B /* AtbParser.swift in Sources */, 37F19A6528E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift in Sources */, - 4B9292D22667123700AD2C21 /* AddBookmarkFolderModalView.swift in Sources */, 4B92929E26670D2A00AD2C21 /* BookmarkSidebarTreeController.swift in Sources */, EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 85589E8727BBB8F20038AD11 /* HomePageFavoritesModel.swift in Sources */, @@ -11871,6 +12020,7 @@ 4B59024026B35F3600489384 /* ChromiumDataImporter.swift in Sources */, B62B48562ADE730D000DECE5 /* FileImportView.swift in Sources */, AAA0CC3C25337FAB0079BC96 /* BackForwardListItemViewModel.swift in Sources */, + 9F982F0D2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */, 1D43EB3429297D760065E5D6 /* BWNotRespondingAlert.swift in Sources */, 4BB88B4525B7B55C006F6B06 /* DebugUserScript.swift in Sources */, AAC6881928626BF800D54247 /* RecentlyClosedTab.swift in Sources */, @@ -11897,6 +12047,7 @@ AA92127725ADA07900600CD4 /* WKWebViewExtension.swift in Sources */, AAAB9114288EB1D600A057A9 /* CleanThisHistoryMenuItem.swift in Sources */, B6C0B23626E732000031CB7F /* DownloadListItem.swift in Sources */, + 9F872DA32B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, 4B9DB0232A983B24000927DB /* WaitlistRequest.swift in Sources */, B6B1E87E26D5DA0E0062C350 /* DownloadsPopover.swift in Sources */, 85774AFF2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift in Sources */, @@ -11968,6 +12119,7 @@ 85C6A29625CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift in Sources */, 85625998269C9C5F00EE44BC /* PasswordManagementPopover.swift in Sources */, 1DDF076328F815AD00EDFBE3 /* BWCommunicator.swift in Sources */, + 9FEE98652B846870002E44E8 /* AddEditBookmarkView.swift in Sources */, 85589E9127BFB9810038AD11 /* HomePageRecentlyVisitedModel.swift in Sources */, 85012B0229133F9F003D0DCC /* NavigationBarPopovers.swift in Sources */, B626A7602992407D00053070 /* CancellableExtension.swift in Sources */, @@ -12053,9 +12205,9 @@ 4B6785472AA8DE68008A5004 /* NetworkProtectionFeatureDisabler.swift in Sources */, 4B0526642B1D55D80054955A /* VPNFeedbackCategory.swift in Sources */, 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */, + 9F56CFB12B843F6C00BB7F11 /* BookmarksDialogViewFactory.swift in Sources */, 4B723E0D26B0006100E14D75 /* SecureVaultLoginImporter.swift in Sources */, B645D8F629FA95440024461F /* WKProcessPoolExtension.swift in Sources */, - 4B9292D32667123700AD2C21 /* AddBookmarkModalView.swift in Sources */, 9D9AE86B2AA76CF90026E7DC /* LoginItemsManager.swift in Sources */, 857E5AF52A79045800FC0FB4 /* PixelExperiment.swift in Sources */, B6C416A7294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift in Sources */, @@ -12082,6 +12234,7 @@ AA9E9A5625A3AE8400D1959D /* NSWindowExtension.swift in Sources */, 7BD3AF5D2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift in Sources */, 370A34B12AB24E3700C77F7C /* SyncDebugMenu.swift in Sources */, + 9FDA6C212B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, AAC5E4C725D6A6E8007F5990 /* AddBookmarkPopover.swift in Sources */, 37CC53F027E8D1440028713D /* PreferencesDownloadsView.swift in Sources */, B6F7127E29F6779000594A45 /* QRSharingService.swift in Sources */, @@ -12090,6 +12243,7 @@ 3171D6BA288984D00068632A /* BadgeAnimationView.swift in Sources */, 1DB67F292B6FE4A6003DF243 /* WebViewSnapshotRenderer.swift in Sources */, 4B9292CE2667123700AD2C21 /* BrowserTabSelectionDelegate.swift in Sources */, + 9FEE986D2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift in Sources */, B6BCC53B2AFD15DF002C5499 /* DataImportProfilePicker.swift in Sources */, 3158B1562B0BF75D00AF130C /* DataBrokerProtectionFeatureVisibility.swift in Sources */, 56D6A3D629DB2BAB0055215A /* ContinueSetUpView.swift in Sources */, @@ -12142,7 +12296,6 @@ AA6EF9B525081B4C004754E6 /* MainMenuActions.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, 4B4D60C02A0C848D00BCD287 /* NetworkProtectionControllerErrorStore.swift in Sources */, - B6F9BDD82B45B7D900677B33 /* AddBookmarkModalViewModel.swift in Sources */, 4B723E1226B0006E00E14D75 /* DataImport.swift in Sources */, 7BE146072A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */, B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */, @@ -12155,6 +12308,7 @@ 1DB67F2D2B6FEFDB003DF243 /* ViewSnapshotRenderer.swift in Sources */, 4B44FEF32B1FEF5A000619D8 /* FocusableTextEditor.swift in Sources */, 4B59023E26B35F3600489384 /* ChromiumLoginReader.swift in Sources */, + 9F872D982B8DA9F800138637 /* Bookmarks+Tab.swift in Sources */, 85D885B326A5A9DE0077C374 /* NSAlert+PasswordManager.swift in Sources */, 983DFB2528B67036006B7E34 /* UserContentUpdating.swift in Sources */, 1D9A4E5A2B43213B00F449E2 /* TabSnapshotExtension.swift in Sources */, @@ -12188,6 +12342,7 @@ 4BE4005527CF3F19007D3161 /* SavePaymentMethodViewController.swift in Sources */, 1D2DC009290167A0008083A1 /* BWStatus.swift in Sources */, AAFE068326C7082D005434CC /* WebKitVersionProvider.swift in Sources */, + 9FEE98692B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */, B63D467A25BFC3E100874977 /* NSCoderExtensions.swift in Sources */, 1D2DC00B290167EC008083A1 /* RunningApplicationCheck.swift in Sources */, B6A5A27125B9377300AA7ADA /* StatePersistenceService.swift in Sources */, @@ -12197,6 +12352,7 @@ 4BB88B5B25B7BA50006F6B06 /* Instruments.swift in Sources */, 9812D895276CEDA5004B6181 /* ContentBlockerRulesLists.swift in Sources */, 4B0511E2262CAA8600F6079C /* NSViewControllerExtension.swift in Sources */, + 9FA173DF2B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */, F44C130225C2DA0400426E3E /* NSAppearanceExtension.swift in Sources */, 4B3B8490297A0E1000A384BD /* EmailManagerExtension.swift in Sources */, B64C84F1269310120048FEBE /* PermissionManager.swift in Sources */, @@ -12263,6 +12419,7 @@ 4BB99D0026FE191E001E4761 /* CoreDataBookmarkImporter.swift in Sources */, 4BCF15D72ABB8A110083F6DF /* NetworkProtectionRemoteMessaging.swift in Sources */, C168B9AC2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift in Sources */, + 9FA173E72B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, D64A5FF82AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 37A6A8F62AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, @@ -12311,6 +12468,7 @@ 37F19A6728E1B43200740DC6 /* DuckPlayerPreferences.swift in Sources */, B6C0B22E26E61CE70031CB7F /* DownloadViewModel.swift in Sources */, 4B41EDA72B1543C9001EEDF4 /* PreferencesVPNView.swift in Sources */, + 9FA173DA2B79BD8A00EE4E6E /* BookmarkDialogContainerView.swift in Sources */, 373A1AA8283ED1B900586521 /* BookmarkHTMLReader.swift in Sources */, B68458B825C7E8B200DC17B6 /* Tab+NSSecureCoding.swift in Sources */, 4B37EE612B4CFC3C00A89A61 /* SurveyURLBuilder.swift in Sources */, @@ -12358,10 +12516,12 @@ AAC5E4E425D6BA9C007F5990 /* NSSizeExtension.swift in Sources */, AA6820EB25503D6A005ED0D5 /* Fire.swift in Sources */, 3158B1492B0BF73000AF130C /* DBPHomeViewController.swift in Sources */, + 9F56CFA92B82DC4300BB7F11 /* AddEditBookmarkFolderView.swift in Sources */, 37445F9C2A1569F00029F789 /* SyncBookmarksAdapter.swift in Sources */, C1E961EF2B87AA29001760E1 /* AutofillActionBuilder.swift in Sources */, B6AAAC3E26048F690029438D /* RandomAccessCollectionExtension.swift in Sources */, 4B9292AF26670F5300AD2C21 /* NSOutlineViewExtensions.swift in Sources */, + 9F56CFAD2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift in Sources */, AA585D82248FD31100E9A3E2 /* AppDelegate.swift in Sources */, 7B1E81A027C8874900FF0E60 /* ContentOverlayViewController.swift in Sources */, C1DAF3B52B9A44860059244F /* AutofillPopoverPresenter.swift in Sources */, @@ -12373,6 +12533,7 @@ 4B4D60BF2A0C848A00BCD287 /* NetworkProtection+ConvenienceInitializers.swift in Sources */, 3158B1592B0BF76400AF130C /* DataBrokerProtectionFeatureDisabler.swift in Sources */, B655124829A79465009BFE1C /* NavigationActionExtension.swift in Sources */, + 9FA173EB2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */, 85308E25267FC9F2001ABD76 /* NSAlertExtension.swift in Sources */, B69A14F62B4D701F00B9417D /* AddBookmarkPopoverViewModel.swift in Sources */, 4B59024826B3673600489384 /* ThirdPartyBrowser.swift in Sources */, @@ -12454,6 +12615,8 @@ AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */, 4BA1A6C2258B0A1300F6F690 /* ContiguousBytesExtension.swift in Sources */, B6A22B622B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, + 9F514F912B7D88AD001832A9 /* AddEditBookmarkFolderDialogView.swift in Sources */, + 9FA173E32B7A12B600EE4E6E /* BookmarkDialogFolderManagementView.swift in Sources */, 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, 1EA7B8D82B7E1283000330A4 /* SubscriptionFeatureAvailability.swift in Sources */, 987799F62999996B005D8EB6 /* BookmarkDatabase.swift in Sources */, @@ -12492,6 +12655,7 @@ B6619F062B17138D00CD9186 /* DataImportSourceViewModelTests.swift in Sources */, 4BBF0917282DD6EF00EE1418 /* TemporaryFileHandlerTests.swift in Sources */, B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */, + 9F982F132B822B7B00231028 /* AddEditBookmarkFolderDialogViewModelTests.swift in Sources */, B630E7FE29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, 4B9292BB2667103100AD2C21 /* BookmarkNodeTests.swift in Sources */, 4B0219A825E0646500ED7DEA /* WebsiteDataStoreTests.swift in Sources */, @@ -12502,6 +12666,7 @@ B6656E122B29E3BE008798A1 /* DownloadListStoreMock.swift in Sources */, 37D23780287EFEE200BCE03B /* PinnedTabsManagerTests.swift in Sources */, AA0877BA26D5161D00B05660 /* WebKitVersionProviderTests.swift in Sources */, + 9FA75A3E2BA00E1400DA5FA6 /* BookmarksBarMenuFactoryTests.swift in Sources */, B69B50462726C5C200758A2B /* AtbAndVariantCleanupTests.swift in Sources */, 567DA94529E95C3F008AC5EE /* YoutubeOverlayUserScriptTests.swift in Sources */, 4B59024C26B38BB800489384 /* ChromiumLoginReaderTests.swift in Sources */, @@ -12535,6 +12700,7 @@ 4B8AD0B127A86D9200AE44D6 /* WKWebsiteDataStoreExtensionTests.swift in Sources */, B69B50472726C5C200758A2B /* VariantManagerTests.swift in Sources */, 8546DE6225C03056000CA5E1 /* UserAgentTests.swift in Sources */, + 9F26060B2B85C20A00819292 /* AddEditBookmarkDialogViewModelTests.swift in Sources */, B63ED0DE26AFD9A300A9DAD1 /* AVCaptureDeviceMock.swift in Sources */, 98A95D88299A2DF900B9B81A /* BookmarkMigrationTests.swift in Sources */, B63ED0E026AFE32F00A9DAD1 /* GeolocationProviderMock.swift in Sources */, @@ -12562,12 +12728,14 @@ B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */, 37CD54BB27F25A4000F1F7B9 /* DownloadsPreferencesTests.swift in Sources */, 4BE344EE2B2376DF003FC223 /* VPNFeedbackFormViewModelTests.swift in Sources */, + 9F872D9D2B9058D000138637 /* Bookmarks+TabTests.swift in Sources */, 9F3910622B68C35600CB5112 /* DownloadsTabExtensionTests.swift in Sources */, 4B9DB0562A983B55000927DB /* MockNotificationService.swift in Sources */, 4B02199C25E063DE00ED7DEA /* FireproofDomainsTests.swift in Sources */, AA0F3DB7261A566C0077F2D9 /* SuggestionLoadingMock.swift in Sources */, B60C6F8129B1B4AD007BFAA8 /* TestRunHelper.swift in Sources */, 4B9DB0582A983B55000927DB /* MockNetworkProtectionCodeRedeemer.swift in Sources */, + 9F872DA02B90644800138637 /* ContextualMenuTests.swift in Sources */, 4B9292BE2667103100AD2C21 /* PasteboardFolderTests.swift in Sources */, 4B9292C52667104B00AD2C21 /* CoreDataTestUtilities.swift in Sources */, 4B723E1926B000DC00E14D75 /* TemporaryFileCreator.swift in Sources */, @@ -12589,6 +12757,7 @@ B63ED0DC26AE7B1E00A9DAD1 /* WebViewMock.swift in Sources */, 4B4F72EC266B2ED300814C60 /* CollectionExtension.swift in Sources */, AAE39D1B24F44885008EF28B /* TabCollectionViewModelDelegateMock.swift in Sources */, + 9F0A2CF82B96A58600C5B8C0 /* BaseBookmarkEntityTests.swift in Sources */, 373A1AAA283ED86C00586521 /* BookmarksHTMLReaderTests.swift in Sources */, 317295D42AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift in Sources */, AA9C363025518CA9004B1BA3 /* FireTests.swift in Sources */, @@ -12638,6 +12807,7 @@ 4BBF0925283083EC00EE1418 /* FileSystemDSLTests.swift in Sources */, 4B11060A25903EAC0039B979 /* CoreDataEncryptionTests.swift in Sources */, B603971029B9D67E00902A34 /* PublishersExtensions.swift in Sources */, + 9F26060E2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift in Sources */, 4B9292C32667103100AD2C21 /* PasteboardBookmarkTests.swift in Sources */, B610F2E427A8F37A00FCEBE9 /* CBRCompileTimeReporterTests.swift in Sources */, AABAF59C260A7D130085060C /* FaviconManagerMock.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/AddBookmark.svg b/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/AddBookmark.svg new file mode 100644 index 0000000000..624b357638 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/AddBookmark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/Contents.json index fd82163d37..b3519a0b69 100644 --- a/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icon-16-bookmark-add.pdf", + "filename" : "AddBookmark.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/icon-16-bookmark-add.pdf b/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/icon-16-bookmark-add.pdf deleted file mode 100644 index 27e0c7b892..0000000000 Binary files a/DuckDuckGo/Assets.xcassets/Images/AddBookmark.imageset/icon-16-bookmark-add.pdf and /dev/null differ diff --git a/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/AddFolder.svg b/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/AddFolder.svg new file mode 100644 index 0000000000..b9b806e64d --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/AddFolder.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/Contents.json index b75b5d095f..55db1eefb9 100644 --- a/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icon-16-folder-add.pdf", + "filename" : "AddFolder.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/icon-16-folder-add.pdf b/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/icon-16-folder-add.pdf deleted file mode 100644 index ba53443ad5..0000000000 Binary files a/DuckDuckGo/Assets.xcassets/Images/AddFolder.imageset/icon-16-folder-add.pdf and /dev/null differ diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/BookmarksFolder.svg b/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/BookmarksFolder.svg new file mode 100644 index 0000000000..4487eeba1a --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/BookmarksFolder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/Contents.json new file mode 100644 index 0000000000..aee9d2b2fb --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/BookmarksFolder.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "BookmarksFolder.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Chevron-Next-16.pdf b/DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Chevron-Medium-Right-16.pdf similarity index 53% rename from DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Chevron-Next-16.pdf rename to DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Chevron-Medium-Right-16.pdf index 463b781e20..7d7e84fbe4 100644 Binary files a/DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Chevron-Next-16.pdf and b/DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Chevron-Medium-Right-16.pdf differ diff --git a/DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Contents.json new file mode 100644 index 0000000000..fa088142ba --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Chevron-Medium-Right-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Chevron-Medium-Right-16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Contents.json similarity index 82% rename from DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Contents.json rename to DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Contents.json index 715e36be33..ee8f9e1708 100644 --- a/DuckDuckGo/Assets.xcassets/Images/Chevron-Next-16.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Chevron-Next-16.pdf", + "filename" : "Trash.svg", "idiom" : "universal" } ], diff --git a/DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Trash.svg b/DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Trash.svg new file mode 100644 index 0000000000..385873ffbf --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Trash.imageset/Trash.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/DuckDuckGo/Bookmarks/Extensions/Bookmarks+Tab.swift b/DuckDuckGo/Bookmarks/Extensions/Bookmarks+Tab.swift new file mode 100644 index 0000000000..82e5748c72 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Extensions/Bookmarks+Tab.swift @@ -0,0 +1,41 @@ +// +// Bookmarks+Tab.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 + +extension Tab { + + @MainActor + static func withContentOfBookmark(folder: BookmarkFolder, burnerMode: BurnerMode) -> [Tab] { + folder.children.compactMap { entity in + guard let url = (entity as? Bookmark)?.urlObject else { return nil } + return Tab(content: .url(url, source: .bookmark), shouldLoadInBackground: true, burnerMode: burnerMode) + } + } + +} + +extension TabCollection { + + @MainActor + static func withContentOfBookmark(folder: BookmarkFolder, burnerMode: BurnerMode) -> TabCollection { + let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: burnerMode) + return TabCollection(tabs: tabs) + } + +} diff --git a/DuckDuckGo/Bookmarks/Model/Bookmark.swift b/DuckDuckGo/Bookmarks/Model/Bookmark.swift index c7c5f61076..4d3cf399d8 100644 --- a/DuckDuckGo/Bookmarks/Model/Bookmark.swift +++ b/DuckDuckGo/Bookmarks/Model/Bookmark.swift @@ -19,7 +19,7 @@ import Cocoa import Bookmarks -internal class BaseBookmarkEntity: Identifiable { +internal class BaseBookmarkEntity: Identifiable, Equatable, Hashable { static func singleEntity(with uuid: String) -> NSFetchRequest { let request = BookmarkEntity.fetchRequest() @@ -99,6 +99,23 @@ internal class BaseBookmarkEntity: Identifiable { } } + // Subclasses needs to override to check equality on their properties + func isEqual(to instance: BaseBookmarkEntity) -> Bool { + id == instance.id && + title == instance.title && + isFolder == instance.isFolder + } + + static func == (lhs: BaseBookmarkEntity, rhs: BaseBookmarkEntity) -> Bool { + return type(of: lhs) == type(of: rhs) && lhs.isEqual(to: rhs) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(title) + hasher.combine(isFolder) + } + } final class BookmarkFolder: BaseBookmarkEntity { @@ -139,6 +156,38 @@ final class BookmarkFolder: BaseBookmarkEntity { super.init(id: id, title: title, isFolder: true) } + + override func isEqual(to instance: BaseBookmarkEntity) -> Bool { + guard let folder = instance as? BookmarkFolder else { + return false + } + return id == folder.id && + title == folder.title && + isFolder == folder.isFolder && + isParentFolderEqual(lhs: parentFolderUUID, rhs: folder.parentFolderUUID) && + children == folder.children + } + + override func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(title) + hasher.combine(isFolder) + hasher.combine(parentFolderUUID) + hasher.combine(children) + } + + // In some cases a bookmark folder that is child of the root folder has its `parentFolderUUID` set to `bookmarks_root`. In some other cases is nil. Making sure that comparing a `nil` and a `bookmarks_root` does not return false. Probably would be good idea to remove the optionality of `parentFolderUUID` in the future and set it to `bookmarks_root` when needed. + private func isParentFolderEqual(lhs: String?, rhs: String?) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case (.some(let lhsValue), .some(let rhsValue)): + return lhsValue == rhsValue + case (.some(let value), .none), (.none, .some(let value)): + return value == "bookmarks_root" + } + } + } final class Bookmark: BaseBookmarkEntity { @@ -196,12 +245,33 @@ final class Bookmark: BaseBookmarkEntity { parentFolderUUID: bookmark.parentFolderUUID) } -} + convenience init(from bookmark: Bookmark, withNewUrl url: String, title: String, isFavorite: Bool) { + self.init(id: bookmark.id, + url: url, + title: title, + isFavorite: isFavorite, + parentFolderUUID: bookmark.parentFolderUUID) + } -extension BaseBookmarkEntity: Equatable { + override func isEqual(to instance: BaseBookmarkEntity) -> Bool { + guard let bookmark = instance as? Bookmark else { + return false + } + return id == bookmark.id && + title == bookmark.title && + isFolder == bookmark.isFolder && + url == bookmark.url && + isFavorite == bookmark.isFavorite && + parentFolderUUID == bookmark.parentFolderUUID + } - static func == (lhs: BaseBookmarkEntity, rhs: BaseBookmarkEntity) -> Bool { - return lhs.id == rhs.id + override func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(title) + hasher.combine(isFolder) + hasher.combine(url) + hasher.combine(isFavorite) + hasher.combine(parentFolderUUID) } } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkFolderInfo.swift b/DuckDuckGo/Bookmarks/Model/BookmarkFolderInfo.swift new file mode 100644 index 0000000000..a4f8004f71 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/BookmarkFolderInfo.swift @@ -0,0 +1,32 @@ +// +// BookmarkFolderInfo.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol BookmarksEntityIdentifiable { + var entityId: String { get } + var parentId: String? { get } +} + +struct BookmarkEntityInfo: Equatable, BookmarksEntityIdentifiable { + let entity: BaseBookmarkEntity + let parent: BookmarkFolder? + + var entityId: String { entity.id } + var parentId: String? { parent?.id } +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkList.swift b/DuckDuckGo/Bookmarks/Model/BookmarkList.swift index 994557e2ba..dcae056327 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkList.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkList.swift @@ -125,6 +125,13 @@ struct BookmarkList { } } + mutating func update(bookmark: Bookmark, newURL: String, newTitle: String, newIsFavorite: Bool) -> Bookmark? { + guard !bookmark.isFolder else { return nil } + + let newBookmark = Bookmark(from: bookmark, withNewUrl: newURL, title: newTitle, isFavorite: newIsFavorite) + return updateBookmarkList(newBookmark: newBookmark, oldBookmark: bookmark) + } + mutating func updateUrl(of bookmark: Bookmark, to newURL: String) -> Bookmark? { guard !bookmark.isFolder else { return nil } @@ -132,12 +139,25 @@ struct BookmarkList { os_log("BookmarkList: Update failed, new url already in bookmark list") return nil } + + let newBookmark = Bookmark(from: bookmark, with: newURL) + return updateBookmarkList(newBookmark: newBookmark, oldBookmark: bookmark) + } + + func bookmarks() -> [IdentifiableBookmark] { + return allBookmarkURLsOrdered + } + +} + +private extension BookmarkList { + + mutating private func updateBookmarkList(newBookmark: Bookmark, oldBookmark bookmark: Bookmark) -> Bookmark? { guard itemsDict[bookmark.url] != nil, let index = allBookmarkURLsOrdered.firstIndex(of: IdentifiableBookmark(from: bookmark)) else { os_log("BookmarkList: Update failed, no such item in bookmark list") return nil } - let newBookmark = Bookmark(from: bookmark, with: newURL) let newIdentifiableBookmark = IdentifiableBookmark(from: newBookmark) allBookmarkURLsOrdered.remove(at: index) @@ -147,13 +167,8 @@ struct BookmarkList { let updatedBookmarks = existingBookmarks.filter { $0.id != bookmark.id } itemsDict[bookmark.url] = updatedBookmarks - itemsDict[newURL] = (itemsDict[newURL] ?? []) + [bookmark] + itemsDict[newBookmark.url] = (itemsDict[newBookmark.url] ?? []) + [newBookmark] return newBookmark } - - func bookmarks() -> [IdentifiableBookmark] { - return allBookmarkURLsOrdered - } - } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index ee19960da1..b3172da78e 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -35,7 +35,9 @@ protocol BookmarkManager: AnyObject { func remove(folder: BookmarkFolder) func remove(objectsWithUUIDs uuids: [String]) func update(bookmark: Bookmark) + func update(bookmark: Bookmark, withURL url: URL, title: String, isFavorite: Bool) func update(folder: BookmarkFolder) + func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) @discardableResult func updateUrl(of bookmark: Bookmark, to newUrl: URL) -> Bookmark? func add(bookmark: Bookmark, to parent: BookmarkFolder?, completion: @escaping (Error?) -> Void) func add(objectsWithUUIDs uuids: [String], to parent: BookmarkFolder?, completion: @escaping (Error?) -> Void) @@ -211,12 +213,35 @@ final class LocalBookmarkManager: BookmarkManager { } + func update(bookmark: Bookmark, withURL url: URL, title: String, isFavorite: Bool) { + guard list != nil else { return } + guard getBookmark(forUrl: bookmark.url) != nil else { + os_log("LocalBookmarkManager: Failed to update bookmark url - not in the list.", type: .error) + return + } + + guard let newBookmark = list?.update(bookmark: bookmark, newURL: url.absoluteString, newTitle: title, newIsFavorite: isFavorite) else { + os_log("LocalBookmarkManager: Failed to update URL of bookmark.", type: .error) + return + } + + bookmarkStore.update(bookmark: newBookmark) + loadBookmarks() + requestSync() + } + func update(folder: BookmarkFolder) { bookmarkStore.update(folder: folder) loadBookmarks() requestSync() } + func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) { + bookmarkStore.update(folder: folder, andMoveToParent: parent) + loadBookmarks() + requestSync() + } + func updateUrl(of bookmark: Bookmark, to newUrl: URL) -> Bookmark? { guard list != nil else { return nil } guard getBookmark(forUrl: bookmark.url) != nil else { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift index 86f24bdc28..4f6b58c10f 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift @@ -67,11 +67,24 @@ final class BookmarkNode: Hashable { return 0 } - init(representedObject: AnyObject, parent: BookmarkNode?) { + /// Creates an instance of a bookmark node. + /// - Parameters: + /// - representedObject: The represented object contained in the node. + /// - parent: An optional parent node. + /// - uniqueId: A unique identifier for the node. This should be used only in unit tests. + /// - Attention: Use this initializer only in tests. + init(representedObject: AnyObject, parent: BookmarkNode?, uniqueId: Int) { self.representedObject = representedObject self.parent = parent - self.uniqueID = BookmarkNode.incrementingID + self.uniqueID = uniqueId + } + /// Creates an instance of a bookmark node. + /// - Parameters: + /// - representedObject: The represented object contained in the node. + /// - parent: An optional parent node. + convenience init(representedObject: AnyObject, parent: BookmarkNode?) { + self.init(representedObject: representedObject, parent: parent, uniqueId: BookmarkNode.incrementingID) BookmarkNode.incrementingID += 1 } @@ -165,7 +178,7 @@ final class BookmarkNode: Hashable { // The Node class will most frequently represent Bookmark entities and PseudoFolders. Because of this, their unique properties are // used to derive the hash for the node so that equality can be handled based on the represented object. if let entity = self.representedObject as? BaseBookmarkEntity { - hasher.combine(entity.id) + hasher.combine(entity.hashValue) } else if let folder = self.representedObject as? PseudoFolder { hasher.combine(folder.name) } else { @@ -176,7 +189,7 @@ final class BookmarkNode: Hashable { // MARK: - Equatable class func == (lhs: BookmarkNode, rhs: BookmarkNode) -> Bool { - return lhs === rhs + return lhs.uniqueID == rhs.uniqueID && lhs.representedObjectEquals(rhs.representedObject) } } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift index edfcfa6e2e..93a404fa95 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift @@ -30,10 +30,12 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS @Published var selectedFolders: [BookmarkFolder] = [] let treeController: BookmarkTreeController - var expandedNodesIDs = Set() + private(set) var expandedNodesIDs = Set() private let contentMode: ContentMode private let bookmarkManager: BookmarkManager + private let showMenuButtonOnHover: Bool + private let onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? private let presentFaviconsFetcherOnboarding: (() -> Void)? private var favoritesPseudoFolder = PseudoFolder.favorites @@ -43,11 +45,15 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS contentMode: ContentMode, bookmarkManager: BookmarkManager, treeController: BookmarkTreeController, + showMenuButtonOnHover: Bool = true, + onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? = nil, presentFaviconsFetcherOnboarding: (() -> Void)? = nil ) { self.contentMode = contentMode self.bookmarkManager = bookmarkManager self.treeController = treeController + self.showMenuButtonOnHover = showMenuButtonOnHover + self.onMenuRequestedAction = onMenuRequestedAction self.presentFaviconsFetcherOnboarding = presentFaviconsFetcherOnboarding super.init() @@ -123,6 +129,8 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } let cell = outlineView.makeView(withIdentifier: .init(BookmarkOutlineCellView.className()), owner: self) as? BookmarkOutlineCellView ?? BookmarkOutlineCellView(identifier: .init(BookmarkOutlineCellView.className())) + cell.shouldShowMenuButton = showMenuButtonOnHover + cell.delegate = self if let bookmark = node.representedObject as? Bookmark { cell.update(from: bookmark) @@ -233,7 +241,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS // Folders cannot be dragged onto any of their descendants: let containsDescendantOfDestination = draggedFolders.contains { draggedFolder in - let folder = BookmarkFolder(id: draggedFolder.id, title: draggedFolder.name) + let folder = BookmarkFolder(id: draggedFolder.id, title: draggedFolder.name, parentFolderUUID: draggedFolder.parentFolderUUID, children: draggedFolder.children) guard let draggedNode = treeController.node(representing: folder) else { return false @@ -329,3 +337,11 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } } + +// MARK: - BookmarkOutlineCellViewDelegate + +extension BookmarkOutlineViewDataSource: BookmarkOutlineCellViewDelegate { + func outlineCellViewRequestedMenu(_ cell: BookmarkOutlineCellView) { + onMenuRequestedAction?(cell) + } +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift index 06167f2356..b8549cad89 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift @@ -33,19 +33,11 @@ final class BookmarkSidebarTreeController: BookmarkTreeControllerDataSource { // MARK: - Private private func childNodesForRootNode(_ node: BookmarkNode) -> [BookmarkNode] { - let favorites = PseudoFolder.favorites - let favoritesNode = BookmarkNode(representedObject: favorites, parent: node) - favoritesNode.canHaveChildNodes = false - - let blankSpacer = SpacerNode.blank - let spacerNode = BookmarkNode(representedObject: blankSpacer, parent: node) - spacerNode.canHaveChildNodes = false - let bookmarks = PseudoFolder.bookmarks let bookmarksNode = BookmarkNode(representedObject: bookmarks, parent: node) bookmarksNode.canHaveChildNodes = true - return [favoritesNode, spacerNode, bookmarksNode] + return [bookmarksNode] } private func childNodes(for parentNode: BookmarkNode) -> [BookmarkNode] { diff --git a/DuckDuckGo/Bookmarks/Model/PasteboardFolder.swift b/DuckDuckGo/Bookmarks/Model/PasteboardFolder.swift index ffc3eb9a41..f99f1e040d 100644 --- a/DuckDuckGo/Bookmarks/Model/PasteboardFolder.swift +++ b/DuckDuckGo/Bookmarks/Model/PasteboardFolder.swift @@ -26,12 +26,15 @@ struct PasteboardFolder: Hashable { static let name = "name" } - let id: String - let name: String + var id: String { folder.id } + var name: String { folder.title } + var parentFolderUUID: String? { folder.parentFolderUUID } + var children: [BaseBookmarkEntity] { folder.children } - init(id: String, name: String) { - self.id = id - self.name = name + private let folder: BookmarkFolder + + init(folder: BookmarkFolder) { + self.folder = folder } // MARK: - Pasteboard Restoration @@ -41,7 +44,7 @@ struct PasteboardFolder: Hashable { return nil } - self.init(id: id, name: name) + self.init(folder: .init(id: id, title: name)) } init?(pasteboardItem: NSPasteboardItem) { @@ -78,19 +81,17 @@ struct PasteboardFolder: Hashable { static let folderUTIInternalType = NSPasteboard.PasteboardType(rawValue: folderUTIInternal) var pasteboardFolder: PasteboardFolder { - return PasteboardFolder(id: folderID, name: folderName) + return PasteboardFolder(folder: folder) } var internalDictionary: PasteboardAttributes { return pasteboardFolder.internalDictionaryRepresentation } - private let folderID: String - private let folderName: String + private let folder: BookmarkFolder init(folder: BookmarkFolder) { - self.folderID = folder.id - self.folderName = folder.title + self.folder = folder } // MARK: - NSPasteboardWriting @@ -102,7 +103,7 @@ struct PasteboardFolder: Hashable { func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { switch type { case .string: - return folderName + return folder.title case FolderPasteboardWriter.folderUTIInternalType: return internalDictionary default: diff --git a/DuckDuckGo/Bookmarks/Model/PseudoFolder.swift b/DuckDuckGo/Bookmarks/Model/PseudoFolder.swift index c3aed14be7..85cef1888a 100644 --- a/DuckDuckGo/Bookmarks/Model/PseudoFolder.swift +++ b/DuckDuckGo/Bookmarks/Model/PseudoFolder.swift @@ -22,7 +22,7 @@ import Foundation final class PseudoFolder: Equatable { static let favorites = PseudoFolder(id: UUID().uuidString, name: UserText.favorites, icon: .favoriteFilledBorder) - static let bookmarks = PseudoFolder(id: UUID().uuidString, name: UserText.bookmarks, icon: .folder) + static let bookmarks = PseudoFolder(id: UUID().uuidString, name: UserText.bookmarks, icon: .bookmarksFolder) let id: String let name: String diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index 6b7981c96a..3466d1a7fa 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -52,6 +52,7 @@ protocol BookmarkStore { func remove(objectsWithUUIDs: [String], completion: @escaping (Bool, Error?) -> Void) func update(bookmark: Bookmark) func update(folder: BookmarkFolder) + func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) func add(objectsWithUUIDs: [String], to parent: BookmarkFolder?, completion: @escaping (Error?) -> Void) func update(objectsWithUUIDs uuids: [String], update: @escaping (BaseBookmarkEntity) -> Void, completion: @escaping (Error?) -> Void) func canMoveObjectWithUUID(objectUUID uuid: String, to parent: BookmarkFolder) -> Bool diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift index f505891891..2ab57158a9 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift @@ -46,6 +46,11 @@ public final class BookmarkStoreMock: BookmarkStore { self.updateFavoriteIndexCalled = updateFavoriteIndexCalled } + var capturedFolder: BookmarkFolder? + var capturedParentFolder: BookmarkFolder? + var capturedParentFolderType: ParentFolderType? + var capturedBookmark: Bookmark? + var loadAllCalled = false var bookmarks: [BaseBookmarkEntity]? var loadError: Error? @@ -60,6 +65,8 @@ public final class BookmarkStoreMock: BookmarkStore { func save(bookmark: Bookmark, parent: BookmarkFolder?, index: Int?, completion: @escaping (Bool, Error?) -> Void) { saveBookmarkCalled = true bookmarks?.append(bookmark) + capturedParentFolder = parent + capturedBookmark = bookmark completion(saveBookmarkSuccess, saveBookmarkError) } @@ -68,6 +75,8 @@ public final class BookmarkStoreMock: BookmarkStore { var saveFolderError: Error? func save(folder: BookmarkFolder, parent: BookmarkFolder?, completion: @escaping (Bool, Error?) -> Void) { saveFolderCalled = true + capturedFolder = folder + capturedParentFolder = parent completion(saveFolderSuccess, saveFolderError) } @@ -92,11 +101,20 @@ public final class BookmarkStoreMock: BookmarkStore { } updateBookmarkCalled = true + capturedBookmark = bookmark } var updateFolderCalled = false func update(folder: BookmarkFolder) { updateFolderCalled = true + capturedFolder = folder + } + + var updateFolderAndMoveToParentCalled = false + func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) { + updateFolderAndMoveToParentCalled = true + capturedFolder = folder + capturedParentFolderType = parent } var addChildCalled = false @@ -122,8 +140,11 @@ public final class BookmarkStoreMock: BookmarkStore { } var moveObjectUUIDCalled = false + var capturedObjectUUIDs: [String]? func move(objectUUIDs: [String], toIndex: Int?, withinParentFolder: ParentFolderType, completion: @escaping (Error?) -> Void) { moveObjectUUIDCalled = true + capturedObjectUUIDs = objectUUIDs + capturedParentFolderType = withinParentFolder } var updateFavoriteIndexCalled = false @@ -135,4 +156,17 @@ public final class BookmarkStoreMock: BookmarkStore { func handleFavoritesAfterDisablingSync() {} } +extension ParentFolderType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.root, .root): + return true + case (.parent(let lhsValue), .parent(let rhsValue)): + return lhsValue == rhsValue + default: + return false + } + } +} + #endif diff --git a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift b/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift index 49f694be2b..f79e265d96 100644 --- a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift +++ b/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift @@ -18,10 +18,19 @@ import AppKit -struct ContextualMenu { +enum ContextualMenu { + + static func menu(for objects: [Any]?) -> NSMenu? { + menu(for: objects, target: nil) + } + + /// 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` + /// - 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? { - // Not all contexts support an editing option for bookmarks. The option is displayed by default, but `includeBookmarkEditMenu` can disable it. - static func menu(for objects: [Any]?, includeBookmarkEditMenu: Bool = true) -> NSMenu? { guard let objects = objects, objects.count > 0 else { return menuForNoSelection() } @@ -31,140 +40,183 @@ struct ContextualMenu { } let node = objects.first as? BookmarkNode - let object = node?.representedObject ?? objects.first as? BaseBookmarkEntity + let object = node?.representedObject as? BaseBookmarkEntity ?? objects.first as? BaseBookmarkEntity + let parentFolder = node?.parent?.representedObject as? BookmarkFolder - if let bookmark = object as? Bookmark { - return menu(for: bookmark, includeBookmarkEditMenu: includeBookmarkEditMenu) - } else if let folder = object as? BookmarkFolder { - return menu(for: folder) - } else { - return nil - } - } + guard let object else { return nil } - // MARK: - Single Item Menu Creation + let menu = menu(for: object, parentFolder: parentFolder) - private static func menuForNoSelection() -> NSMenu { - let menu = NSMenu(title: "") - menu.addItem(newFolderMenuItem()) + menu?.items.forEach { item in + item.target = target + } return menu } - private static func menu(for bookmark: Bookmark, includeBookmarkEditMenu: Bool) -> NSMenu { - let menu = NSMenu(title: "") + /// Creates an instance of NSMenu for the specified `BaseBookmarkEntity`and parent `BookmarkFolder`. + /// + /// - Parameters: + /// - entity: The bookmark entity to create the menu for. + /// - parentFolder: An optional `BookmarkFolder`. + /// - 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? { + let menu: NSMenu? + if let bookmark = entity as? Bookmark { + menu = self.menu(for: bookmark, parent: parentFolder, isFavorite: bookmark.isFavorite) + } 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) + } else { + menu = nil + } - menu.addItem(openBookmarkInNewTabMenuItem(bookmark: bookmark)) - menu.addItem(openBookmarkInNewWindowMenuItem(bookmark: bookmark)) - menu.addItem(NSMenuItem.separator()) + return menu + } - menu.addItem(addBookmarkToFavoritesMenuItem(bookmark: bookmark)) + /// Returns an array of `NSMenuItem` to show for a bookmark. + /// + /// - Important: The `representedObject` for the `NSMenuItem` returned is `nil`. This function is meant to be used for scenarios where the model is not available at the time of creating the `NSMenu` such as from the BookmarkBarCollectionViewItem. + /// + /// - Parameter isFavorite: True if the menu item should contain a menu item to add to favorites. False to contain a menu item to remove from favorites. + /// - Returns: An array of `NSMenuItem` + static func bookmarkMenuItems(isFavorite: Bool) -> [NSMenuItem] { + menuItems(for: nil, parent: nil, isFavorite: isFavorite) + } - if includeBookmarkEditMenu { - menu.addItem(editBookmarkMenuItem(bookmark: bookmark)) - } + /// Returns an array of `NSMenuItem` to show for a bookmark folder. + /// + /// - Important: The `representedObject` for the `NSMenuItem` returned is `nil`. This function is meant to be used for scenarios where the model is not available at the time of creating the `NSMenu` such as from the BookmarkBarCollectionViewItem. + /// + /// - Returns: An array of `NSMenuItem` + static func folderMenuItems() -> [NSMenuItem] { + menuItems(for: nil, parent: nil) + } - menu.addItem(NSMenuItem.separator()) +} - menu.addItem(copyBookmarkMenuItem(bookmark: bookmark)) - menu.addItem(deleteBookmarkMenuItem(bookmark: bookmark)) - menu.addItem(NSMenuItem.separator()) +private extension ContextualMenu { - menu.addItem(newFolderMenuItem()) + static func menuForNoSelection() -> NSMenu { + NSMenu(items: [addFolderMenuItem(folder: nil)]) + } - return menu + static func menu(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool) -> NSMenu { + NSMenu(items: menuItems(for: bookmark, parent: parent, isFavorite: isFavorite)) } - private static func menu(for folder: BookmarkFolder) -> NSMenu { - let menu = NSMenu(title: "") + static func menu(for folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenu { + NSMenu(items: menuItems(for: folder, parent: parent)) + } - menu.addItem(renameFolderMenuItem(folder: folder)) - menu.addItem(deleteFolderMenuItem(folder: folder)) - menu.addItem(NSMenuItem.separator()) + static func menuItems(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool) -> [NSMenuItem] { + [ + openBookmarkInNewTabMenuItem(bookmark: bookmark), + openBookmarkInNewWindowMenuItem(bookmark: bookmark), + NSMenuItem.separator(), + addBookmarkToFavoritesMenuItem(isFavorite: isFavorite, bookmark: bookmark), + NSMenuItem.separator(), + editBookmarkMenuItem(bookmark: bookmark), + copyBookmarkMenuItem(bookmark: bookmark), + deleteBookmarkMenuItem(bookmark: bookmark), + moveToEndMenuItem(entity: bookmark, parent: parent), + NSMenuItem.separator(), + addFolderMenuItem(folder: parent), + manageBookmarksMenuItem(), + ] + } - menu.addItem(openInNewTabsMenuItem(folder: folder)) + static func menuItems(for folder: BookmarkFolder?, parent: BookmarkFolder?) -> [NSMenuItem] { + [ + openInNewTabsMenuItem(folder: folder), + openAllInNewWindowMenuItem(folder: folder), + NSMenuItem.separator(), + editFolderMenuItem(folder: folder, parent: parent), + deleteFolderMenuItem(folder: folder), + moveToEndMenuItem(entity: folder, parent: parent), + NSMenuItem.separator(), + addFolderMenuItem(folder: folder), + manageBookmarksMenuItem(), + ] + } - return menu + static func menuItem(_ title: String, _ action: Selector, _ representedObject: Any? = nil) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.representedObject = representedObject + return item } - // MARK: - Menu Items + // MARK: - Single Bookmark Menu Items - static func newFolderMenuItem() -> NSMenuItem { - return menuItem(UserText.newFolder, #selector(FolderMenuItemSelectors.newFolder(_:))) + static func openBookmarkInNewTabMenuItem(bookmark: Bookmark?) -> NSMenuItem { + menuItem(UserText.openInNewTab, #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), bookmark) } - static func renameFolderMenuItem(folder: BookmarkFolder) -> NSMenuItem { - return menuItem(UserText.renameFolder, #selector(FolderMenuItemSelectors.renameFolder(_:)), folder) + static func openBookmarkInNewWindowMenuItem(bookmark: Bookmark?) -> NSMenuItem { + menuItem(UserText.openInNewWindow, #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), bookmark) } - static func deleteFolderMenuItem(folder: BookmarkFolder) -> NSMenuItem { - return menuItem(UserText.deleteFolder, #selector(FolderMenuItemSelectors.deleteFolder(_:)), folder) + static func manageBookmarksMenuItem() -> NSMenuItem { + menuItem(UserText.bookmarksManageBookmarks, #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) } - static func openInNewTabsMenuItem(folder: BookmarkFolder) -> NSMenuItem { - return menuItem(UserText.bookmarksOpenInNewTabs, #selector(FolderMenuItemSelectors.openInNewTabs(_:)), folder) + static func addBookmarkToFavoritesMenuItem(isFavorite: Bool, bookmark: Bookmark?) -> NSMenuItem { + let title = isFavorite ? UserText.removeFromFavorites : UserText.addToFavorites + return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmark) } - static func openBookmarksInNewTabsMenuItem(bookmarks: [Bookmark]) -> NSMenuItem { - return menuItem(UserText.bookmarksOpenInNewTabs, #selector(FolderMenuItemSelectors.openInNewTabs(_:)), bookmarks) + static func addBookmarksToFavoritesMenuItem(bookmarks: [Bookmark], allFavorites: Bool) -> NSMenuItem { + let title = allFavorites ? UserText.removeFromFavorites : UserText.addToFavorites + return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmarks) } - static func openBookmarkInNewTabMenuItem(bookmark: Bookmark) -> NSMenuItem { - return menuItem(UserText.openInNewTab, #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), bookmark) + static func editBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { + menuItem(UserText.editBookmark, #selector(BookmarkMenuItemSelectors.editBookmark(_:)), bookmark) } - static func openBookmarkInNewWindowMenuItem(bookmark: Bookmark) -> NSMenuItem { - return menuItem(UserText.openInNewWindow, #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), bookmark) + static func copyBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { + menuItem(UserText.copy, #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), bookmark) } - static func addBookmarkToFavoritesMenuItem(bookmark: Bookmark) -> NSMenuItem { - let title: String - - if bookmark.isFavorite { - title = UserText.removeFromFavorites - } else { - title = UserText.addToFavorites - } - - return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmark) + static func deleteBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { + menuItem(UserText.bookmarksBarContextMenuDelete, #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), bookmark) } - static func addBookmarksToFavoritesMenuItem(bookmarks: [Bookmark], allFavorites: Bool) -> NSMenuItem { - let title: String + static func moveToEndMenuItem(entity: BaseBookmarkEntity?, parent: BookmarkFolder?) -> NSMenuItem { + let bookmarkEntityInfo = entity.flatMap { BookmarkEntityInfo(entity: $0, parent: parent) } + return menuItem(UserText.bookmarksBarContextMenuMoveToEnd, #selector(BookmarkMenuItemSelectors.moveToEnd(_:)), bookmarkEntityInfo) + } - if allFavorites { - title = UserText.removeFromFavorites - } else { - title = UserText.addToFavorites - } + // MARK: - Bookmark Folder Menu Items - return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmarks) + static func openInNewTabsMenuItem(folder: BookmarkFolder?) -> NSMenuItem { + menuItem(UserText.openAllInNewTabs, #selector(FolderMenuItemSelectors.openInNewTabs(_:)), folder) } - static func editBookmarkMenuItem(bookmark: Bookmark) -> NSMenuItem { - let title = NSLocalizedString("Edit…", comment: "Command") - return menuItem(title, #selector(BookmarkMenuItemSelectors.editBookmark(_:)), bookmark) + static func openAllInNewWindowMenuItem(folder: BookmarkFolder?) -> NSMenuItem { + menuItem(UserText.openAllTabsInNewWindow, #selector(FolderMenuItemSelectors.openAllInNewWindow(_:)), folder) } - static func copyBookmarkMenuItem(bookmark: Bookmark) -> NSMenuItem { - let title = NSLocalizedString("Copy", comment: "Command") - return menuItem(title, #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), bookmark) + static func addFolderMenuItem(folder: BookmarkFolder?) -> NSMenuItem { + menuItem(UserText.addFolder, #selector(FolderMenuItemSelectors.newFolder(_:)), folder) } - static func deleteBookmarkMenuItem(bookmark: Bookmark) -> NSMenuItem { - let title = NSLocalizedString("Delete", comment: "Command") - return menuItem(title, #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), bookmark) + 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) } - static func menuItem(_ title: String, _ action: Selector, _ representedObject: Any? = nil) -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: "") - item.representedObject = representedObject - return item + static func deleteFolderMenuItem(folder: BookmarkFolder?) -> NSMenuItem { + menuItem(UserText.bookmarksBarContextMenuDelete, #selector(FolderMenuItemSelectors.deleteFolder(_:)), folder) } // MARK: - Multi-Item Menu Creation - private static func menu(for entities: [BaseBookmarkEntity]) -> NSMenu { + static func openBookmarksInNewTabsMenuItem(bookmarks: [Bookmark]) -> NSMenuItem { + menuItem(UserText.bookmarksOpenInNewTabs, #selector(FolderMenuItemSelectors.openInNewTabs(_:)), bookmarks) + } + + static func menu(for entities: [BaseBookmarkEntity]) -> NSMenu { let menu = NSMenu(title: "") var menuItems: [NSMenuItem] = [] @@ -185,8 +237,7 @@ struct ContextualMenu { menuItems.append(NSMenuItem.separator()) } - let title = NSLocalizedString("Delete", comment: "Command") - let deleteItem = NSMenuItem(title: title, action: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), keyEquivalent: "") + let deleteItem = NSMenuItem(title: UserText.bookmarksBarContextMenuDelete, action: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), keyEquivalent: "") deleteItem.representedObject = entities menuItems.append(deleteItem) diff --git a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift index 307d16cc5d..e2be507a44 100644 --- a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift @@ -391,18 +391,27 @@ final class LocalBookmarkStore: BookmarkStore { } func update(folder: BookmarkFolder) { - do { - _ = try applyChangesAndSave(changes: { context in - let folderFetchRequest = BaseBookmarkEntity.singleEntity(with: folder.id) - let folderFetchRequestResults = try? context.fetch(folderFetchRequest) - - guard let bookmarkFolderMO = folderFetchRequestResults?.first else { - assertionFailure("LocalBookmarkStore: Failed to get BookmarkEntity from the context") - throw BookmarkStoreError.missingEntity + _ = try applyChangesAndSave(changes: { [weak self] context in + guard let self = self else { + throw BookmarkStoreError.storeDeallocated } + try update(folder: folder, in: context) + }) + } catch { + let error = error as NSError + commonOnSaveErrorHandler(error) + } + } - bookmarkFolderMO.update(with: folder) + func update(folder: BookmarkFolder, andMoveToParent parent: ParentFolderType) { + do { + _ = try applyChangesAndSave(changes: { [weak self] context in + guard let self = self else { + throw BookmarkStoreError.storeDeallocated + } + let folderEntity = try update(folder: folder, in: context) + try move(entities: [folderEntity], toIndex: nil, withinParentFolderType: parent, in: context) }) } catch { let error = error as NSError @@ -566,10 +575,6 @@ final class LocalBookmarkStore: BookmarkStore { throw BookmarkStoreError.storeDeallocated } - guard let rootFolder = self.bookmarksRoot(in: context) else { - throw BookmarkStoreError.missingRoot - } - // Guarantee that bookmarks are fetched in the same order as the UUIDs. In the future, this should fetch all objects at once with a // batch fetch request and have them sorted in the correct order. let bookmarkManagedObjects: [BookmarkEntity] = objectUUIDs.compactMap { uuid in @@ -577,28 +582,8 @@ final class LocalBookmarkStore: BookmarkStore { return (try? context.fetch(entityFetchRequest))?.first } - let newParentFolder: BookmarkEntity - - switch type { - case .root: newParentFolder = rootFolder - case .parent(let newParentUUID): - let bookmarksFetchRequest = BaseBookmarkEntity.singleEntity(with: newParentUUID) + try move(entities: bookmarkManagedObjects, toIndex: index, withinParentFolderType: type, in: context) - if let fetchedParent = try context.fetch(bookmarksFetchRequest).first, fetchedParent.isFolder { - newParentFolder = fetchedParent - } else { - throw BookmarkStoreError.missingEntity - } - } - - if let index = index, index < newParentFolder.childrenArray.count { - self.move(entities: bookmarkManagedObjects, to: index, within: newParentFolder) - } else { - for bookmarkManagedObject in bookmarkManagedObjects { - bookmarkManagedObject.parent = nil - newParentFolder.addToChildren(bookmarkManagedObject) - } - } }, onError: { [weak self] error in self?.commonOnSaveErrorHandler(error) DispatchQueue.main.async { completion(error) } @@ -996,6 +981,53 @@ final class LocalBookmarkStore: BookmarkStore { } +private extension LocalBookmarkStore { + + @discardableResult + func update(folder: BookmarkFolder, in context: NSManagedObjectContext) throws -> BookmarkEntity { + let folderFetchRequest = BaseBookmarkEntity.singleEntity(with: folder.id) + let folderFetchRequestResults = try? context.fetch(folderFetchRequest) + + guard let bookmarkFolderMO = folderFetchRequestResults?.first else { + assertionFailure("LocalBookmarkStore: Failed to get BookmarkEntity from the context") + throw BookmarkStoreError.missingEntity + } + + bookmarkFolderMO.update(with: folder) + return bookmarkFolderMO + } + + func move(entities: [BookmarkEntity], toIndex index: Int?, withinParentFolderType type: ParentFolderType, in context: NSManagedObjectContext) throws { + guard let rootFolder = bookmarksRoot(in: context) else { + throw BookmarkStoreError.missingRoot + } + + let newParentFolder: BookmarkEntity + + switch type { + case .root: newParentFolder = rootFolder + case .parent(let newParentUUID): + let bookmarksFetchRequest = BaseBookmarkEntity.singleEntity(with: newParentUUID) + + if let fetchedParent = try context.fetch(bookmarksFetchRequest).first, fetchedParent.isFolder { + newParentFolder = fetchedParent + } else { + throw BookmarkStoreError.missingEntity + } + } + + if let index = index, index < newParentFolder.childrenArray.count { + self.move(entities: entities, to: index, within: newParentFolder) + } else { + for bookmarkManagedObject in entities { + bookmarkManagedObject.parent = nil + newParentFolder.addToChildren(bookmarkManagedObject) + } + } + } + +} + extension LocalBookmarkStore.BookmarkStoreError: CustomNSError { var errorCode: Int { diff --git a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift index 44114fc9d8..393b22de8e 100644 --- a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift +++ b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift @@ -18,7 +18,13 @@ import AppKit -@objc protocol BookmarkMenuItemSelectors { +@objc protocol BookmarksMenuItemSelectors { + func newFolder(_ sender: NSMenuItem) + func moveToEnd(_ sender: NSMenuItem) + @objc optional func manageBookmarks(_ sender: NSMenuItem) +} + +@objc protocol BookmarkMenuItemSelectors: BookmarksMenuItemSelectors { func openBookmarkInNewTab(_ sender: NSMenuItem) func openBookmarkInNewWindow(_ sender: NSMenuItem) @@ -30,11 +36,11 @@ import AppKit } -@objc protocol FolderMenuItemSelectors { +@objc protocol FolderMenuItemSelectors: BookmarksMenuItemSelectors { - func newFolder(_ sender: NSMenuItem) - func renameFolder(_ sender: NSMenuItem) + func editFolder(_ sender: NSMenuItem) func deleteFolder(_ sender: NSMenuItem) func openInNewTabs(_ sender: NSMenuItem) + func openAllInNewWindow(_ sender: NSMenuItem) } diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderModalView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkFolderModalView.swift deleted file mode 100644 index 31cc06dc04..0000000000 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderModalView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// AddBookmarkFolderModalView.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 SwiftUI - -struct AddBookmarkFolderModalView: ModalView { - - @State var model: AddBookmarkFolderModalViewModel = .init() - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(model.title) - .fontWeight(.semibold) - - HStack(spacing: 16) { - Text(UserText.newBookmarkDialogBookmarkNameTitle) - .frame(height: 22) - - TextField("", text: $model.folderName) - .accessibilityIdentifier("Title Text Field") - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - } - .padding(.bottom, 4) - - HStack { - Spacer() - - Button(UserText.cancel) { - model.cancel(dismiss: dismiss.callAsFunction) - } - .keyboardShortcut(.cancelAction) - - Button(model.addButtonTitle) { - model.addFolder(dismiss: dismiss.callAsFunction) - } - .keyboardShortcut(.defaultAction) - .disabled(model.isAddButtonDisabled) - - } - } - .font(.system(size: 13)) - .padding() - .frame(width: 450, height: 131) - } - -} - -#Preview { - AddBookmarkFolderModalView() -} diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift index a0ab50c89a..f15465df36 100644 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift +++ b/DuckDuckGo/Bookmarks/View/AddBookmarkFolderPopoverView.swift @@ -23,59 +23,27 @@ struct AddBookmarkFolderPopoverView: ModalView { @ObservedObject var model: AddBookmarkFolderPopoverViewModel var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(UserText.newFolder) - .bold() - - VStack(alignment: .leading, spacing: 7) { - Text("Location:", comment: "Add Folder popover: parent folder picker title") - - BookmarkFolderPicker(folders: model.folders, selectedFolder: $model.parent) - .accessibilityIdentifier("bookmark.folder.folder.dropdown") - .disabled(model.isDisabled) - } - - VStack(alignment: .leading, spacing: 7) { - Text(UserText.newFolderDialogFolderNameTitle) - - TextField("", text: $model.folderName) - .focusedOnAppear() - .accessibilityIdentifier("bookmark.folder.name.textfield") - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disabled(model.isDisabled) - } - .padding(.bottom, 16) - - HStack { - Spacer() - - Button(action: { - model.cancel() - }) { - Text(UserText.cancel) - } - .accessibilityIdentifier("bookmark.add.cancel.button") - .disabled(model.isDisabled) - - Button(action: { - model.addFolder() - }) { - Text("Add Folder", comment: "Add Folder popover: Create folder button") - } - .keyboardShortcut(.defaultAction) - .accessibilityIdentifier("bookmark.add.add.folder.button") - .disabled(model.isAddFolderButtonDisabled || model.isDisabled) - } - } + AddEditBookmarkFolderView( + title: model.title, + buttonsState: .expanded, + folders: model.folders, + folderName: $model.folderName, + selectedFolder: $model.parent, + cancelActionTitle: model.cancelActionTitle, + isCancelActionDisabled: model.isCancelActionDisabled, + cancelAction: { _ in model.cancel() }, + defaultActionTitle: model.defaultActionTitle, + isDefaultActionDisabled: model.isDefaultActionButtonDisabled, + defaultAction: { _ in model.addFolder() } + ) + .padding(.vertical, 16.0) .font(.system(size: 13)) - .padding() - .frame(width: 300, height: 229) - .background(Color(.popoverBackground)) + .frame(width: 320) } } #if DEBUG -#Preview { +#Preview("Add Folder - Light") { let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [ BookmarkFolder(id: "1", title: "Folder 1", children: [ BookmarkFolder(id: "2", title: "Nested Folder with a name that in theory won‘t fit into the picker", children: [ @@ -94,5 +62,17 @@ struct AddBookmarkFolderPopoverView: ModalView { return AddBookmarkFolderPopoverView(model: AddBookmarkFolderPopoverViewModel(bookmarkManager: bkman) { print("CompletionHandler:", $0?.title ?? "") }) + .preferredColorScheme(.light) +} + +#Preview("Add Folder - Dark") { + let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [])) + bkman.loadBookmarks() + customAssertionFailure = { _, _, _ in } + + return AddBookmarkFolderPopoverView(model: AddBookmarkFolderPopoverViewModel(bookmarkManager: bkman) { + print("CompletionHandler:", $0?.title ?? "") + }) + .preferredColorScheme(.dark) } #endif diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkModalView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkModalView.swift deleted file mode 100644 index 56941737fd..0000000000 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkModalView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// AddBookmarkModalView.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 AppKit -import SwiftUI - -struct AddBookmarkModalView: ModalView { - - @State private(set) var model: AddBookmarkModalViewModel - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(model.title) - .fontWeight(.semibold) - - HStack(spacing: 16) { - VStack(alignment: .leading) { - Text("Title:", comment: "Add Bookmark dialog bookmark title field heading") - .frame(height: 22) - Text("Address:", comment: "Add Bookmark dialog bookmark url field heading") - .frame(height: 22) - } - - VStack { - TextField("", text: $model.bookmarkTitle) - .accessibilityIdentifier("Title Text Field") - .textFieldStyle(.roundedBorder) - TextField("", text: $model.bookmarkAddress) - .accessibilityIdentifier("URL Text Field") - .textFieldStyle(.roundedBorder) - .disableAutocorrection(true) - } - } - .padding(.bottom, 4) - - HStack { - Spacer() - - Button(UserText.cancel) { - model.cancel(dismiss: dismiss.callAsFunction) - } - .keyboardShortcut(.cancelAction) - - Button(model.addButtonTitle) { - model.addOrSave(dismiss: dismiss.callAsFunction) - } - .keyboardShortcut(.defaultAction) - .disabled(model.isAddButtonDisabled) - - } - } - .font(.system(size: 13)) - .padding() - .frame(width: 437, height: 164) - } - -} - -#Preview { - AddBookmarkModalView(model: AddBookmarkModalViewModel()) -} diff --git a/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift b/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift index c8d9cadcbd..fba84b44bc 100644 --- a/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift +++ b/DuckDuckGo/Bookmarks/View/AddBookmarkPopoverView.swift @@ -38,82 +38,32 @@ struct AddBookmarkPopoverView: View { @MainActor private var addBookmarkView: some View { - VStack(alignment: .leading, spacing: 19) { - Text("Bookmark Added", comment: "Bookmark Added popover title") - .fontWeight(.bold) - .padding(.bottom, 4) - - VStack(alignment: .leading, spacing: 10) { - TextField("", text: $model.bookmarkTitle) - .focusedOnAppear() - .accessibilityIdentifier("bookmark.add.name.textfield") - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(size: 14)) - - HStack { - BookmarkFolderPicker(folders: model.folders, - selectedFolder: $model.selectedFolder) - .accessibilityIdentifier("bookmark.add.folder.dropdown") - - Button { - model.addFolderButtonAction() - } label: { - Image(.addFolder) - } - .accessibilityIdentifier("bookmark.add.new.folder.button") - .buttonStyle(StandardButtonStyle()) - } - } - - Divider() - - Button { - model.favoritesButtonAction() - } label: { - HStack(spacing: 8) { - if model.bookmark.isFavorite { - Image(.favoriteFilled) - Text(UserText.removeFromFavorites) - } else { - Image(.favorite) - Text(UserText.addToFavorites) - } - } - } - .accessibilityIdentifier("bookmark.add.add.to.favorites.button") - .buttonStyle(.borderless) - .foregroundColor(Color.button) - - HStack { - Spacer() - - Button { - model.removeButtonAction(dismiss: dismiss.callAsFunction) - } label: { - Text("Remove", comment: "Remove bookmark button title") - } - .accessibilityIdentifier("bookmark.add.remove.button") - - Button { - model.doneButtonAction(dismiss: dismiss.callAsFunction) - } label: { - Text(UserText.done) - } - .keyboardShortcut(.defaultAction) - .accessibilityIdentifier("bookmark.add.done.button") - } - - } + AddEditBookmarkView( + title: UserText.Bookmarks.Dialog.Title.addedBookmark, + buttonsState: .expanded, + bookmarkName: $model.bookmarkTitle, + bookmarkURLPath: nil, + isBookmarkFavorite: $model.isBookmarkFavorite, + folders: model.folders, + selectedFolder: $model.selectedFolder, + isURLFieldHidden: true, + addFolderAction: model.addFolderButtonAction, + otherActionTitle: UserText.remove, + isOtherActionDisabled: false, + otherAction: model.removeButtonAction, + defaultActionTitle: UserText.done, + isDefaultActionDisabled: model.isDefaultActionButtonDisabled, + defaultAction: model.doneButtonAction + ) + .padding(.vertical, 16.0) .font(.system(size: 13)) - .padding(EdgeInsets(top: 19, leading: 19, bottom: 19, trailing: 19)) - .frame(width: 300, height: 229) - .background(Color(.popoverBackground)) + .frame(width: 320) } } #if DEBUG -#Preview { { +#Preview("Bookmark Added - Light") { let bkm = Bookmark(id: "n", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "1") let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [ BookmarkFolder(id: "1", title: "Folder with a name that shouldn‘t fit into the picker", children: [ @@ -133,5 +83,16 @@ struct AddBookmarkPopoverView: View { customAssertionFailure = { _, _, _ in } return AddBookmarkPopoverView(model: AddBookmarkPopoverViewModel(bookmark: bkm, bookmarkManager: bkman)) -}() } + .preferredColorScheme(.light) +} + +#Preview("Bookmark Added - Dark") { + let bkm = Bookmark(id: "n", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "1") + let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [ + BookmarkFolder(id: "1", title: "Folder with a name that shouldn‘t fit into the picker", children: [])])) + bkman.loadBookmarks() + + return AddBookmarkPopoverView(model: AddBookmarkPopoverViewModel(bookmark: bkm, bookmarkManager: bkman)) + .preferredColorScheme(.dark) +} #endif diff --git a/DuckDuckGo/Bookmarks/View/BookmarkFolderPicker.swift b/DuckDuckGo/Bookmarks/View/BookmarkFolderPicker.swift index c0aeab9bec..e7de655e8c 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkFolderPicker.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkFolderPicker.swift @@ -32,7 +32,7 @@ struct BookmarkFolderPicker: View { return popUpButton } content: { - PopupButtonItem(icon: .folder, title: UserText.bookmarks) + PopupButtonItem(icon: .bookmarksFolder, title: UserText.bookmarks) PopupButtonItem.separator() diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift index 541ad955b6..c405c23f61 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -63,6 +63,9 @@ final class BookmarkListViewController: NSViewController { contentMode: .bookmarksAndFolders, bookmarkManager: bookmarkManager, treeController: treeController, + onMenuRequestedAction: { [weak self] cell in + self?.showContextMenu(for: cell) + }, presentFaviconsFetcherOnboarding: { [weak self] in guard let self, let window = self.view.window else { return @@ -334,23 +337,18 @@ final class BookmarkListViewController: NSViewController { } @objc func newBookmarkButtonClicked(_ sender: AnyObject) { - delegate?.popover(shouldPreventClosure: true) - AddBookmarkModalView(model: AddBookmarkModalViewModel(currentTabWebsite: currentTabWebsite) { [weak delegate] _ in - delegate?.popover(shouldPreventClosure: false) - }).show(in: parent?.view.window) + let view = BookmarksDialogViewFactory.makeAddBookmarkView(currentTab: currentTabWebsite) + showDialog(view: view) } @objc func newFolderButtonClicked(_ sender: AnyObject) { - delegate?.popover(shouldPreventClosure: true) - AddBookmarkFolderModalView() - .show(in: parent?.view.window) { [weak delegate] in - delegate?.popover(shouldPreventClosure: false) - } + let parentFolder = sender.representedObject as? BookmarkFolder + let view = BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parentFolder) + showDialog(view: view) } @objc func openManagementInterface(_ sender: NSButton) { - WindowControllersManager.shared.showBookmarksTab() - delegate?.popoverShouldClose(self) + showManageBookmarks() } @objc func handleClick(_ sender: NSOutlineView) { @@ -425,6 +423,35 @@ final class BookmarkListViewController: NSViewController { outlineView.selectRowIndexes(indexes, byExtendingSelection: false) } + private func showContextMenu(for cell: BookmarkOutlineCellView) { + let row = outlineView.row(for: cell) + guard + let item = outlineView.item(atRow: row), + let contextMenu = ContextualMenu.menu(for: [item], target: self) + else { + return + } + + contextMenu.popUpAtMouseLocation(in: view) + } + +} + +private extension BookmarkListViewController { + + func showDialog(view: any ModalView) { + delegate?.popover(shouldPreventClosure: true) + + view.show(in: parent?.view.window) { [weak delegate] in + delegate?.popover(shouldPreventClosure: false) + } + } + + func showManageBookmarks() { + WindowControllersManager.shared.showBookmarksTab() + delegate?.popoverShouldClose(self) + } + } // MARK: - Menu Item Selectors @@ -439,11 +466,11 @@ extension BookmarkListViewController: NSMenuDelegate { } if outlineView.selectedRowIndexes.contains(row) { - return ContextualMenu.menu(for: outlineView.selectedItems, includeBookmarkEditMenu: false) + return ContextualMenu.menu(for: outlineView.selectedItems) } if let item = outlineView.item(atRow: row) { - return ContextualMenu.menu(for: [item], includeBookmarkEditMenu: false) + return ContextualMenu.menu(for: [item]) } else { return nil } @@ -498,7 +525,13 @@ extension BookmarkListViewController: BookmarkMenuItemSelectors { } func editBookmark(_ sender: NSMenuItem) { - // Unsupported in the list view for the initial release. + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to retrieve Bookmark from Edit Bookmark context menu item") + return + } + + let view = BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark) + showDialog(view: view) } func copyBookmark(_ sender: NSMenuItem) { @@ -527,6 +560,20 @@ extension BookmarkListViewController: BookmarkMenuItemSelectors { bookmarkManager.remove(objectsWithUUIDs: uuids) } + func manageBookmarks(_ sender: NSMenuItem) { + showManageBookmarks() + } + + func moveToEnd(_ sender: NSMenuItem) { + guard let bookmarkEntity = sender.representedObject as? BookmarksEntityIdentifiable else { + assertionFailure("Failed to cast menu item's represented object to BookmarkEntity") + return + } + + let parentFolderType: ParentFolderType = bookmarkEntity.parentId.flatMap { .parent(uuid: $0) } ?? .root + bookmarkManager.move(objectUUIDs: [bookmarkEntity.entityId], toIndex: nil, withinParentFolder: parentFolderType) { _ in } + } + } extension BookmarkListViewController: FolderMenuItemSelectors { @@ -535,18 +582,16 @@ extension BookmarkListViewController: FolderMenuItemSelectors { newFolderButtonClicked(sender) } - func renameFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { - assertionFailure("Failed to retrieve Bookmark from Rename Folder context menu item") + func editFolder(_ sender: NSMenuItem) { + guard let bookmarkEntityInfo = sender.representedObject as? BookmarkEntityInfo, + let folder = bookmarkEntityInfo.entity as? BookmarkFolder + else { + assertionFailure("Failed to retrieve Bookmark from Edit Folder context menu item") return } - delegate?.popover(shouldPreventClosure: true) - - AddBookmarkFolderModalView(model: AddBookmarkFolderModalViewModel(folder: folder)) - .show(in: parent?.view.window) { [weak delegate] in - delegate?.popover(shouldPreventClosure: false) - } + let view = BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) + showDialog(view: view) } func deleteFolder(_ sender: NSMenuItem) { @@ -560,15 +605,28 @@ extension BookmarkListViewController: FolderMenuItemSelectors { func openInNewTabs(_ sender: NSMenuItem) { guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, - let children = (sender.representedObject as? BookmarkFolder)?.children else { - assertionFailure("Cannot open in new tabs") + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new tabs") return } - let tabs = children.compactMap { ($0 as? Bookmark)?.urlObject }.map { Tab(content: .url($0, source: .bookmark), shouldLoadInBackground: true, burnerMode: tabCollection.burnerMode) } + let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) tabCollection.append(tabs: tabs) } + func openAllInNewWindow(_ sender: NSMenuItem) { + guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new window") + return + } + + let newTabCollection = TabCollection.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) + WindowsManager.openNewWindow(with: newTabCollection, isBurner: tabCollection.isBurner) + } + } // MARK: - BookmarkListPopover diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift index a9b03ede97..ecc643b33d 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift @@ -32,12 +32,10 @@ private struct EditedBookmarkMetadata { final class BookmarkManagementDetailViewController: NSViewController, NSMenuItemValidation { - fileprivate enum Constants { - static let animationSpeed: TimeInterval = 0.3 - } - + private let toolbarButtonsStackView = NSStackView() private lazy var newBookmarkButton = MouseOverButton(title: " " + UserText.newBookmark, target: self, action: #selector(presentAddBookmarkModal)) private lazy var newFolderButton = MouseOverButton(title: " " + UserText.newFolder, target: self, action: #selector(presentAddFolderModal)) + private lazy var deleteItemsButton = MouseOverButton(title: " " + UserText.bookmarksBarContextMenuDelete, target: self, action: #selector(delete)) private lazy var separator = NSBox() private lazy var scrollView = NSScrollView() @@ -54,32 +52,10 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem private let bookmarkManager: BookmarkManager private var selectionState: BookmarkManagementSidebarViewController.SelectionState = .empty { didSet { - editingBookmarkIndex = nil reloadData() } } - private var isEditing: Bool { - return editingBookmarkIndex != nil - } - - private var editingBookmarkIndex: EditedBookmarkMetadata? { - didSet { - NSAnimationContext.runAnimationGroup { context in - context.allowsImplicitAnimation = true - context.duration = Constants.animationSpeed - - NSAppearance.withAppAppearance { - if editingBookmarkIndex != nil { - view.animator().layer?.backgroundColor = NSColor.backgroundSecondary.cgColor - } else { - view.animator().layer?.backgroundColor = NSColor.bookmarkPageBackground.cgColor - } - } - } - } - } - func update(selectionState: BookmarkManagementSidebarViewController.SelectionState) { self.selectionState = selectionState } @@ -101,34 +77,16 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem view.addSubview(separator) view.addSubview(scrollView) view.addSubview(emptyState) - view.addSubview(newBookmarkButton) - view.addSubview(newFolderButton) - - newBookmarkButton.bezelStyle = .shadowlessSquare - newBookmarkButton.cornerRadius = 4 - newBookmarkButton.normalTintColor = .button - newBookmarkButton.mouseDownColor = .buttonMouseDown - newBookmarkButton.mouseOverColor = .buttonMouseOver - newBookmarkButton.imageHugsTitle = true - newBookmarkButton.setContentHuggingPriority(.defaultHigh, for: .vertical) - newBookmarkButton.translatesAutoresizingMaskIntoConstraints = false - newBookmarkButton.alignment = .center - newBookmarkButton.font = .systemFont(ofSize: 13) - newBookmarkButton.image = .addBookmark - newBookmarkButton.imagePosition = .imageLeading - - newFolderButton.bezelStyle = .shadowlessSquare - newFolderButton.cornerRadius = 4 - newFolderButton.normalTintColor = .button - newFolderButton.mouseDownColor = .buttonMouseDown - newFolderButton.mouseOverColor = .buttonMouseOver - newFolderButton.imageHugsTitle = true - newFolderButton.setContentHuggingPriority(.defaultHigh, for: .vertical) - newFolderButton.translatesAutoresizingMaskIntoConstraints = false - newFolderButton.alignment = .center - newFolderButton.font = .systemFont(ofSize: 13) - newFolderButton.image = .addFolder - newFolderButton.imagePosition = .imageLeading + view.addSubview(toolbarButtonsStackView) + toolbarButtonsStackView.addArrangedSubview(newBookmarkButton) + toolbarButtonsStackView.addArrangedSubview(newFolderButton) + toolbarButtonsStackView.addArrangedSubview(deleteItemsButton) + toolbarButtonsStackView.translatesAutoresizingMaskIntoConstraints = false + toolbarButtonsStackView.distribution = .fill + + configureToolbar(button: newBookmarkButton, image: .addBookmark, isHidden: false) + configureToolbar(button: newFolderButton, image: .addFolder, isHidden: false) + configureToolbar(button: deleteItemsButton, image: .trash, isHidden: true) emptyState.addSubview(emptyStateImageView) emptyState.addSubview(emptyStateTitle) @@ -137,32 +95,27 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem emptyState.isHidden = true emptyState.translatesAutoresizingMaskIntoConstraints = false + importButton.translatesAutoresizingMaskIntoConstraints = false - emptyStateTitle.isEditable = false - emptyStateTitle.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateTitle.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateTitle.translatesAutoresizingMaskIntoConstraints = false - emptyStateTitle.alignment = .center - emptyStateTitle.drawsBackground = false - emptyStateTitle.isBordered = false - emptyStateTitle.font = .systemFont(ofSize: 15, weight: .semibold) - emptyStateTitle.textColor = .labelColor - emptyStateTitle.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateTitle, - lineHeight: 1.14, - kern: -0.23) - - emptyStateMessage.isEditable = false - emptyStateMessage.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateMessage.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateMessage.translatesAutoresizingMaskIntoConstraints = false - emptyStateMessage.alignment = .center - emptyStateMessage.drawsBackground = false - emptyStateMessage.isBordered = false - emptyStateMessage.font = .systemFont(ofSize: 13) - emptyStateMessage.textColor = .labelColor - emptyStateMessage.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateMessage, - lineHeight: 1.05, - kern: -0.08) + configureEmptyState( + label: emptyStateTitle, + font: .systemFont(ofSize: 15, weight: .semibold), + attributedTitle: .make( + UserText.bookmarksEmptyStateTitle, + lineHeight: 1.14, + kern: -0.23 + ) + ) + + configureEmptyState( + label: emptyStateMessage, + font: .systemFont(ofSize: 13), + attributedTitle: .make( + UserText.bookmarksEmptyStateMessage, + lineHeight: 1.05, + kern: -0.08 + ) + ) emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) @@ -195,7 +148,6 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem tableView.selectionHighlightStyle = .none tableView.allowsMultipleSelection = true tableView.usesAutomaticRowHeights = true - tableView.action = #selector(handleClick) tableView.doubleAction = #selector(handleDoubleClick) tableView.delegate = self tableView.dataSource = self @@ -209,47 +161,47 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem } private func setupLayout() { - newBookmarkButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 48).isActive = true - view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: 48).isActive = true - separator.topAnchor.constraint(equalTo: newBookmarkButton.bottomAnchor, constant: 24).isActive = true - emptyState.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 20).isActive = true - scrollView.topAnchor.constraint(equalTo: separator.bottomAnchor).isActive = true - - view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true - view.trailingAnchor.constraint(greaterThanOrEqualTo: newFolderButton.trailingAnchor, constant: 20).isActive = true - view.trailingAnchor.constraint(equalTo: separator.trailingAnchor, constant: 58).isActive = true - newFolderButton.leadingAnchor.constraint(equalTo: newBookmarkButton.trailingAnchor, constant: 16).isActive = true - emptyState.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true - newFolderButton.centerYAnchor.constraint(equalTo: newBookmarkButton.centerYAnchor).isActive = true - separator.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 58).isActive = true - newBookmarkButton.topAnchor.constraint(equalTo: view.topAnchor, constant: 32).isActive = true - emptyState.topAnchor.constraint(greaterThanOrEqualTo: separator.bottomAnchor, constant: 8).isActive = true - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 48).isActive = true - emptyState.centerXAnchor.constraint(equalTo: separator.centerXAnchor).isActive = true - - newBookmarkButton.heightAnchor.constraint(equalToConstant: 24).isActive = true - - newFolderButton.heightAnchor.constraint(equalToConstant: 24).isActive = true - - emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true + NSLayoutConstraint.activate([ + toolbarButtonsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 48), + view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: 48), + separator.topAnchor.constraint(equalTo: toolbarButtonsStackView.bottomAnchor, constant: 24), + emptyState.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 20), + scrollView.topAnchor.constraint(equalTo: separator.bottomAnchor), - importButton.translatesAutoresizingMaskIntoConstraints = false - importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8).isActive = true - emptyState.heightAnchor.constraint(equalToConstant: 218).isActive = true - emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8).isActive = true - importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - emptyState.widthAnchor.constraint(equalToConstant: 224).isActive = true - emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor).isActive = true - emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8).isActive = true + view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + view.trailingAnchor.constraint(greaterThanOrEqualTo: toolbarButtonsStackView.trailingAnchor, constant: 20), + view.trailingAnchor.constraint(equalTo: separator.trailingAnchor, constant: 58), + emptyState.centerXAnchor.constraint(equalTo: view.centerXAnchor), + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 58), + toolbarButtonsStackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 32), + emptyState.topAnchor.constraint(greaterThanOrEqualTo: separator.bottomAnchor, constant: 8), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 48), + emptyState.centerXAnchor.constraint(equalTo: separator.centerXAnchor), + + newBookmarkButton.heightAnchor.constraint(equalToConstant: 24), + newFolderButton.heightAnchor.constraint(equalToConstant: 24), + deleteItemsButton.heightAnchor.constraint(equalToConstant: 24), - emptyStateMessage.widthAnchor.constraint(equalToConstant: 192).isActive = true + emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), - emptyStateTitle.widthAnchor.constraint(equalToConstant: 192).isActive = true + importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8), + emptyState.heightAnchor.constraint(equalToConstant: 218), + emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8), + importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + emptyState.widthAnchor.constraint(equalToConstant: 224), + emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor), + emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8), + + emptyStateMessage.widthAnchor.constraint(equalToConstant: 192), + + emptyStateTitle.widthAnchor.constraint(equalToConstant: 192), + + emptyStateImageView.widthAnchor.constraint(equalToConstant: 128), + emptyStateImageView.heightAnchor.constraint(equalToConstant: 96), + ]) - emptyStateImageView.widthAnchor.constraint(equalToConstant: 128).isActive = true - emptyStateImageView.heightAnchor.constraint(equalToConstant: 96).isActive = true } override func viewDidLoad() { @@ -264,15 +216,9 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem override func viewDidDisappear() { super.viewDidDisappear() - editingBookmarkIndex = nil reloadData() } - override func mouseUp(with event: NSEvent) { - // Clicking anywhere outside of the table view should end editing mode for a given cell. - updateEditingState(forRowAt: -1) - } - override func keyDown(with event: NSEvent) { if event.charactersIgnoringModifiers == String(UnicodeScalar(NSDeleteCharacter)!) { deleteSelectedItems() @@ -280,15 +226,13 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem } fileprivate func reloadData() { - guard editingBookmarkIndex == nil else { - // If the table view is editing, the reload will be deferred until after the cell animation has completed. - return - } emptyState.isHidden = !(bookmarkManager.list?.topLevelEntities.isEmpty ?? true) let scrollPosition = tableView.visibleRect.origin tableView.reloadData() tableView.scroll(scrollPosition) + + updateToolbarButtons() } @objc func onImportClicked(_ sender: NSButton) { @@ -306,7 +250,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem let index = sender.clickedRow - guard index != -1, editingBookmarkIndex?.index != index, let entity = fetchEntity(at: index) else { + guard index != -1, let entity = fetchEntity(at: index) else { return } @@ -324,21 +268,13 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem } } - @objc func handleClick(_ sender: NSTableView) { - let index = sender.clickedRow - - if index != editingBookmarkIndex?.index { - endEditing() - } - } - @objc func presentAddBookmarkModal(_ sender: Any) { - AddBookmarkModalView(model: AddBookmarkModalViewModel(parent: selectionState.folder)) + BookmarksDialogViewFactory.makeAddBookmarkView(parent: selectionState.folder) .show(in: view.window) } @objc func presentAddFolderModal(_ sender: Any) { - AddBookmarkFolderModalView(model: AddBookmarkFolderModalViewModel(parent: selectionState.folder)) + BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: selectionState.folder) .show(in: view.window) } @@ -354,53 +290,6 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem return true } - private func endEditing() { - if let editingIndex = editingBookmarkIndex?.index { - self.editingBookmarkIndex = nil - animateEditingState(forRowAt: editingIndex, editing: false) - } - } - - private func updateEditingState(forRowAt index: Int) { - guard index != -1 else { - endEditing() - return - } - - if editingBookmarkIndex?.index == nil || editingBookmarkIndex?.index != index { - endEditing() - } - - if let entity = fetchEntity(at: index) { - editingBookmarkIndex = EditedBookmarkMetadata(uuid: entity.id, index: index) - animateEditingState(forRowAt: index, editing: true) - } else { - assertionFailure("\(#file): Failed to find entity when updating editing state") - } - } - - private func animateEditingState(forRowAt index: Int, editing: Bool, completion: (() -> Void)? = nil) { - if let cell = tableView.view(atColumn: 0, row: index, makeIfNecessary: false) as? BookmarkTableCellView, - let row = tableView.rowView(atRow: index, makeIfNecessary: false) as? BookmarkTableRowView { - - tableView.beginUpdates() - NSAnimationContext.runAnimationGroup { context in - context.allowsImplicitAnimation = true - context.duration = Constants.animationSpeed - context.completionHandler = completion - - cell.editing = editing - row.editing = editing - - row.layoutSubtreeIfNeeded() - cell.layoutSubtreeIfNeeded() - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(arrayLiteral: 0, index)) - } - - tableView.endUpdates() - } - } - private func totalRows() -> Int { switch selectionState { case .empty: @@ -444,12 +333,6 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi let rowView = BookmarkTableRowView() rowView.onSelectionChanged = onSelectionChanged - let entity = fetchEntity(at: row) - - if let uuid = editingBookmarkIndex?.uuid, uuid == entity?.id { - rowView.editing = true - } - return rowView } @@ -463,14 +346,12 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi if let bookmark = entity as? Bookmark { cell.update(from: bookmark) - cell.editing = bookmark.id == editingBookmarkIndex?.uuid if bookmark.favicon(.small) == nil { faviconsFetcherOnboarding?.presentOnboardingIfNeeded() } } else if let folder = entity as? BookmarkFolder { cell.update(from: folder) - cell.editing = folder.id == editingBookmarkIndex?.uuid } else { assertionFailure("Failed to cast bookmark") } @@ -573,6 +454,17 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi } } + private func fetchEntityAndParent(at row: Int) -> (entity: BaseBookmarkEntity?, parentFolder: BookmarkFolder?) { + switch selectionState { + case .empty: + return (bookmarkManager.list?.topLevelEntities[safe: row], nil) + case .folder(let folder): + return (folder.children[safe: row], folder) + case .favorites: + return (bookmarkManager.list?.favoriteBookmarks[safe: row], nil) + } + } + private func index(for entity: Bookmark) -> Int? { switch selectionState { case .empty: @@ -610,11 +502,25 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi } func onSelectionChanged() { - resetSelections() - let indexes = tableView.selectedRowIndexes - indexes.forEach { - let cell = self.tableView.view(atColumn: 0, row: $0, makeIfNecessary: false) as? BookmarkTableCellView - cell?.isSelected = true + func updateCellSelections() { + resetSelections() + tableView.selectedRowIndexes.forEach { + let cell = self.tableView.view(atColumn: 0, row: $0, makeIfNecessary: false) as? BookmarkTableCellView + cell?.isSelected = true + } + } + + updateCellSelections() + updateToolbarButtons() + } + + private func updateToolbarButtons() { + let shouldShowDeleteButton = tableView.selectedRowIndexes.count > 1 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + deleteItemsButton.animator().isHidden = !shouldShowDeleteButton + newBookmarkButton.animator().isHidden = shouldShowDeleteButton + newFolderButton.animator().isHidden = shouldShowDeleteButton } } @@ -633,13 +539,45 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi } } +// MARK: - Private + +private extension BookmarkManagementDetailViewController { + + func configureToolbar(button: MouseOverButton, image: NSImage, isHidden: Bool) { + button.bezelStyle = .shadowlessSquare + button.cornerRadius = 4 + button.normalTintColor = .button + button.mouseDownColor = .buttonMouseDown + button.mouseOverColor = .buttonMouseOver + button.imageHugsTitle = true + button.setContentHuggingPriority(.defaultHigh, for: .vertical) + button.alignment = .center + button.font = .systemFont(ofSize: 13) + button.image = image + button.imagePosition = .imageLeading + button.isHidden = isHidden + } + + func configureEmptyState(label: NSTextField, font: NSFont, attributedTitle: NSAttributedString) { + label.isEditable = false + label.setContentHuggingPriority(.defaultHigh, for: .vertical) + label.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) + label.translatesAutoresizingMaskIntoConstraints = false + label.alignment = .center + label.drawsBackground = false + label.isBordered = false + label.font = font + label.textColor = .labelColor + label.attributedStringValue = attributedTitle + } + +} + // MARK: - BookmarkTableCellViewDelegate extension BookmarkManagementDetailViewController: BookmarkTableCellViewDelegate { func bookmarkTableCellViewRequestedMenu(_ sender: NSButton, cell: BookmarkTableCellView) { - guard !isEditing else { return } - let row = tableView.row(for: cell) guard let bookmark = fetchEntity(at: row) as? Bookmark else { @@ -647,45 +585,8 @@ extension BookmarkManagementDetailViewController: BookmarkTableCellViewDelegate return } - if let contextMenu = ContextualMenu.menu(for: [bookmark]), let cursorLocation = self.view.window?.mouseLocationOutsideOfEventStream { - let convertedLocation = self.view.convert(cursorLocation, from: nil) - contextMenu.items.forEach { item in - item.target = self - } - - contextMenu.popUp(positioning: nil, at: convertedLocation, in: self.view) - } - } - - func bookmarkTableCellViewToggledFavorite(cell: BookmarkTableCellView) { - let row = tableView.row(for: cell) - - guard let bookmark = fetchEntity(at: row) as? Bookmark else { - assertionFailure("BookmarkManagementDetailViewController: Tried to favorite object which is not bookmark") - return - } - - bookmark.isFavorite.toggle() - bookmarkManager.update(bookmark: bookmark) - } - - func bookmarkTableCellView(_ cell: BookmarkTableCellView, updatedBookmarkWithUUID uuid: String, newTitle: String, newUrl: String) { - let row = tableView.row(for: cell) - defer { - endEditing() - } - guard var bookmark = fetchEntity(at: row) as? Bookmark, bookmark.id == uuid else { - return - } - - if let url = newUrl.url, url.absoluteString != bookmark.url { - bookmark = bookmarkManager.updateUrl(of: bookmark, to: url) ?? bookmark - } - let bookmarkTitle = (newTitle.isEmpty ? bookmark.title : newTitle).trimmingWhitespace() - if bookmark.title != bookmarkTitle { - bookmark.title = bookmarkTitle - bookmarkManager.update(bookmark: bookmark) - } + guard let contextMenu = ContextualMenu.menu(for: [bookmark], target: self) else { return } + contextMenu.popUpAtMouseLocation(in: view) } } @@ -695,20 +596,21 @@ extension BookmarkManagementDetailViewController: BookmarkTableCellViewDelegate extension BookmarkManagementDetailViewController: NSMenuDelegate { func contextualMenuForClickedRows() -> NSMenu? { - guard !isEditing else { return nil } - let row = tableView.clickedRow guard row != -1 else { return ContextualMenu.menu(for: nil) } - if tableView.selectedRowIndexes.contains(row) { + // If only one item is selected try to get the item and its parent folder otherwise show the menu for multiple items. + if tableView.selectedRowIndexes.contains(row), tableView.selectedRowIndexes.count > 1 { return ContextualMenu.menu(for: self.selectedItems()) } - if let item = fetchEntity(at: row) { - return ContextualMenu.menu(for: [item]) + let (item, parent) = fetchEntityAndParent(at: row) + + if let item { + return ContextualMenu.menu(for: item, parentFolder: parent) } else { return nil } @@ -738,13 +640,15 @@ extension BookmarkManagementDetailViewController: FolderMenuItemSelectors { presentAddFolderModal(sender) } - func renameFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { + func editFolder(_ sender: NSMenuItem) { + guard let bookmarkEntityInfo = sender.representedObject as? BookmarkEntityInfo, + let folder = bookmarkEntityInfo.entity as? BookmarkFolder + else { assertionFailure("Failed to cast menu represented object to BookmarkFolder") return } - AddBookmarkFolderModalView(model: AddBookmarkFolderModalViewModel(folder: folder)) + BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) .show(in: view.window) } @@ -757,6 +661,16 @@ extension BookmarkManagementDetailViewController: FolderMenuItemSelectors { bookmarkManager.remove(folder: folder) } + func moveToEnd(_ sender: NSMenuItem) { + guard let bookmarkEntity = sender.representedObject as? BookmarksEntityIdentifiable else { + assertionFailure("Failed to cast menu item's represented object to BookmarkEntity") + return + } + + let parentFolderType: ParentFolderType = bookmarkEntity.parentId.flatMap { .parent(uuid: $0) } ?? .root + bookmarkManager.move(objectUUIDs: [bookmarkEntity.entityId], toIndex: nil, withinParentFolder: parentFolderType) { _ in } + } + func openInNewTabs(_ sender: NSMenuItem) { if let children = (sender.representedObject as? BookmarkFolder)?.children { let bookmarks = children.compactMap { $0 as? Bookmark } @@ -768,6 +682,18 @@ extension BookmarkManagementDetailViewController: FolderMenuItemSelectors { } } + func openAllInNewWindow(_ sender: NSMenuItem) { + guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new window") + return + } + + let newTabCollection = TabCollection.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) + WindowsManager.openNewWindow(with: newTabCollection, isBurner: tabCollection.isBurner) + } + } extension BookmarkManagementDetailViewController: BookmarkMenuItemSelectors { @@ -811,8 +737,10 @@ extension BookmarkManagementDetailViewController: BookmarkMenuItemSelectors { } func editBookmark(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark, let bookmarkIndex = index(for: bookmark) else { return } - updateEditingState(forRowAt: bookmarkIndex) + guard let bookmark = sender.representedObject as? Bookmark else { return } + + BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark) + .show(in: view.window) } func copyBookmark(_ sender: NSMenuItem) { diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift index 6d0fdd6860..53e502383b 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift @@ -51,7 +51,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { 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) + private lazy var dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController, showMenuButtonOnHover: false) private var cancellables = Set() @@ -211,6 +211,13 @@ final class BookmarkManagementSidebarViewController: NSViewController { // MARK: NSOutlineView Configuration private func expandAndRestore(selectedNodes: [BookmarkNode]) { + // OutlineView doesn't allow multiple selections so there should be only one selected node at time. + let selectedNode = selectedNodes.first + // As the data source reloaded we need to refresh the previously selected nodes. + // Lets consider the scenario where we add a folder to a subfolder. + // When the folder is added we need to "refresh" the node because the previously selected node folder has changed (it has a child folder now). + var refreshedSelectedNodes: [BookmarkNode] = [] + treeController.visitNodes { node in if let objectID = (node.representedObject as? BaseBookmarkEntity)?.id { if dataSource.expandedNodesIDs.contains(objectID) { @@ -218,6 +225,11 @@ final class BookmarkManagementSidebarViewController: NSViewController { } else { outlineView.collapseItem(node) } + + // Add the node if it contains previously selected folder + if let folder = selectedNode?.representedObject as? BookmarkFolder, folder.id == objectID { + refreshedSelectedNodes.append(node) + } } // Expand the Bookmarks pseudo folder automatically. @@ -226,7 +238,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { } } - restoreSelection(to: selectedNodes) + restoreSelection(to: refreshedSelectedNodes) } private func restoreSelection(to nodes: [BookmarkNode]) { @@ -292,16 +304,20 @@ extension BookmarkManagementSidebarViewController: NSMenuDelegate { extension BookmarkManagementSidebarViewController: FolderMenuItemSelectors { func newFolder(_ sender: NSMenuItem) { - AddBookmarkFolderModalView().show(in: view.window) + let parent = sender.representedObject as? BookmarkFolder + BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parent) + .show(in: view.window) } - func renameFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { - assertionFailure("Failed to retrieve Bookmark from Rename Folder context menu item") + func editFolder(_ sender: NSMenuItem) { + guard let bookmarkEntityInfo = sender.representedObject as? BookmarkEntityInfo, + let folder = bookmarkEntityInfo.entity as? BookmarkFolder + else { + assertionFailure("Failed to cast menu represented object to BookmarkFolder") return } - AddBookmarkFolderModalView(model: AddBookmarkFolderModalViewModel(folder: folder)) + BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) .show(in: view.window) } @@ -314,17 +330,40 @@ extension BookmarkManagementSidebarViewController: FolderMenuItemSelectors { bookmarkManager.remove(folder: folder) } + func moveToEnd(_ sender: NSMenuItem) { + guard let bookmarkEntity = sender.representedObject as? BookmarksEntityIdentifiable else { + assertionFailure("Failed to cast menu item's represented object to BookmarkEntity") + return + } + + let parentFolderType: ParentFolderType = bookmarkEntity.parentId.flatMap { .parent(uuid: $0) } ?? .root + bookmarkManager.move(objectUUIDs: [bookmarkEntity.entityId], toIndex: nil, withinParentFolder: parentFolderType) { _ in } + } + func openInNewTabs(_ sender: NSMenuItem) { guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, - let children = (sender.representedObject as? BookmarkFolder)?.children else { - assertionFailure("Cannot open in new tabs") + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new tabs") return } - let tabs = children.compactMap { ($0 as? Bookmark)?.urlObject }.map { Tab(content: .url($0, source: .bookmark), shouldLoadInBackground: true, burnerMode: tabCollection.burnerMode) } + let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) tabCollection.append(tabs: tabs) } + func openAllInNewWindow(_ sender: NSMenuItem) { + guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new window") + return + } + + let newTabCollection = TabCollection.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) + WindowsManager.openNewWindow(with: newTabCollection, isBurner: tabCollection.isBurner) + } + } #if DEBUG diff --git a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift index 08e6c56953..603849bbbf 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift @@ -19,11 +19,24 @@ import AppKit import Foundation +protocol BookmarkOutlineCellViewDelegate: AnyObject { + func outlineCellViewRequestedMenu(_ cell: BookmarkOutlineCellView) +} + final class BookmarkOutlineCellView: NSTableCellView { private lazy var faviconImageView = NSImageView() private lazy var titleLabel = NSTextField(string: "Bookmark/Folder") private lazy var countLabel = NSTextField(string: "42") + private lazy var menuButton = NSButton(title: "", image: .settings, target: self, action: #selector(cellMenuButtonClicked)) + private lazy var favoriteImageView = NSImageView() + private lazy var trackingArea: NSTrackingArea = { + NSTrackingArea(rect: .zero, options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], owner: self, userInfo: nil) + }() + + var shouldShowMenuButton = false + + weak var delegate: BookmarkOutlineCellViewDelegate? init(identifier: NSUserInterfaceItemIdentifier) { super.init(frame: .zero) @@ -34,10 +47,35 @@ final class BookmarkOutlineCellView: NSTableCellView { fatalError("\(type(of: self)): Bad initializer") } + override func updateTrackingAreas() { + super.updateTrackingAreas() + + guard !trackingAreas.contains(trackingArea), shouldShowMenuButton else { return } + addTrackingArea(trackingArea) + } + + override func mouseEntered(with event: NSEvent) { + guard shouldShowMenuButton else { return } + countLabel.isHidden = true + favoriteImageView.isHidden = true + menuButton.isHidden = false + } + + override func mouseExited(with event: NSEvent) { + guard shouldShowMenuButton else { return } + menuButton.isHidden = true + countLabel.isHidden = false + favoriteImageView.isHidden = false + } + + // MARK: - Private + private func setupUI() { addSubview(faviconImageView) addSubview(titleLabel) addSubview(countLabel) + addSubview(menuButton) + addSubview(favoriteImageView) faviconImageView.translatesAutoresizingMaskIntoConstraints = false faviconImageView.image = .bookmarkDefaultFavicon @@ -64,40 +102,74 @@ final class BookmarkOutlineCellView: NSTableCellView { countLabel.textColor = .blackWhite60 countLabel.lineBreakMode = .byClipping + menuButton.translatesAutoresizingMaskIntoConstraints = false + menuButton.contentTintColor = .button + menuButton.imagePosition = .imageTrailing + menuButton.isBordered = false + menuButton.isHidden = true + + favoriteImageView.translatesAutoresizingMaskIntoConstraints = false + favoriteImageView.imageScaling = .scaleProportionallyDown setupLayout() } private func setupLayout() { - faviconImageView.heightAnchor.constraint(equalToConstant: 16).isActive = true - faviconImageView.widthAnchor.constraint(equalToConstant: 16).isActive = true - faviconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5).isActive = true - faviconImageView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + NSLayoutConstraint.activate([ + faviconImageView.heightAnchor.constraint(equalToConstant: 16), + faviconImageView.widthAnchor.constraint(equalToConstant: 16), + faviconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), + faviconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 10), + bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 6), + + countLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + countLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 5), + trailingAnchor.constraint(equalTo: countLabel.trailingAnchor), + + menuButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), + menuButton.trailingAnchor.constraint(equalTo: trailingAnchor), + menuButton.topAnchor.constraint(equalTo: topAnchor), + menuButton.bottomAnchor.constraint(equalTo: bottomAnchor), + menuButton.widthAnchor.constraint(equalToConstant: 28), + + favoriteImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), + favoriteImageView.trailingAnchor.constraint(equalTo: menuButton.trailingAnchor), + favoriteImageView.heightAnchor.constraint(equalToConstant: 15), + favoriteImageView.widthAnchor.constraint(equalToConstant: 15), + ]) + faviconImageView.setContentHuggingPriority(NSLayoutConstraint.Priority(rawValue: 251), for: .horizontal) faviconImageView.setContentHuggingPriority(NSLayoutConstraint.Priority(rawValue: 251), for: .vertical) - titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 10).isActive = true - bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6).isActive = true - titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 6).isActive = true titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) titleLabel.setContentHuggingPriority(.init(rawValue: 200), for: .horizontal) - countLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true - countLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 5).isActive = true - trailingAnchor.constraint(equalTo: countLabel.trailingAnchor).isActive = true countLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) countLabel.setContentHuggingPriority(.required, for: .horizontal) } + @objc private func cellMenuButtonClicked() { + delegate?.outlineCellViewRequestedMenu(self) + } + + // MARK: - Public + func update(from bookmark: Bookmark) { faviconImageView.image = bookmark.favicon(.small) ?? .bookmarkDefaultFavicon titleLabel.stringValue = bookmark.title countLabel.stringValue = "" + favoriteImageView.image = bookmark.isFavorite ? .favoriteFilledBorder : nil } func update(from folder: BookmarkFolder) { faviconImageView.image = .folder titleLabel.stringValue = folder.title + favoriteImageView.image = nil let totalChildBookmarks = folder.totalChildBookmarks if totalChildBookmarks > 0 { @@ -111,10 +183,13 @@ final class BookmarkOutlineCellView: NSTableCellView { faviconImageView.image = pseudoFolder.icon titleLabel.stringValue = pseudoFolder.name countLabel.stringValue = pseudoFolder.count > 0 ? String(pseudoFolder.count) : "" + favoriteImageView.image = nil } } +// MARK: - Preview + #if DEBUG @available(macOS 14.0, *) #Preview { diff --git a/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift index 195ad48845..8bfdc3c4fb 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift @@ -22,8 +22,6 @@ import Foundation @objc protocol BookmarkTableCellViewDelegate: AnyObject { func bookmarkTableCellViewRequestedMenu(_ sender: NSButton, cell: BookmarkTableCellView) - func bookmarkTableCellViewToggledFavorite(cell: BookmarkTableCellView) - func bookmarkTableCellView(_ cellView: BookmarkTableCellView, updatedBookmarkWithUUID uuid: String, newTitle: String, newUrl: String) } @@ -33,53 +31,18 @@ final class BookmarkTableCellView: NSTableCellView { private lazy var titleLabel = NSTextField(string: "Bookmark") private lazy var bookmarkURLLabel = NSTextField(string: "URL") - private lazy var favoriteButton = NSButton(title: "", image: .favoriteFilledBorder, target: self, action: #selector(favoriteButtonClicked)) private lazy var accessoryImageView = NSImageView(image: .forward) - private var favoriteButtonBottomConstraint: NSLayoutConstraint! - private var favoriteButtonTrailingConstraint: NSLayoutConstraint! - private lazy var containerView = NSView() - private lazy var shadowView = NSBox() private lazy var menuButton = NSButton(title: "", image: .settings, target: self, action: #selector(cellMenuButtonClicked)) - // Shadow view constraints: - - private var shadowViewTopConstraint: NSLayoutConstraint! - private var shadowViewBottomConstraint: NSLayoutConstraint! - - // Container view constraints: - - private var titleLabelTopConstraint: NSLayoutConstraint! - private var titleLabelBottomConstraint: NSLayoutConstraint! - @objc func cellMenuButtonClicked(_ sender: NSButton) { delegate?.bookmarkTableCellViewRequestedMenu(sender, cell: self) } - @objc func favoriteButtonClicked(_ sender: NSButton) { - guard entity is Bookmark else { - assertionFailure("\(#file): Tried to favorite non-Bookmark object") - return - } - - delegate?.bookmarkTableCellViewToggledFavorite(cell: self) - } - weak var delegate: BookmarkTableCellViewDelegate? - var editing: Bool = false { - didSet { - if editing { - enterEditingMode() - } else { - exitEditingMode() - } - updateColors() - } - } - var isSelected = false { didSet { updateColors() @@ -96,16 +59,14 @@ final class BookmarkTableCellView: NSTableCellView { return } - accessoryImageView.isHidden = mouseInside || editing - menuButton.isHidden = !mouseInside || editing + accessoryImageView.isHidden = mouseInside + menuButton.isHidden = !mouseInside - if !mouseInside && !editing { + if !mouseInside { resetAppearanceFromBookmark() } - if !editing { - updateTitleLabelValue() - } + updateTitleLabelValue() } } @@ -130,36 +91,16 @@ final class BookmarkTableCellView: NSTableCellView { fatalError("\(type(of: self)): Bad initializer") } - // swiftlint:disable:next function_body_length private func setupUI() { autoresizingMask = [.width, .height] - addSubview(shadowView) addSubview(containerView) - shadowView.boxType = .custom - shadowView.borderColor = .clear - shadowView.borderWidth = 1 - shadowView.cornerRadius = 4 - shadowView.fillColor = .tableCellEditing - shadowView.translatesAutoresizingMaskIntoConstraints = false - shadowView.wantsLayer = true - shadowView.layer?.backgroundColor = NSColor.tableCellEditing.cgColor - shadowView.layer?.cornerRadius = 6 - - let shadow = NSShadow() - shadow.shadowOffset = NSSize(width: 0, height: -1) - shadow.shadowColor = NSColor.black.withAlphaComponent(0.2) - shadow.shadowBlurRadius = 2.0 - shadowView.shadow = shadow - containerView.translatesAutoresizingMaskIntoConstraints = false containerView.addSubview(faviconImageView) containerView.addSubview(titleLabel) containerView.addSubview(menuButton) containerView.addSubview(accessoryImageView) - containerView.addSubview(bookmarkURLLabel) - containerView.addSubview(favoriteButton) faviconImageView.contentTintColor = .suggestionIcon faviconImageView.wantsLayer = true @@ -176,92 +117,50 @@ final class BookmarkTableCellView: NSTableCellView { titleLabel.font = .systemFont(ofSize: 13) titleLabel.textColor = .labelColor titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.cell?.sendsActionOnEndEditing = true titleLabel.cell?.usesSingleLineMode = true titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) titleLabel.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - titleLabel.delegate = self - - bookmarkURLLabel.focusRingType = .none - bookmarkURLLabel.isEditable = false - bookmarkURLLabel.isSelectable = false - bookmarkURLLabel.isBordered = false - bookmarkURLLabel.drawsBackground = false - bookmarkURLLabel.font = .systemFont(ofSize: 13) - bookmarkURLLabel.textColor = .secondaryLabelColor - bookmarkURLLabel.lineBreakMode = .byClipping - bookmarkURLLabel.translatesAutoresizingMaskIntoConstraints = false - bookmarkURLLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - bookmarkURLLabel.setContentHuggingPriority(.required, for: .vertical) - bookmarkURLLabel.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - bookmarkURLLabel.delegate = self accessoryImageView.translatesAutoresizingMaskIntoConstraints = false - accessoryImageView.widthAnchor.constraint(equalToConstant: 22).isActive = true - accessoryImageView.heightAnchor.constraint(equalToConstant: 32).isActive = true menuButton.contentTintColor = .button menuButton.translatesAutoresizingMaskIntoConstraints = false menuButton.isBordered = false menuButton.isHidden = true - - favoriteButton.translatesAutoresizingMaskIntoConstraints = false - favoriteButton.isBordered = false } private func setupLayout() { + NSLayoutConstraint.activate([ + trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 3), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 3), + bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 3), + containerView.topAnchor.constraint(equalTo: topAnchor, constant: 3), - trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: 3).isActive = true - shadowView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 3).isActive = true - containerView.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor).isActive = true - containerView.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor).isActive = true - containerView.topAnchor.constraint(equalTo: shadowView.topAnchor).isActive = true - containerView.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor).isActive = true + menuButton.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8), + faviconImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 6), - bookmarkURLLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10).isActive = true - favoriteButtonTrailingConstraint = trailingAnchor.constraint(equalTo: favoriteButton.trailingAnchor, constant: 3) - favoriteButtonTrailingConstraint.isActive = true + accessoryImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 8), + trailingAnchor.constraint(equalTo: accessoryImageView.trailingAnchor, constant: 3), + faviconImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + trailingAnchor.constraint(equalTo: menuButton.trailingAnchor, constant: 2), - menuButton.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8).isActive = true - faviconImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 6).isActive = true - favoriteButton.topAnchor.constraint(equalTo: bookmarkURLLabel.bottomAnchor).isActive = true + menuButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - accessoryImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true - titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 8).isActive = true - trailingAnchor.constraint(equalTo: accessoryImageView.trailingAnchor, constant: 3).isActive = true - faviconImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true - trailingAnchor.constraint(equalTo: menuButton.trailingAnchor, constant: 2).isActive = true - bookmarkURLLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor).isActive = true - trailingAnchor.constraint(equalTo: bookmarkURLLabel.trailingAnchor, constant: 16).isActive = true - menuButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true + menuButton.heightAnchor.constraint(equalToConstant: 32), + menuButton.widthAnchor.constraint(equalToConstant: 28), - favoriteButton.widthAnchor.constraint(equalToConstant: 24).isActive = true - favoriteButton.heightAnchor.constraint(equalToConstant: 24).isActive = true + faviconImageView.heightAnchor.constraint(equalToConstant: 16), + faviconImageView.widthAnchor.constraint(equalToConstant: 16), - menuButton.heightAnchor.constraint(equalToConstant: 32).isActive = true - menuButton.widthAnchor.constraint(equalToConstant: 28).isActive = true + bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 5), - faviconImageView.heightAnchor.constraint(equalToConstant: 16).isActive = true - faviconImageView.widthAnchor.constraint(equalToConstant: 16).isActive = true - - shadowViewTopConstraint = shadowView.topAnchor.constraint(equalTo: topAnchor, constant: 3) - shadowViewTopConstraint.isActive = true - - shadowViewBottomConstraint = bottomAnchor.constraint(equalTo: shadowView.bottomAnchor, constant: 3) - shadowViewBottomConstraint.isActive = true - - titleLabelBottomConstraint = bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8) - titleLabelBottomConstraint.priority = .init(rawValue: 250) - titleLabelBottomConstraint.isActive = true - - favoriteButtonBottomConstraint = bottomAnchor.constraint(equalTo: favoriteButton.bottomAnchor, constant: 8) - favoriteButtonBottomConstraint.priority = .init(rawValue: 750) - favoriteButtonBottomConstraint.isActive = true - - titleLabelTopConstraint = titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 5) - titleLabelTopConstraint.isActive = true + accessoryImageView.widthAnchor.constraint(equalToConstant: 22), + accessoryImageView.heightAnchor.constraint(equalToConstant: 32), + ]) } override var backgroundStyle: NSView.BackgroundStyle { @@ -314,84 +213,28 @@ final class BookmarkTableCellView: NSTableCellView { accessoryImageView.isHidden = false } - accessoryImageView.image = bookmark.isFavorite ? .favorite : nil - favoriteButton.image = bookmark.isFavorite ? .favoriteFilledBorder : .favorite + accessoryImageView.image = bookmark.isFavorite ? .favoriteFilledBorder : nil titleLabel.stringValue = bookmark.title primaryTitleLabelValue = bookmark.title tertiaryTitleLabelValue = bookmark.url - bookmarkURLLabel.stringValue = bookmark.url } func update(from folder: BookmarkFolder) { self.entity = folder faviconImageView.image = .folder - accessoryImageView.image = .chevronNext16 + accessoryImageView.image = .chevronMediumRight16 primaryTitleLabelValue = folder.title tertiaryTitleLabelValue = nil } private func resetCellState() { self.entity = nil - editing = false mouseInside = false - bookmarkURLLabel.isHidden = true - favoriteButton.isHidden = true - titleLabelBottomConstraint.priority = .required - } - - private func enterEditingMode() { - titleLabel.isEditable = true - bookmarkURLLabel.isEditable = true - - shadowViewTopConstraint.constant = 10 - shadowViewBottomConstraint.constant = 10 - titleLabelTopConstraint.constant = 12 - favoriteButtonTrailingConstraint.constant = 11 - favoriteButtonBottomConstraint.constant = 18 - shadowView.isHidden = false - faviconImageView.isHidden = true - - bookmarkURLLabel.isHidden = false - favoriteButton.isHidden = false - titleLabelBottomConstraint.priority = .defaultLow - - hideTertiaryValueInTitleLabel() - - // Reluctantly use GCD as a workaround for a rare label layout issue, in which the text field shows no text upon becoming first responder. - DispatchQueue.main.async { - self.titleLabel.becomeFirstResponder() - } - } - - private func exitEditingMode() { - window?.makeFirstResponder(nil) - - titleLabel.isEditable = false - bookmarkURLLabel.isEditable = false - - titleLabelTopConstraint.constant = 5 - shadowViewTopConstraint.constant = 3 - shadowViewBottomConstraint.constant = 3 - favoriteButtonTrailingConstraint.constant = 3 - favoriteButtonBottomConstraint.constant = 8 - shadowView.isHidden = true - faviconImageView.isHidden = false - - bookmarkURLLabel.isHidden = true - favoriteButton.isHidden = true - titleLabelBottomConstraint.priority = .required - - if let editedBookmark = self.entity as? Bookmark { - delegate?.bookmarkTableCellView(self, - updatedBookmarkWithUUID: editedBookmark.id, - newTitle: titleLabel.stringValue, - newUrl: bookmarkURLLabel.stringValue) - } } private func updateColors() { - titleLabel.textColor = isSelected && !editing ? .white : .controlTextColor + titleLabel.textColor = isSelected ? .white : .controlTextColor menuButton.contentTintColor = isSelected ? .white : .button faviconImageView.contentTintColor = isSelected ? .white : .suggestionIcon accessoryImageView.contentTintColor = isSelected ? .white : .suggestionIcon @@ -428,11 +271,7 @@ final class BookmarkTableCellView: NSTableCellView { } private func updateTitleLabelValue() { - guard !editing else { - return - } - - if let tertiaryValue = tertiaryTitleLabelValue, mouseInside, !editing { + if let tertiaryValue = tertiaryTitleLabelValue, mouseInside { showTertiaryValueInTitleLabel(tertiaryValue) } else { hideTertiaryValueInTitleLabel() @@ -467,26 +306,6 @@ final class BookmarkTableCellView: NSTableCellView { } -extension BookmarkTableCellView: NSTextFieldDelegate { - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - switch commandSelector { - case #selector(cancelOperation) where self.editing: - self.resetAppearanceFromBookmark() - self.editing = false - return true - - case #selector(insertNewline) where self.editing: - self.editing = false - return true - - default: break - } - return false - } - -} - #if DEBUG @available(macOS 14.0, *) #Preview { @@ -517,19 +336,10 @@ extension BookmarkTableCellView { fatalError("init(coder:) has not been implemented") } - func bookmarkTableCellViewRequestedMenu(_ sender: NSButton, cell: BookmarkTableCellView) { - cell.editing.toggle() - } + func bookmarkTableCellViewRequestedMenu(_ sender: NSButton, cell: BookmarkTableCellView) {} func bookmarkTableCellViewToggledFavorite(cell: BookmarkTableCellView) { (cell.entity as? Bookmark)?.isFavorite.toggle() - cell.editing = false - } - - func bookmarkTableCellView(_ cellView: BookmarkTableCellView, updatedBookmarkWithUUID uuid: String, newTitle: String, newUrl: String) { - if cell.editing { - cell.editing = false - } } } } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkTableRowView.swift b/DuckDuckGo/Bookmarks/View/BookmarkTableRowView.swift index ebd3a9f318..0f06f84788 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkTableRowView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkTableRowView.swift @@ -16,14 +16,12 @@ // limitations under the License. // -import Foundation +import AppKit final class BookmarkTableRowView: NSTableRowView { var onSelectionChanged: (() -> Void)? - var editing = false - var hasPrevious = false { didSet { needsDisplay = true @@ -56,7 +54,7 @@ final class BookmarkTableRowView: NSTableRowView { backgroundColor.setFill() bounds.fill() - if mouseInside && !editing { + if mouseInside { let path = NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6) NSColor.rowHover.setFill() path.fill() @@ -68,8 +66,6 @@ final class BookmarkTableRowView: NSTableRowView { } override func drawSelection(in dirtyRect: NSRect) { - guard !editing else { return } - var roundedCorners = [NSBezierPath.Corners]() if !hasPrevious { diff --git a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift new file mode 100644 index 0000000000..2c0256bba4 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkDialogView.swift @@ -0,0 +1,123 @@ +// +// AddEditBookmarkDialogView.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 SwiftUI + +struct AddEditBookmarkDialogView: ModalView { + @ObservedObject private var viewModel: AddEditBookmarkDialogCoordinatorViewModel + + init(viewModel: AddEditBookmarkDialogCoordinatorViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Group { + switch viewModel.viewState { + case .bookmark: + addEditBookmarkView + case .folder: + addFolderView + } + } + .font(.system(size: 13)) + } + + private var addEditBookmarkView: some View { + AddEditBookmarkView( + title: viewModel.bookmarkModel.title, + buttonsState: .compressed, + bookmarkName: $viewModel.bookmarkModel.bookmarkName, + bookmarkURLPath: $viewModel.bookmarkModel.bookmarkURLPath, + isBookmarkFavorite: $viewModel.bookmarkModel.isBookmarkFavorite, + folders: viewModel.bookmarkModel.folders, + selectedFolder: $viewModel.bookmarkModel.selectedFolder, + isURLFieldHidden: false, + addFolderAction: viewModel.addFolderAction, + otherActionTitle: viewModel.bookmarkModel.cancelActionTitle, + isOtherActionDisabled: viewModel.bookmarkModel.isOtherActionDisabled, + otherAction: viewModel.bookmarkModel.cancel, + defaultActionTitle: viewModel.bookmarkModel.defaultActionTitle, + isDefaultActionDisabled: viewModel.bookmarkModel.isDefaultActionDisabled, + defaultAction: viewModel.bookmarkModel.addOrSave + ) + .frame(width: 448, height: 288) + } + + private var addFolderView: some View { + AddEditBookmarkFolderView( + title: viewModel.folderModel.title, + buttonsState: .compressed, + folders: viewModel.folderModel.folders, + folderName: $viewModel.folderModel.folderName, + selectedFolder: $viewModel.folderModel.selectedFolder, + cancelActionTitle: viewModel.folderModel.cancelActionTitle, + isCancelActionDisabled: viewModel.folderModel.isOtherActionDisabled, + cancelAction: { _ in + viewModel.dismissAction() + }, + defaultActionTitle: viewModel.folderModel.defaultActionTitle, + isDefaultActionDisabled: viewModel.folderModel.isDefaultActionDisabled, + defaultAction: { _ in + viewModel.folderModel.addOrSave { + viewModel.dismissAction() + } + } + ) + .frame(width: 448, height: 210) + } +} + +// MARK: - Previews + +#if DEBUG +#Preview("Add Bookmark - Light Mode") { + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [])) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkView(parent: nil, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Add Bookmark - Dark Mode") { + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [])) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkView(parent: nil, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} + +#Preview("Edit Bookmark - Light Mode") { + let parentFolder = BookmarkFolder(id: "7", title: "DuckDuckGo") + let bookmark = Bookmark(id: "1", url: "www.duckduckgo.com", title: "DuckDuckGo", isFavorite: true, parentFolderUUID: "7") + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [bookmark, parentFolder])) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Edit Bookmark - Dark Mode") { + let parentFolder = BookmarkFolder(id: "7", title: "DuckDuckGo") + let bookmark = Bookmark(id: "1", url: "www.duckduckgo.com", title: "DuckDuckGo", isFavorite: true, parentFolderUUID: "7") + let bookmarkManager = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: [bookmark, parentFolder])) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} +#endif diff --git a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderDialogView.swift b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderDialogView.swift new file mode 100644 index 0000000000..f859311335 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderDialogView.swift @@ -0,0 +1,107 @@ +// +// AddEditBookmarkFolderDialogView.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 SwiftUI + +struct AddEditBookmarkFolderDialogView: ModalView { + @ObservedObject private var viewModel: AddEditBookmarkFolderDialogViewModel + + init(viewModel: AddEditBookmarkFolderDialogViewModel) { + self.viewModel = viewModel + } + + var body: some View { + AddEditBookmarkFolderView( + title: viewModel.title, + buttonsState: .compressed, + folders: viewModel.folders, + folderName: $viewModel.folderName, + selectedFolder: $viewModel.selectedFolder, + cancelActionTitle: viewModel.cancelActionTitle, + isCancelActionDisabled: viewModel.isOtherActionDisabled, + cancelAction: viewModel.cancel, + defaultActionTitle: viewModel.defaultActionTitle, + isDefaultActionDisabled: viewModel.isDefaultActionDisabled, + defaultAction: viewModel.addOrSave + ) + .font(.system(size: 13)) + .frame(width: 448, height: 210) + } +} + +// MARK: - Previews +#if DEBUG +#Preview("Add Folder To Bookmarks - Light") { + let bookmarkFolder = BookmarkFolder(id: "1", title: "DuckDuckGo", children: []) + let store = BookmarkStoreMock(bookmarks: [bookmarkFolder]) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: nil, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Add Folder To Bookmarks Subfolder - Light") { + let bookmarkFolder = BookmarkFolder(id: "1", title: "DuckDuckGo", children: []) + let store = BookmarkStoreMock(bookmarks: [bookmarkFolder]) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: bookmarkFolder, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Edit Folder - Light") { + let bookmarkFolder = BookmarkFolder(id: "1", title: "DuckDuckGo", children: []) + let store = BookmarkStoreMock(bookmarks: [bookmarkFolder]) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: bookmarkFolder, parentFolder: nil, bookmarkManager: bookmarkManager) + .preferredColorScheme(.light) +} + +#Preview("Add Folder To Bookmarks - Dark") { + let store = BookmarkStoreMock(bookmarks: []) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: nil, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} + +#Preview("Add Folder To Bookmarks Subfolder - Dark") { + let bookmarkFolder = BookmarkFolder(id: "1", title: "DuckDuckGo", children: []) + let store = BookmarkStoreMock(bookmarks: [bookmarkFolder]) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: bookmarkFolder, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} + +#Preview("Edit Folder in Subfolder - Dark") { + let bookmarkFolder = BookmarkFolder(id: "1", title: "DuckDuckGo", children: []) + let store = BookmarkStoreMock(bookmarks: [bookmarkFolder]) + let bookmarkManager = LocalBookmarkManager(bookmarkStore: store) + bookmarkManager.loadBookmarks() + + return BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: bookmarkFolder, parentFolder: bookmarkFolder, bookmarkManager: bookmarkManager) + .preferredColorScheme(.dark) +} +#endif diff --git a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderView.swift b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderView.swift new file mode 100644 index 0000000000..d89cfc7c93 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkFolderView.swift @@ -0,0 +1,132 @@ +// +// AddEditBookmarkFolderView.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 SwiftUI + +struct AddEditBookmarkFolderView: View { + enum ButtonsState { + case compressed + case expanded + } + + let title: String + let buttonsState: ButtonsState + let folders: [FolderViewModel] + @Binding var folderName: String + @Binding var selectedFolder: BookmarkFolder? + + let cancelActionTitle: String + let isCancelActionDisabled: Bool + let cancelAction: @MainActor (_ dismiss: () -> Void) -> Void + + let defaultActionTitle: String + let isDefaultActionDisabled: Bool + let defaultAction: @MainActor (_ dismiss: () -> Void) -> Void + + var body: some View { + BookmarkDialogContainerView( + title: title, + middleSection: { + BookmarkDialogStackedContentView( + .init( + title: UserText.Bookmarks.Dialog.Field.name, + content: TextField("", text: $folderName) + .focusedOnAppear() + .accessibilityIdentifier("bookmark.add.name.textfield") + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + ), + .init( + title: UserText.Bookmarks.Dialog.Field.location, + content: BookmarkFolderPicker( + folders: folders, + selectedFolder: $selectedFolder + ) + .accessibilityIdentifier("bookmark.folder.folder.dropdown") + ) + ) + }, + bottomSection: { + BookmarkDialogButtonsView( + viewState: .init(buttonsState), + otherButtonAction: .init( + title: cancelActionTitle, + keyboardShortCut: .cancelAction, + isDisabled: isCancelActionDisabled, + action: cancelAction + ), defaultButtonAction: .init( + title: defaultActionTitle, + keyboardShortCut: .defaultAction, + isDisabled: isDefaultActionDisabled, + action: defaultAction + ) + ) + } + ) + } +} + +private extension BookmarkDialogButtonsView.ViewState { + + init(_ state: AddEditBookmarkFolderView.ButtonsState) { + switch state { + case .compressed: + self = .compressed + case .expanded: + self = .expanded + } + } +} + +#Preview("Compressed") { + @State var folderName = "" + @State var selectedFolder: BookmarkFolder? + + return AddEditBookmarkFolderView( + title: "Test Title", + buttonsState: .compressed, + folders: [], + folderName: $folderName, + selectedFolder: $selectedFolder, + cancelActionTitle: UserText.cancel, + isCancelActionDisabled: false, + cancelAction: { _ in }, + defaultActionTitle: UserText.save, + isDefaultActionDisabled: false, + defaultAction: { _ in } + ) +} + +#Preview("Expanded") { + @State var folderName = "" + @State var selectedFolder: BookmarkFolder? + + return AddEditBookmarkFolderView( + title: "Test Title", + buttonsState: .expanded, + folders: [], + folderName: $folderName, + selectedFolder: $selectedFolder, + cancelActionTitle: UserText.cancel, + isCancelActionDisabled: false, + cancelAction: { _ in }, + defaultActionTitle: UserText.save, + isDefaultActionDisabled: false, + defaultAction: { _ in } + ) +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkView.swift b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkView.swift new file mode 100644 index 0000000000..8d34889432 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/AddEditBookmarkView.swift @@ -0,0 +1,113 @@ +// +// AddEditBookmarkView.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 SwiftUI + +struct AddEditBookmarkView: View { + let title: String + let buttonsState: BookmarksDialogButtonsState + + @Binding var bookmarkName: String + var bookmarkURLPath: Binding? + @Binding var isBookmarkFavorite: Bool + + let folders: [FolderViewModel] + @Binding var selectedFolder: BookmarkFolder? + + let isURLFieldHidden: Bool + + let addFolderAction: () -> Void + + let otherActionTitle: String + let isOtherActionDisabled: Bool + let otherAction: @MainActor (_ dismiss: () -> Void) -> Void + + let defaultActionTitle: String + let isDefaultActionDisabled: Bool + let defaultAction: @MainActor (_ dismiss: () -> Void) -> Void + + var body: some View { + BookmarkDialogContainerView( + title: title, + middleSection: { + BookmarkDialogStackedContentView( + .init( + title: UserText.Bookmarks.Dialog.Field.name, + content: TextField("", text: $bookmarkName) + .focusedOnAppear() + .accessibilityIdentifier("bookmark.add.name.textfield") + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + ), + .init( + title: UserText.Bookmarks.Dialog.Field.url, + content: TextField("", text: bookmarkURLPath ?? .constant("")) + .accessibilityIdentifier("bookmark.add.url.textfield") + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)), + isContentViewHidden: isURLFieldHidden + ), + .init( + title: UserText.Bookmarks.Dialog.Field.location, + content: BookmarkDialogFolderManagementView( + folders: folders, + selectedFolder: $selectedFolder, + onActionButton: addFolderAction + ) + ) + ) + BookmarkFavoriteView(isFavorite: $isBookmarkFavorite) + }, + bottomSection: { + BookmarkDialogButtonsView( + viewState: .init(buttonsState), + otherButtonAction: .init( + title: otherActionTitle, + isDisabled: isOtherActionDisabled, + action: otherAction + ), + defaultButtonAction: .init( + title: defaultActionTitle, + keyboardShortCut: .defaultAction, + isDisabled: isDefaultActionDisabled, + action: defaultAction + ) + ) + } + ) + } + +} + +// MARK: - BookmarksDialogButtonsState + +enum BookmarksDialogButtonsState { + case compressed + case expanded +} + +extension BookmarkDialogButtonsView.ViewState { + init(_ state: BookmarksDialogButtonsState) { + switch state { + case .compressed: + self = .compressed + case .expanded: + self = .expanded + } + } +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogButtonsView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogButtonsView.swift new file mode 100644 index 0000000000..7726516704 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogButtonsView.swift @@ -0,0 +1,186 @@ +// +// BookmarkDialogButtonsView.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 SwiftUI + +struct BookmarkDialogButtonsView: View { + private let viewState: ViewState + private let otherButtonAction: Action + private let defaultButtonAction: Action + @Environment(\.dismiss) private var dismiss + + init( + viewState: ViewState, + otherButtonAction: Action, + defaultButtonAction: Action + ) { + self.viewState = viewState + self.otherButtonAction = otherButtonAction + self.defaultButtonAction = defaultButtonAction + } + + var body: some View { + HStack { + if viewState == .compressed { + Spacer() + } + + actionButton(action: otherButtonAction, viewState: viewState) + + actionButton(action: defaultButtonAction, viewState: viewState) + } + } + + @MainActor + private func actionButton(action: Action, viewState: ViewState) -> some View { + Button { + action.action(dismiss.callAsFunction) + } label: { + Text(action.title) + .frame(height: viewState.height) + .frame(maxWidth: viewState.maxWidth) + } + .keyboardShortcut(action.keyboardShortCut) + .disabled(action.isDisabled) + .ifLet(action.accessibilityIdentifier) { view, value in + view.accessibilityIdentifier(value) + } + } +} + +// MARK: - BookmarkDialogButtonsView + Types + +extension BookmarkDialogButtonsView { + + enum ViewState: Equatable { + case compressed + case expanded + } + + struct Action { + let title: String + let keyboardShortCut: KeyboardShortcut? + let accessibilityIdentifier: String? + let isDisabled: Bool + let action: @MainActor (_ dismiss: () -> Void) -> Void + + init( + title: String, + accessibilityIdentifier: String? = nil, + keyboardShortCut: KeyboardShortcut? = nil, + isDisabled: Bool = false, + action: @MainActor @escaping (_ dismiss: () -> Void) -> Void + ) { + self.title = title + self.keyboardShortCut = keyboardShortCut + self.accessibilityIdentifier = accessibilityIdentifier + self.isDisabled = isDisabled + self.action = action + } + } +} + +// MARK: - BookmarkDialogButtonsView.ViewState + +private extension BookmarkDialogButtonsView.ViewState { + + var maxWidth: CGFloat? { + switch self { + case .compressed: + return nil + case .expanded: + return .infinity + } + } + + var height: CGFloat? { + switch self { + case .compressed: + return nil + case .expanded: + return 28.0 + } + } + +} + +// MARK: - Preview + +#Preview("Compressed - Disable Default Button") { + BookmarkDialogButtonsView( + viewState: .compressed, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: true, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Compressed - Enabled Default Button") { + BookmarkDialogButtonsView( + viewState: .compressed, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: false, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Expanded - Disable Default Button") { + BookmarkDialogButtonsView( + viewState: .expanded, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: true, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} + +#Preview("Expanded - Enable Default Button") { + BookmarkDialogButtonsView( + viewState: .expanded, + otherButtonAction: .init( + title: "Left", + action: { _ in } + ), + defaultButtonAction: .init( + title: "Right", + isDisabled: false, + action: {_ in } + ) + ) + .frame(width: 320, height: 50) +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift new file mode 100644 index 0000000000..ea49712abb --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogContainerView.swift @@ -0,0 +1,50 @@ +// +// BookmarkDialogContainerView.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 SwiftUI +import SwiftUIExtensions + +struct BookmarkDialogContainerView: View { + private let title: String + @ViewBuilder private let middleSection: () -> Content + @ViewBuilder private let bottomSection: () -> Buttons + + init( + title: String, + @ViewBuilder middleSection: @escaping () -> Content, + @ViewBuilder bottomSection: @escaping () -> Buttons + ) { + self.title = title + self.middleSection = middleSection + self.bottomSection = bottomSection + } + + var body: some View { + TieredDialogView( + verticalSpacing: 16.0, + horizontalPadding: 20.0, + top: { + Text(title) + .foregroundColor(.primary) + .fontWeight(.semibold) + }, + center: middleSection, + bottom: bottomSection + ) + } +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogFolderManagementView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogFolderManagementView.swift new file mode 100644 index 0000000000..8081abc14d --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogFolderManagementView.swift @@ -0,0 +1,76 @@ +// +// BookmarkDialogFolderManagementView.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 SwiftUI +import SwiftUIExtensions + +struct BookmarkDialogFolderManagementView: View { + private let folders: [FolderViewModel] + private var selectedFolder: Binding + private let onActionButton: @MainActor () -> Void + + init( + folders: [FolderViewModel], + selectedFolder: Binding, + onActionButton: @escaping @MainActor () -> Void + ) { + self.folders = folders + self.selectedFolder = selectedFolder + self.onActionButton = onActionButton + } + + var body: some View { + HStack { + BookmarkFolderPicker( + folders: folders, + selectedFolder: selectedFolder + ) + .accessibilityIdentifier("bookmark.add.folder.dropdown") + + Button { + onActionButton() + } label: { + Image(.addFolder) + } + .accessibilityIdentifier("bookmark.add.new.folder.button") + .buttonStyle(StandardButtonStyle()) + } + } +} + +#Preview { + @State var selectedFolder: BookmarkFolder? = BookmarkFolder(id: "1", title: "Nested Folder", children: []) + let folderViewModels: [FolderViewModel] = [ + .init( + entity: .init( + id: "1", + title: "Nested Folder", + parentFolderUUID: nil, + children: [] + ), + level: 1 + ) + ] + + return BookmarkDialogFolderManagementView( + folders: folderViewModels, + selectedFolder: $selectedFolder, + onActionButton: {} + ) + .frame(width: 400) +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogStackedContentView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogStackedContentView.swift new file mode 100644 index 0000000000..864b0cdb13 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkDialogStackedContentView.swift @@ -0,0 +1,111 @@ +// +// BookmarkDialogStackedContentView.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 SwiftUI +import SwiftUIExtensions + +struct BookmarkDialogStackedContentView: View { + private let items: [Item] + + init(_ items: Item...) { + self.items = items + } + + init(_ items: [Item]) { + self.items = items + } + + var body: some View { + TwoColumnsListView( + horizontalSpacing: 16.0, + verticalSpacing: 20.0, + rowHeight: 22.0, + leftColumn: { + ForEach(items, id: \.title) { item in + if !item.isContentViewHidden { + Text(item.title) + .foregroundColor(.primary) + .fontWeight(.medium) + } + } + }, + rightColumn: { + ForEach(items, id: \.title) { item in + if !item.isContentViewHidden { + item.content + } + } + } + ) + } +} + +// MARK: - BookmarkModalStackedContentView + Item + +extension BookmarkDialogStackedContentView { + struct Item { + fileprivate let title: String + fileprivate let content: AnyView + fileprivate let isContentViewHidden: Bool + + init(title: String, content: any View, isContentViewHidden: Bool = false) { + self.title = title + self.content = AnyView(content) + self.isContentViewHidden = isContentViewHidden + } + } +} + +// MARK: - Preview + +#Preview { + @State var name: String = "DuckDuckGo" + @State var url: String = "https://www.duckduckgo.com" + @State var selectedFolder: BookmarkFolder? + + return BookmarkDialogStackedContentView( + .init( + title: "Name", + content: + TextField("", text: $name) + .textFieldStyle(.roundedBorder) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + + ), + .init( + title: "URL", + content: + TextField("", text: $url) + .textFieldStyle(.roundedBorder) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(size: 14)) + ), + .init( + title: "Location", + content: + BookmarkDialogFolderManagementView( + folders: [], + selectedFolder: $selectedFolder, + onActionButton: { } + ) + ) + ) + .padding([.horizontal, .vertical]) + .frame(width: 400) +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarkFavoriteView.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkFavoriteView.swift new file mode 100644 index 0000000000..9778ab0d5c --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarkFavoriteView.swift @@ -0,0 +1,47 @@ +// +// BookmarkFavoriteView.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 SwiftUI +import SwiftUIExtensions +import PreferencesViews + +struct BookmarkFavoriteView: View { + @Binding var isFavorite: Bool + + var body: some View { + Toggle(isOn: $isFavorite) { + HStack(spacing: 6) { + Image(.favoriteFilledBorder) + Text(UserText.addToFavorites) + .foregroundColor(.primary) + } + } + .toggleStyle(.checkbox) + .accessibilityIdentifier("bookmark.add.add.to.favorites.button") + } +} + +#Preview("Favorite") { + BookmarkFavoriteView(isFavorite: .constant(true)) + .frame(width: 300) +} + +#Preview("Not Favorite") { + BookmarkFavoriteView(isFavorite: .constant(false)) + .frame(width: 300) +} diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift new file mode 100644 index 0000000000..b29b50bbbb --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift @@ -0,0 +1,93 @@ +// +// BookmarksDialogViewFactory.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 SwiftUI + +@MainActor +enum BookmarksDialogViewFactory { + + /// Creates an instance of AddEditBookmarkFolderDialogView for adding a Bookmark Folder. + /// - Parameters: + /// - parentFolder: An optional `BookmarkFolder`. When adding a folder to the root bookmark folder pass `nil`. For any other folder pass the `BookmarkFolder` the new folder should be within. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkFolderDialogView. + static func makeAddBookmarkFolderView(parentFolder: BookmarkFolder?, bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkFolderDialogView { + let viewModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: parentFolder), bookmarkManager: bookmarkManager) + return AddEditBookmarkFolderDialogView(viewModel: viewModel) + } + + /// Creates an instance of AddEditBookmarkFolderDialogView for editing a Bookmark Folder. + /// - Parameters: + /// - folder: The `BookmarkFolder` to edit. + /// - parentFolder: An optional `BookmarkFolder`. When editing a folder within the root bookmark folder pass `nil`. For any other folder pass the `BookmarkFolder` the folder belongs to. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkFolderDialogView. + static func makeEditBookmarkFolderView(folder: BookmarkFolder, parentFolder: BookmarkFolder?, bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkFolderDialogView { + let viewModel = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: parentFolder), bookmarkManager: bookmarkManager) + return AddEditBookmarkFolderDialogView(viewModel: viewModel) + } + + /// Creates an instance of AddEditBookmarkDialogView for adding a Bookmark with the specified web page. + /// - Parameters: + /// - currentTab: An optional `WebsiteInfo`. When adding a bookmark from the bookmark shortcut panel, if the `Tab` has loaded a web page pass the information via the `currentTab`. If the `Tab` has not loaded a tab pass `nil`. If adding a `Bookmark` from the `Manage Bookmark` settings page, pass `nil`. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkDialogView. + static func makeAddBookmarkView(currentTab: WebsiteInfo?, bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkDialogView { + let viewModel = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: currentTab), bookmarkManager: bookmarkManager) + return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) + } + + /// Creates an instance of AddEditBookmarkDialogView for adding a Bookmark with the specified parent folder. + /// - Parameters: + /// - parentFolder: An optional `BookmarkFolder`. When adding a bookmark from the bookmark management view, if the user select a parent folder pass this value won't be `nil`. Otherwise, if no folder is selected this value will be `nil`. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkDialogView. + static func makeAddBookmarkView(parent: BookmarkFolder?, bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkDialogView { + let viewModel = AddEditBookmarkDialogViewModel(mode: .add(parentFolder: parent), bookmarkManager: bookmarkManager) + return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) + } + + /// Creates an instance of AddEditBookmarkDialogView for adding a Bookmark from the Favorites view in the empty Tab. + /// - Parameter bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkDialogView, + static func makeAddFavoriteView(bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkDialogView { + let viewModel = AddEditBookmarkDialogViewModel(mode: .add(shouldPresetFavorite: true), bookmarkManager: bookmarkManager) + return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) + } + + /// Creates an instance of AddEditBookmarkDialogView for editing a Bookmark. + /// - Parameters: + /// - bookmark: The `Bookmark` to edit. + /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. + /// - Returns: An instance of AddEditBookmarkDialogView. + static func makeEditBookmarkView(bookmark: Bookmark, bookmarkManager: LocalBookmarkManager = .shared) -> AddEditBookmarkDialogView { + let viewModel = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) + } + +} + +private extension BookmarksDialogViewFactory { + + private static func makeAddEditBookmarkDialogView(viewModel: AddEditBookmarkDialogViewModel, bookmarkManager: BookmarkManager) -> AddEditBookmarkDialogView { + let addFolderViewModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: nil), bookmarkManager: bookmarkManager) + let viewModel = AddEditBookmarkDialogCoordinatorViewModel(bookmarkModel: viewModel, folderModel: addFolderViewModel) + return AddEditBookmarkDialogView(viewModel: viewModel) + } + +} diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderModalViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderModalViewModel.swift deleted file mode 100644 index ac2701e3ca..0000000000 --- a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderModalViewModel.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// AddBookmarkFolderModalViewModel.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 AddBookmarkFolderModalViewModel { - - let bookmarkManager: BookmarkManager - - let title: String - let addButtonTitle: String - let originalFolder: BookmarkFolder? - let parent: BookmarkFolder? - - var folderName: String = "" - - var isAddButtonDisabled: Bool { - folderName.trimmingWhitespace().isEmpty - } - - init(folder: BookmarkFolder, - bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, - completionHandler: @escaping (BookmarkFolder?) -> Void = { _ in }) { - self.bookmarkManager = bookmarkManager - self.folderName = folder.title - self.originalFolder = folder - self.parent = nil - self.title = UserText.renameFolder - self.addButtonTitle = UserText.save - } - - init(parent: BookmarkFolder? = nil, - bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, - completionHandler: @escaping (BookmarkFolder?) -> Void = { _ in }) { - self.bookmarkManager = bookmarkManager - self.originalFolder = nil - self.parent = parent - self.title = UserText.newFolder - self.addButtonTitle = UserText.newFolderDialogAdd - } - - func cancel(dismiss: () -> Void) { - dismiss() - } - - func addFolder(dismiss: () -> Void) { - guard !folderName.isEmpty else { - assertionFailure("folderName is empty, button should be disabled") - return - } - - let folderName = folderName.trimmingWhitespace() - if let folder = originalFolder { - folder.title = folderName - bookmarkManager.update(folder: folder) - - } else { - bookmarkManager.makeFolder(for: folderName, parent: parent, completion: { _ in }) - } - - dismiss() - } - -} diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderPopoverViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderPopoverViewModel.swift index c16625a362..a1359fa61b 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderPopoverViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkFolderPopoverViewModel.swift @@ -22,15 +22,30 @@ import Foundation final class AddBookmarkFolderPopoverViewModel: ObservableObject { private let bookmarkManager: BookmarkManager - let folders: [FolderViewModel] - @Published var parent: BookmarkFolder? + private let completionHandler: (BookmarkFolder?) -> Void + @Published var parent: BookmarkFolder? @Published var folderName: String = "" - @Published private(set) var isDisabled = false - private let completionHandler: (BookmarkFolder?) -> Void + var title: String { + UserText.Bookmarks.Dialog.Title.addFolder + } + + var cancelActionTitle: String { + UserText.cancel + } + + var defaultActionTitle: String { + UserText.Bookmarks.Dialog.Action.addFolder + } + + let isCancelActionDisabled = false + + var isDefaultActionButtonDisabled: Bool { + folderName.trimmingWhitespace().isEmpty || isDisabled + } init(bookmark: Bookmark? = nil, folderName: String = "", diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkModalViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkModalViewModel.swift deleted file mode 100644 index 15554afcbc..0000000000 --- a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkModalViewModel.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// AddBookmarkModalViewModel.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 AddBookmarkModalViewModel { - - let bookmarkManager: BookmarkManager - - let title: String - let addButtonTitle: String - var isFavorite: Bool - - private let originalBookmark: Bookmark? - private let parent: BookmarkFolder? - - private let completionHandler: (Bookmark?) -> Void - - var bookmarkTitle: String = "" - var bookmarkAddress: String = "" - - private var hasValidInput: Bool { - guard let url = bookmarkAddress.url else { return false } - - return !bookmarkTitle.trimmingWhitespace().isEmpty && url.isValid - } - - var isAddButtonDisabled: Bool { !hasValidInput } - - func cancel(dismiss: () -> Void) { - completionHandler(nil) - dismiss() - } - - func addOrSave(dismiss: () -> Void) { - guard let url = bookmarkAddress.url else { - assertionFailure("invalid URL, button should be disabled") - return - } - - var result: Bookmark? - let bookmarkTitle = bookmarkTitle.trimmingWhitespace() - if var bookmark = originalBookmark ?? bookmarkManager.getBookmark(for: url) { - - if url.absoluteString != bookmark.url { - bookmark = bookmarkManager.updateUrl(of: bookmark, to: url) ?? bookmark - } - if bookmark.title != bookmarkTitle || bookmark.isFavorite != isFavorite { - bookmark.title = bookmarkTitle - bookmark.isFavorite = isFavorite - bookmarkManager.update(bookmark: bookmark) - } - - result = bookmark - - } else if !bookmarkManager.isUrlBookmarked(url: url) { - result = bookmarkManager.makeBookmark(for: url, title: bookmarkTitle, isFavorite: isFavorite, index: nil, parent: parent) - } - - completionHandler(result) - dismiss() - } - - init(isFavorite: Bool = false, - bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, - currentTabWebsite website: WebsiteInfo? = nil, - parent: BookmarkFolder? = nil, - completionHandler: @escaping (Bookmark?) -> Void = { _ in }) { - - self.bookmarkManager = bookmarkManager - - self.isFavorite = isFavorite - self.title = isFavorite ? UserText.addFavorite : UserText.newBookmark - self.addButtonTitle = UserText.bookmarkDialogAdd - - if let website, - !LocalBookmarkManager.shared.isUrlBookmarked(url: website.url) { - bookmarkTitle = website.title ?? "" - bookmarkAddress = website.url.absoluteString - } - self.parent = parent - self.originalBookmark = nil - - self.completionHandler = completionHandler - } - - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, - originalBookmark: Bookmark?, - isFavorite: Bool = false, - completionHandler: @escaping (Bookmark?) -> Void = { _ in }) { - - self.bookmarkManager = bookmarkManager - - self.isFavorite = isFavorite - if originalBookmark != nil { - self.title = isFavorite ? UserText.editFavorite : UserText.updateBookmark - self.addButtonTitle = UserText.save - } else { - self.title = isFavorite ? UserText.addFavorite : UserText.newBookmark - self.addButtonTitle = UserText.bookmarkDialogAdd - } - - self.parent = nil - self.originalBookmark = originalBookmark - - bookmarkTitle = originalBookmark?.title ?? "" - bookmarkAddress = originalBookmark?.url ?? "" - - self.completionHandler = completionHandler - } - -} diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkPopoverViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkPopoverViewModel.swift index b474e148b3..88168901ed 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkPopoverViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/AddBookmarkPopoverViewModel.swift @@ -38,8 +38,24 @@ final class AddBookmarkPopoverViewModel: ObservableObject { } } + @Published var isBookmarkFavorite: Bool { + didSet { + bookmark.isFavorite = isBookmarkFavorite + bookmarkManager.update(bookmark: bookmark) + } + } + + @Published var bookmarkTitle: String { + didSet { + bookmark.title = bookmarkTitle.trimmingWhitespace() + bookmarkManager.update(bookmark: bookmark) + } + } + @Published var addFolderViewModel: AddBookmarkFolderPopoverViewModel? + let isDefaultActionButtonDisabled: Bool = false + private var bookmarkListCancellable: AnyCancellable? init(bookmark: Bookmark, @@ -47,6 +63,7 @@ final class AddBookmarkPopoverViewModel: ObservableObject { self.bookmarkManager = bookmarkManager self.bookmark = bookmark self.bookmarkTitle = bookmark.title + self.isBookmarkFavorite = bookmark.isFavorite bookmarkListCancellable = bookmarkManager.listPublisher .receive(on: DispatchQueue.main) @@ -74,12 +91,6 @@ final class AddBookmarkPopoverViewModel: ObservableObject { dismiss() } - func favoritesButtonAction() { - bookmark.isFavorite.toggle() - - bookmarkManager.update(bookmark: bookmark) - } - func addFolderButtonAction() { addFolderViewModel = .init(bookmark: bookmark, bookmarkManager: bookmarkManager) { [bookmark, bookmarkManager, weak self] newFolder in if let newFolder { @@ -98,14 +109,6 @@ final class AddBookmarkPopoverViewModel: ObservableObject { addFolderViewModel = nil } - @Published var bookmarkTitle: String { - didSet { - bookmark.title = bookmarkTitle.trimmingWhitespace() - - bookmarkManager.update(bookmark: bookmark) - } - } - } struct FolderViewModel: Identifiable, Equatable { diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogCoordinatorViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogCoordinatorViewModel.swift new file mode 100644 index 0000000000..27fc0c64e5 --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogCoordinatorViewModel.swift @@ -0,0 +1,74 @@ +// +// AddEditBookmarkDialogCoordinatorViewModel.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 SwiftUI +import Combine + +final class AddEditBookmarkDialogCoordinatorViewModel: ObservableObject { + @ObservedObject var bookmarkModel: BookmarkViewModel + @ObservedObject var folderModel: AddFolderViewModel + @Published var viewState: ViewState + + private var cancellables: Set = [] + + init(bookmarkModel: BookmarkViewModel, folderModel: AddFolderViewModel) { + self.bookmarkModel = bookmarkModel + self.folderModel = folderModel + viewState = .bookmark + bind() + } + + func dismissAction() { + viewState = .bookmark + } + + func addFolderAction() { + folderModel.selectedFolder = bookmarkModel.selectedFolder + viewState = .folder + } + + private func bind() { + bookmarkModel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + folderModel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + folderModel.addFolderPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] bookmarkFolder in + self?.bookmarkModel.selectedFolder = bookmarkFolder + } + .store(in: &cancellables) + } +} + +extension AddEditBookmarkDialogCoordinatorViewModel { + enum ViewState { + case bookmark + case folder + } +} diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift new file mode 100644 index 0000000000..0aa03eade1 --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift @@ -0,0 +1,216 @@ +// +// AddEditBookmarkDialogViewModel.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 Combine + +@MainActor +protocol BookmarkDialogEditing: BookmarksDialogViewModel { + var bookmarkName: String { get set } + var bookmarkURLPath: String { get set } + var isBookmarkFavorite: Bool { get set } + + var isURLFieldHidden: Bool { get } +} + +@MainActor +final class AddEditBookmarkDialogViewModel: BookmarkDialogEditing { + + /// The type of operation to perform on a bookmark. + enum Mode { + /// Add a new bookmark. Bookmarks can have a parent folder but not necessarily. + /// If the users add a bookmark to the root `Bookmarks` folder, then the parent folder is `nil`. + /// If the users add a bookmark to a different folder then the parent folder is not `nil`. + /// If the users add a bookmark from the bookmark shortcut and `Tab` has a page loaded, then the `tabWebsite` is not `nil`. + /// When adding a bookmark from favorite screen the `shouldPresetFavorite` flag should be set to `true`. + case add(tabWebsite: WebsiteInfo? = nil, parentFolder: BookmarkFolder? = nil, shouldPresetFavorite: Bool = false) + /// Edit an existing bookmark. + case edit(bookmark: Bookmark) + } + + @Published var bookmarkName: String + @Published var bookmarkURLPath: String + @Published var isBookmarkFavorite: Bool + + @Published private(set) var folders: [FolderViewModel] + @Published var selectedFolder: BookmarkFolder? + + private var folderCancellable: AnyCancellable? + + var title: String { + mode.title + } + + let isURLFieldHidden: Bool = false + + var cancelActionTitle: String { + mode.cancelActionTitle + } + + var defaultActionTitle: String { + mode.defaultActionTitle + } + + private var hasValidInput: Bool { + guard let url = bookmarkURLPath.url else { return false } + return !bookmarkName.trimmingWhitespace().isEmpty && url.isValid + } + + let isOtherActionDisabled: Bool = false + + var isDefaultActionDisabled: Bool { !hasValidInput } + + private let mode: Mode + private let bookmarkManager: BookmarkManager + + init(mode: Mode, bookmarkManager: LocalBookmarkManager = .shared) { + let isFavorite = mode.bookmarkURL.flatMap(bookmarkManager.isUrlFavorited) ?? false + self.mode = mode + self.bookmarkManager = bookmarkManager + folders = .init(bookmarkManager.list) + switch mode { + case let .add(websiteInfo, parentFolder, shouldPresetFavorite): + // When adding a new bookmark with website info we need to show the bookmark name and URL only if the bookmark is not bookmarked already. + // Scenario we click on the "Add Bookmark" button from Bookmarks shortcut Panel. If Tab has a Bookmark loaded we present the dialog with prepopulated name and URL from the tab. + // If we save and click again on the "Add Bookmark" button we don't want to try re-add the same bookmark. Hence we present a dialog that is not pre-populated. + let isAlreadyBookmarked = websiteInfo.flatMap { bookmarkManager.isUrlBookmarked(url: $0.url) } ?? false + let websiteName = isAlreadyBookmarked ? "" : websiteInfo?.title ?? "" + let websiteURLPath = isAlreadyBookmarked ? "" : websiteInfo?.url.absoluteString ?? "" + bookmarkName = websiteName + bookmarkURLPath = websiteURLPath + isBookmarkFavorite = shouldPresetFavorite ? true : isFavorite + selectedFolder = parentFolder + case let .edit(bookmark): + bookmarkName = bookmark.title + bookmarkURLPath = bookmark.urlObject?.absoluteString ?? "" + isBookmarkFavorite = isFavorite + selectedFolder = folders.first(where: { $0.id == bookmark.parentFolderUUID })?.entity + } + + bind() + } + + func cancel(dismiss: () -> Void) { + dismiss() + } + + func addOrSave(dismiss: () -> Void) { + guard let url = bookmarkURLPath.url else { + assertionFailure("Invalid URL, default action button should be disabled.") + return + } + + let trimmedBookmarkName = bookmarkName.trimmingWhitespace() + + switch mode { + case .add: + addBookmark(withURL: url, name: trimmedBookmarkName, isFavorite: isBookmarkFavorite, to: selectedFolder) + case let .edit(bookmark): + updateBookmark(bookmark, url: url, name: trimmedBookmarkName, isFavorite: isBookmarkFavorite, location: selectedFolder) + } + dismiss() + } +} + +private extension AddEditBookmarkDialogViewModel { + + func bind() { + folderCancellable = bookmarkManager.listPublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] bookmarkList in + self?.folders = .init(bookmarkList) + }) + } + + func updateBookmark(_ bookmark: Bookmark, url: URL, name: String, isFavorite: Bool, location: BookmarkFolder?) { + // If the URL or Title or Favorite is changed update bookmark + if bookmark.url != url.absoluteString || bookmark.title != name || bookmark.isFavorite != isBookmarkFavorite { + bookmarkManager.update(bookmark: bookmark, withURL: url, title: name, isFavorite: isFavorite) + } + + // If the bookmark changed parent location, move it. + if shouldMove(bookmark: bookmark) { + let parentFolder: ParentFolderType = selectedFolder.flatMap { .parent(uuid: $0.id) } ?? .root + bookmarkManager.move(objectUUIDs: [bookmark.id], toIndex: nil, withinParentFolder: parentFolder, completion: { _ in }) + } + } + + func addBookmark(withURL url: URL, name: String, isFavorite: Bool, to parent: BookmarkFolder?) { + // If a bookmark already exist with the new URL, update it + if let existingBookmark = bookmarkManager.getBookmark(for: url) { + updateBookmark(existingBookmark, url: url, name: name, isFavorite: isFavorite, location: parent) + } else { + bookmarkManager.makeBookmark(for: url, title: name, isFavorite: isFavorite, index: nil, parent: parent) + } + } + + func shouldMove(bookmark: Bookmark) -> Bool { + // There's a discrepancy in representing the root folder. A bookmark belonging to the root folder has `parentFolderUUID` equal to `bookmarks_root`. + // There's no `BookmarkFolder` to represent the root folder, so the root folder is represented by a nil selectedFolder. + // Move Bookmarks if its parent folder is != from the selected folder but ONLY if: + // - The selected folder is not nil. This ensure we're comparing a subfolder with any bookmark parent folder. + // - The selected folder is nil and the bookmark parent folder is not the root folder. This ensure we're not unnecessarily moving the items within the same root folder. + bookmark.parentFolderUUID != selectedFolder?.id && (selectedFolder != nil || selectedFolder == nil && !bookmark.isParentFolderRoot) + } +} + +private extension AddEditBookmarkDialogViewModel.Mode { + + var title: String { + switch self { + case .add: + return UserText.Bookmarks.Dialog.Title.addBookmark + case .edit: + return UserText.Bookmarks.Dialog.Title.editBookmark + } + } + + var cancelActionTitle: String { + switch self { + case .add, .edit: + return UserText.cancel + } + } + + var defaultActionTitle: String { + switch self { + case .add: + return UserText.Bookmarks.Dialog.Action.addBookmark + case .edit: + return UserText.save + } + } + + var bookmarkURL: URL? { + switch self { + case let .add(tabInfo, _, _): + return tabInfo?.url + case let .edit(bookmark): + return bookmark.urlObject + } + } + +} + +private extension Bookmark { + + var isParentFolderRoot: Bool { + parentFolderUUID == "bookmarks_root" + } + +} diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift new file mode 100644 index 0000000000..62c1e0356c --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkFolderDialogViewModel.swift @@ -0,0 +1,181 @@ +// +// AddEditBookmarkFolderDialogViewModel.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 Combine + +@MainActor +protocol BookmarkFolderDialogEditing: BookmarksDialogViewModel { + var addFolderPublisher: AnyPublisher { get } + var folderName: String { get set } +} + +@MainActor +final class AddEditBookmarkFolderDialogViewModel: BookmarkFolderDialogEditing { + + /// The type of operation to perform on a folder + enum Mode { + /// Add a new folder. Folders can have a parent folder but not necessarily. + /// If the users add a folder to a folder whose parent is the root `Bookmarks` folder, then the parent folder is `nil`. + /// If the users add a folder to a folder whose parent is not the root `Bookmarks` folder, then the parent folder is not `nil`. + case add(parentFolder: BookmarkFolder? = nil) + /// Edit an existing folder. Existing folder can have a parent folder but not necessarily. + /// If the users edit a folder whose parent is the root `Bookmarks` folder, then the parent folder is `nil` + /// If the users edit a folder whose parent is not the root `Bookmarks` folder, then the parent folder is not `nil`. + case edit(folder: BookmarkFolder, parentFolder: BookmarkFolder?) + } + + @Published var folderName: String + @Published var selectedFolder: BookmarkFolder? + + let folders: [FolderViewModel] + + var title: String { + mode.title + } + + var cancelActionTitle: String { + mode.cancelActionTitle + } + + var defaultActionTitle: String { + mode.defaultActionTitle + } + + let isOtherActionDisabled = false + + var isDefaultActionDisabled: Bool { + folderName.trimmingWhitespace().isEmpty + } + + var addFolderPublisher: AnyPublisher { + addFolderSubject.eraseToAnyPublisher() + } + + private let mode: Mode + private let bookmarkManager: BookmarkManager + private let addFolderSubject: PassthroughSubject = .init() + + init(mode: Mode, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + self.mode = mode + self.bookmarkManager = bookmarkManager + folderName = mode.folderName + folders = .init(bookmarkManager.list) + selectedFolder = mode.parentFolder + } + + func cancel(dismiss: () -> Void) { + dismiss() + } + + func addOrSave(dismiss: () -> Void) { + defer { dismiss() } + + guard !folderName.isEmpty else { + assertionFailure("folderName is empty, button should be disabled") + return + } + + let folderName = folderName.trimmingWhitespace() + + switch mode { + case let .edit(folder, originalParent): + // If there are no pending changes dismiss + guard folder.title != folderName || selectedFolder?.id != originalParent?.id else { return } + // Otherwise update Folder. + update(folder: folder, originalParent: originalParent, newParent: selectedFolder) + case .add: + add(folderWithName: folderName, to: selectedFolder) + } + } + +} + +// MARK: - Private + +private extension AddEditBookmarkFolderDialogViewModel { + + func update(folder: BookmarkFolder, originalParent: BookmarkFolder?, newParent: BookmarkFolder?) { + // If the original location of the folder changed move it to the new folder. + if selectedFolder?.id != originalParent?.id { + // Update the title anyway. + folder.title = folderName + let parentFolderType: ParentFolderType = newParent.flatMap { ParentFolderType.parent(uuid: $0.id) } ?? .root + bookmarkManager.update(folder: folder, andMoveToParent: parentFolderType) + } else if folder.title != folderName { // If only title changed just update the folder title without updating its parent. + folder.title = folderName + bookmarkManager.update(folder: folder) + } + } + + func add(folderWithName name: String, to parent: BookmarkFolder?) { + bookmarkManager.makeFolder(for: name, parent: parent) { [weak self] bookmarkFolder in + self?.addFolderSubject.send(bookmarkFolder) + } + } + +} + +// MARK: - AddEditBookmarkFolderDialogViewModel.Mode + +private extension AddEditBookmarkFolderDialogViewModel.Mode { + + var title: String { + switch self { + case .add: + return UserText.Bookmarks.Dialog.Title.addFolder + case .edit: + return UserText.Bookmarks.Dialog.Title.editFolder + } + } + + var cancelActionTitle: String { + switch self { + case .add, .edit: + return UserText.cancel + } + } + + var defaultActionTitle: String { + switch self { + case .add: + return UserText.Bookmarks.Dialog.Action.addFolder + case .edit: + return UserText.save + } + } + + var folderName: String { + switch self { + case .add: + return "" + case let .edit(folder, _): + return folder.title + } + } + + var parentFolder: BookmarkFolder? { + switch self { + case let .add(parentFolder): + return parentFolder + case let .edit(_, parentFolder): + return parentFolder + } + } + +} diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarksDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarksDialogViewModel.swift new file mode 100644 index 0000000000..08ef2cc47d --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarksDialogViewModel.swift @@ -0,0 +1,35 @@ +// +// BookmarksDialogViewModel.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 + +@MainActor +protocol BookmarksDialogViewModel: ObservableObject { + var title: String { get } + + var folders: [FolderViewModel] { get } + var selectedFolder: BookmarkFolder? { get set } + + var cancelActionTitle: String { get } + var isOtherActionDisabled: Bool { get } + var defaultActionTitle: String { get } + var isDefaultActionDisabled: Bool { get } + + func cancel(dismiss: () -> Void) + func addOrSave(dismiss: () -> Void) +} diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBarCollectionViewItem.swift b/DuckDuckGo/BookmarksBar/View/BookmarksBarCollectionViewItem.swift index 61c0ec7630..dd9d2e617f 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBarCollectionViewItem.swift +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBarCollectionViewItem.swift @@ -24,11 +24,13 @@ protocol BookmarksBarCollectionViewItemDelegate: AnyObject { func bookmarksBarCollectionViewItemOpenInNewTabAction(_ item: BookmarksBarCollectionViewItem) func bookmarksBarCollectionViewItemOpenInNewWindowAction(_ item: BookmarksBarCollectionViewItem) - func bookmarksBarCollectionViewItemAddToFavoritesAction(_ item: BookmarksBarCollectionViewItem) + func bookmarksBarCollectionViewItemToggleFavoritesAction(_ item: BookmarksBarCollectionViewItem) func bookmarksBarCollectionViewEditAction(_ item: BookmarksBarCollectionViewItem) func bookmarksBarCollectionViewItemMoveToEndAction(_ item: BookmarksBarCollectionViewItem) func bookmarksBarCollectionViewItemCopyBookmarkURLAction(_ item: BookmarksBarCollectionViewItem) func bookmarksBarCollectionViewItemDeleteEntityAction(_ item: BookmarksBarCollectionViewItem) + func bookmarksBarCollectionViewItemAddEntityAction(_ item: BookmarksBarCollectionViewItem) + func bookmarksBarCollectionViewItemManageBookmarksAction(_ item: BookmarksBarCollectionViewItem) } @@ -128,114 +130,72 @@ extension BookmarksBarCollectionViewItem: NSMenuDelegate { switch entityType { case .bookmark(_, _, _, let isFavorite): - menu.items = createBookmarkMenuItems(isFavorite: isFavorite) + menu.items = ContextualMenu.bookmarkMenuItems(isFavorite: isFavorite) case .folder: - menu.items = createFolderMenuItems() + menu.items = ContextualMenu.folderMenuItems() } } } -extension BookmarksBarCollectionViewItem { +extension BookmarksBarCollectionViewItem: BookmarkMenuItemSelectors { - // MARK: Bookmark Menu Items - - func createBookmarkMenuItems(isFavorite: Bool) -> [NSMenuItem] { - let items = [ - openBookmarkInNewTabMenuItem(), - openBookmarkInNewWindowMenuItem(), - NSMenuItem.separator(), - addToFavoritesMenuItem(isFavorite: isFavorite), - editItem(), - moveToEndMenuItem(), - NSMenuItem.separator(), - copyBookmarkURLMenuItem(), - deleteEntityMenuItem() - ].compactMap { $0 } - - return items - } - - func openBookmarkInNewTabMenuItem() -> NSMenuItem { - return menuItem(UserText.openInNewTab, #selector(openBookmarkInNewTabMenuItemSelected(_:))) - } - - @objc - func openBookmarkInNewTabMenuItemSelected(_ sender: NSMenuItem) { + func openBookmarkInNewTab(_ sender: NSMenuItem) { delegate?.bookmarksBarCollectionViewItemOpenInNewTabAction(self) } - func openBookmarkInNewWindowMenuItem() -> NSMenuItem { - return menuItem(UserText.openInNewWindow, #selector(openBookmarkInNewWindowMenuItemSelected(_:))) - } - - @objc - func openBookmarkInNewWindowMenuItemSelected(_ sender: NSMenuItem) { + func openBookmarkInNewWindow(_ sender: NSMenuItem) { delegate?.bookmarksBarCollectionViewItemOpenInNewWindowAction(self) } - func addToFavoritesMenuItem(isFavorite: Bool) -> NSMenuItem? { - guard !isFavorite else { - return nil - } - - return menuItem(UserText.addToFavorites, #selector(addToFavoritesMenuItemSelected(_:))) - } - - @objc - func addToFavoritesMenuItemSelected(_ sender: NSMenuItem) { - delegate?.bookmarksBarCollectionViewItemAddToFavoritesAction(self) + func toggleBookmarkAsFavorite(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemToggleFavoritesAction(self) } - func editItem() -> NSMenuItem { - return menuItem("Edit…", #selector(editItemSelected(_:))) + func editBookmark(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewEditAction(self) } - @objc - func editItemSelected(_ sender: NSMenuItem) { - delegate?.bookmarksBarCollectionViewEditAction(self) + func copyBookmark(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemCopyBookmarkURLAction(self) } - func moveToEndMenuItem() -> NSMenuItem { - return menuItem(UserText.bookmarksBarContextMenuMoveToEnd, #selector(moveToEndMenuItemSelected(_:))) + func deleteBookmark(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemDeleteEntityAction(self) } - @objc - func moveToEndMenuItemSelected(_ sender: NSMenuItem) { + func moveToEnd(_ sender: NSMenuItem) { delegate?.bookmarksBarCollectionViewItemMoveToEndAction(self) } - func copyBookmarkURLMenuItem() -> NSMenuItem { - return menuItem(UserText.bookmarksBarContextMenuCopy, #selector(copyBookmarkURLMenuItemSelected(_:))) + func deleteEntities(_ sender: NSMenuItem) {} + + func manageBookmarks(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemManageBookmarksAction(self) } - @objc - func copyBookmarkURLMenuItemSelected(_ sender: NSMenuItem) { - delegate?.bookmarksBarCollectionViewItemCopyBookmarkURLAction(self) +} + +extension BookmarksBarCollectionViewItem: FolderMenuItemSelectors { + + func newFolder(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemAddEntityAction(self) } - func deleteEntityMenuItem() -> NSMenuItem { - return menuItem(UserText.bookmarksBarContextMenuDelete, #selector(deleteMenuItemSelected(_:))) + func editFolder(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewEditAction(self) } - @objc - func deleteMenuItemSelected(_ sender: NSMenuItem) { + func deleteFolder(_ sender: NSMenuItem) { delegate?.bookmarksBarCollectionViewItemDeleteEntityAction(self) } - // MARK: Folder Menu Items - - func createFolderMenuItems() -> [NSMenuItem] { - return [ - editItem(), - moveToEndMenuItem(), - NSMenuItem.separator(), - deleteEntityMenuItem() - ] + func openInNewTabs(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemOpenInNewTabAction(self) } - func menuItem(_ title: String, _ action: Selector) -> NSMenuItem { - return NSMenuItem(title: title, action: action, keyEquivalent: "") + func openAllInNewWindow(_ sender: NSMenuItem) { + delegate?.bookmarksBarCollectionViewItemOpenInNewWindowAction(self) } } diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBarMenuFactory.swift b/DuckDuckGo/BookmarksBar/View/BookmarksBarMenuFactory.swift index 56eff5f57a..c8d9f6c016 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBarMenuFactory.swift +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBarMenuFactory.swift @@ -34,6 +34,13 @@ struct BookmarksBarMenuFactory { menu.addItem(makeMenuItem(prefs)) } + static func addToMenuWithManageBookmarksSection(_ menu: NSMenu, target: AnyObject, addFolderSelector: Selector, manageBookmarksSelector: Selector, prefs: AppearancePreferences = .shared) { + addToMenu(menu, prefs) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: UserText.addFolder, action: addFolderSelector, target: target)) + menu.addItem(NSMenuItem(title: UserText.bookmarksManageBookmarks, action: manageBookmarksSelector, target: target)) + } + private static func makeMenuItem( _ prefs: AppearancePreferences) -> NSMenuItem { let item = NSMenuItem(title: UserText.showBookmarksBar, action: nil, keyEquivalent: "B") item.submenu = NSMenu(items: [ diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBarViewController.swift b/DuckDuckGo/BookmarksBar/View/BookmarksBarViewController.swift index ea9b61e496..e298a6335e 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBarViewController.swift +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBarViewController.swift @@ -223,59 +223,113 @@ extension BookmarksBarViewController: BookmarksBarViewModelDelegate { bookmarksBarCollectionView.reloadData() } - private func handle(_ action: BookmarksBarViewModel.BookmarksBarItemAction, for bookmark: Bookmark) { +} + +// MARK: - Private + +private extension BookmarksBarViewController { + + func handle(_ action: BookmarksBarViewModel.BookmarksBarItemAction, for bookmark: Bookmark) { switch action { case .openInNewTab: - guard let url = bookmark.urlObject else { return } - tabCollectionViewModel.appendNewTab(with: .url(url, source: .bookmark), selected: true) + openInNewTab(bookmark: bookmark) case .openInNewWindow: - guard let url = bookmark.urlObject else { return } - WindowsManager.openNewWindow(with: url, source: .bookmark, isBurner: false) + openInNewWindow(bookmark: bookmark) case .clickItem: WindowControllersManager.shared.open(bookmark: bookmark) - case .addToFavorites: - bookmark.isFavorite = true + case .toggleFavorites: + bookmark.isFavorite.toggle() bookmarkManager.update(bookmark: bookmark) case .edit: - AddBookmarkModalView(model: AddBookmarkModalViewModel(originalBookmark: bookmark)) - .show(in: view.window) + showDialog(view: BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark)) case .moveToEnd: bookmarkManager.move(objectUUIDs: [bookmark.id], toIndex: nil, withinParentFolder: .root) { _ in } case .copyURL: bookmark.copyUrlToPasteboard() case .deleteEntity: bookmarkManager.remove(bookmark: bookmark) + case .addFolder: + addFolder(inParent: nil) + case .manageBookmarks: + manageBookmarks() } } - private func handle(_ action: BookmarksBarViewModel.BookmarksBarItemAction, for folder: BookmarkFolder, item: BookmarksBarCollectionViewItem) { + func handle(_ action: BookmarksBarViewModel.BookmarksBarItemAction, for folder: BookmarkFolder, item: BookmarksBarCollectionViewItem) { switch action { case .clickItem: - let childEntities = folder.children - let viewModels = childEntities.map { BookmarkViewModel(entity: $0) } - let menuItems = viewModel.bookmarksTreeMenuItems(from: viewModels, topLevel: true) - let menu = bookmarkFolderMenu(items: menuItems) - - menu.popUp(positioning: nil, at: CGPoint(x: 0, y: item.view.frame.minY - 7), in: item.view) + showSubmenuFor(folder: folder, fromView: item.view) case .edit: - AddBookmarkFolderModalView(model: AddBookmarkFolderModalViewModel(folder: folder)) - .show(in: view.window) + showDialog(view: BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: nil)) case .moveToEnd: bookmarkManager.move(objectUUIDs: [folder.id], toIndex: nil, withinParentFolder: .root) { _ in } case .deleteEntity: bookmarkManager.remove(folder: folder) + case .addFolder: + addFolder(inParent: folder) + case .openInNewTab: + openAllInNewTabs(folder: folder) + case .openInNewWindow: + openAllInNewWindow(folder: folder) + case .manageBookmarks: + manageBookmarks() default: assertionFailure("Received unexpected action for bookmark folder") } } - private func bookmarkFolderMenu(items: [NSMenuItem]) -> NSMenu { + func bookmarkFolderMenu(items: [NSMenuItem]) -> NSMenu { let menu = NSMenu() menu.items = items.isEmpty ? [NSMenuItem.empty] : items menu.autoenablesItems = false return menu } + func openInNewTab(bookmark: Bookmark) { + guard let url = bookmark.urlObject else { return } + tabCollectionViewModel.appendNewTab(with: .url(url, source: .bookmark), selected: true) + } + + func openInNewWindow(bookmark: Bookmark) { + guard let url = bookmark.urlObject else { return } + WindowsManager.openNewWindow(with: url, source: .bookmark, isBurner: false) + } + + func openAllInNewTabs(folder: BookmarkFolder) { + let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollectionViewModel.burnerMode) + tabCollectionViewModel.append(tabs: tabs) + } + + func openAllInNewWindow(folder: BookmarkFolder) { + let tabCollection = TabCollection.withContentOfBookmark(folder: folder, burnerMode: tabCollectionViewModel.burnerMode) + WindowsManager.openNewWindow(with: tabCollection, isBurner: tabCollectionViewModel.isBurner) + } + + func showSubmenuFor(folder: BookmarkFolder, fromView view: NSView) { + let childEntities = folder.children + let viewModels = childEntities.map { BookmarkViewModel(entity: $0) } + let menuItems = viewModel.bookmarksTreeMenuItems(from: viewModels, topLevel: true) + let menu = bookmarkFolderMenu(items: menuItems) + + menu.popUp(positioning: nil, at: CGPoint(x: 0, y: view.frame.minY - 7), in: view) + } + + func addFolder(inParent parent: BookmarkFolder?) { + showDialog(view: BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parent)) + } + + func showDialog(view: any ModalView) { + view.show(in: self.view.window) + } + + @objc func manageBookmarks() { + WindowControllersManager.shared.showBookmarksTab() + } + + @objc func addFolder(sender: NSMenuItem) { + addFolder(inParent: nil) + } + } // MARK: - Menu @@ -284,7 +338,12 @@ extension BookmarksBarViewController: NSMenuDelegate { public func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() - BookmarksBarMenuFactory.addToMenu(menu) + BookmarksBarMenuFactory.addToMenuWithManageBookmarksSection( + menu, + target: self, + addFolderSelector: #selector(addFolder(sender:)), + manageBookmarksSelector: #selector(manageBookmarks) + ) } } diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBarViewModel.swift b/DuckDuckGo/BookmarksBar/View/BookmarksBarViewModel.swift index 2dc81a1596..1c82b4c576 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBarViewModel.swift +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBarViewModel.swift @@ -47,11 +47,13 @@ final class BookmarksBarViewModel: NSObject { case clickItem case openInNewTab case openInNewWindow - case addToFavorites + case toggleFavorites case edit case moveToEnd case copyURL case deleteEntity + case addFolder + case manageBookmarks } struct BookmarksBarItem { @@ -482,8 +484,8 @@ extension BookmarksBarViewModel: BookmarksBarCollectionViewItemDelegate { delegate?.bookmarksBarViewModelReceived(action: .openInNewWindow, for: item) } - func bookmarksBarCollectionViewItemAddToFavoritesAction(_ item: BookmarksBarCollectionViewItem) { - delegate?.bookmarksBarViewModelReceived(action: .addToFavorites, for: item) + func bookmarksBarCollectionViewItemToggleFavoritesAction(_ item: BookmarksBarCollectionViewItem) { + delegate?.bookmarksBarViewModelReceived(action: .toggleFavorites, for: item) } func bookmarksBarCollectionViewEditAction(_ item: BookmarksBarCollectionViewItem) { @@ -502,4 +504,12 @@ extension BookmarksBarViewModel: BookmarksBarCollectionViewItemDelegate { delegate?.bookmarksBarViewModelReceived(action: .deleteEntity, for: item) } + func bookmarksBarCollectionViewItemAddEntityAction(_ item: BookmarksBarCollectionViewItem) { + delegate?.bookmarksBarViewModelReceived(action: .addFolder, for: item) + } + + func bookmarksBarCollectionViewItemManageBookmarksAction(_ item: BookmarksBarCollectionViewItem) { + delegate?.bookmarksBarViewModelReceived(action: .manageBookmarks, for: item) + } + } diff --git a/DuckDuckGo/Common/Extensions/NSMenuExtension.swift b/DuckDuckGo/Common/Extensions/NSMenuExtension.swift index 69043e9a3b..6790d5c4f7 100644 --- a/DuckDuckGo/Common/Extensions/NSMenuExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSMenuExtension.swift @@ -58,4 +58,14 @@ extension NSMenu { insertItem(newItem, at: index) } + /// Pops up the menu at the current mouse location. + /// + /// - Parameter view: The view to display the menu item over. + /// - Attention: If the view is not currently installed in a window, this function does not show any pop up menu. + func popUpAtMouseLocation(in view: NSView) { + guard let cursorLocation = view.window?.mouseLocationOutsideOfEventStream else { return } + let convertedLocation = view.convert(cursorLocation, from: nil) + popUp(positioning: nil, at: convertedLocation, in: view) + } + } diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 555dece7ae..3c142fd364 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -426,7 +426,6 @@ struct UserText { static let addToFavorites = NSLocalizedString("add.to.favorites", value: "Add to Favorites", comment: "Button for adding bookmarks to favorites") static let addFavorite = NSLocalizedString("add.favorite", value: "Add Favorite", comment: "Button for adding a favorite bookmark") static let editFavorite = NSLocalizedString("edit.favorite", value: "Edit Favorite", comment: "Header of the view that edits a favorite bookmark") - static let editFolder = NSLocalizedString("edit.folder", value: "Edit Folder", comment: "Header of the view that edits a bookmark folder") static let removeFromFavorites = NSLocalizedString("remove.from.favorites", value: "Remove from Favorites", comment: "Button for removing bookmarks from favorites") static let bookmarkThisPage = NSLocalizedString("bookmark.this.page", value: "Bookmark This Page", comment: "Menu item for bookmarking current page") static let bookmarksShowToolbarPanel = NSLocalizedString("bookmarks.show-toolbar-panel", value: "Open Bookmarks Panel", comment: "Menu item for opening the bookmarks panel") @@ -459,7 +458,6 @@ struct UserText { static let newFolder = NSLocalizedString("folder.optionsMenu.newFolder", value: "New Folder", comment: "Option for creating a new folder") static let renameFolder = NSLocalizedString("folder.optionsMenu.renameFolder", value: "Rename Folder", comment: "Option for renaming a folder") static let deleteFolder = NSLocalizedString("folder.optionsMenu.deleteFolder", value: "Delete Folder", comment: "Option for deleting a folder") - static let newFolderDialogFolderNameTitle = NSLocalizedString("add.folder.name", value: "Name:", comment: "Add Folder popover: folder name text field title") static let newBookmarkDialogBookmarkNameTitle = NSLocalizedString("add.bookmark.name", value: "Name:", comment: "New bookmark folder dialog folder name field heading") static let updateBookmark = NSLocalizedString("bookmark.update", value: "Update Bookmark", comment: "Option for updating a bookmark") @@ -1048,9 +1046,9 @@ struct UserText { enum Bookmarks { enum Dialog { enum Title { - static let addBookmark = NSLocalizedString("bookmarks.dialog.title.add", value: "Add bookmark", comment: "Bookmark creation dialog title") + static let addBookmark = NSLocalizedString("bookmarks.dialog.title.add", value: "Add Bookmark", comment: "Bookmark creation dialog title") static let addedBookmark = NSLocalizedString("bookmarks.dialog.title.added", value: "Bookmark Added", comment: "Bookmark added popover title") - static let editBookmark = NSLocalizedString("bookmarks.dialog.title.edit", value: "Edit bookmark", comment: "Bookmark edit dialog title") + static let editBookmark = NSLocalizedString("bookmarks.dialog.title.edit", value: "Edit Bookmark", comment: "Bookmark edit dialog title") static let addFolder = NSLocalizedString("bookmarks.dialog.folder.title.add", value: "Add Folder", comment: "Bookmark folder creation dialog title") static let editFolder = NSLocalizedString("bookmarks.dialog.folder.title.edit", value: "Edit Folder", comment: "Bookmark folder edit dialog title") } diff --git a/DuckDuckGo/HomePage/Model/HomePageFavoritesModel.swift b/DuckDuckGo/HomePage/Model/HomePageFavoritesModel.swift index 0860f02cfc..e88a48b1b0 100644 --- a/DuckDuckGo/HomePage/Model/HomePageFavoritesModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageFavoritesModel.swift @@ -86,14 +86,16 @@ extension HomePage.Models { let open: (Bookmark, OpenTarget) -> Void let removeFavorite: (Bookmark) -> Void let deleteBookmark: (Bookmark) -> Void - let addEdit: (Bookmark?) -> Void + let add: () -> Void + let edit: (Bookmark) -> Void let moveFavorite: (Bookmark, Int) -> Void let onFaviconMissing: () -> Void init(open: @escaping (Bookmark, OpenTarget) -> Void, removeFavorite: @escaping (Bookmark) -> Void, deleteBookmark: @escaping (Bookmark) -> Void, - addEdit: @escaping (Bookmark?) -> Void, + add: @escaping () -> Void, + edit: @escaping (Bookmark) -> Void, moveFavorite: @escaping (Bookmark, Int) -> Void, onFaviconMissing: @escaping () -> Void ) { @@ -102,7 +104,8 @@ extension HomePage.Models { self.open = open self.removeFavorite = removeFavorite self.deleteBookmark = deleteBookmark - self.addEdit = addEdit + self.add = add + self.edit = edit self.moveFavorite = moveFavorite self.onFaviconMissing = onFaviconMissing } @@ -119,12 +122,12 @@ extension HomePage.Models { open(bookmark, .current) } - func edit(_ bookmark: Bookmark) { - addEdit(bookmark) + func editBookmark(_ bookmark: Bookmark) { + edit(bookmark) } func addNew() { - addEdit(nil) + add() } private func updateVisibleModels() { diff --git a/DuckDuckGo/HomePage/View/FavoritesView.swift b/DuckDuckGo/HomePage/View/FavoritesView.swift index 90956a4a66..52e7f585d1 100644 --- a/DuckDuckGo/HomePage/View/FavoritesView.swift +++ b/DuckDuckGo/HomePage/View/FavoritesView.swift @@ -305,14 +305,17 @@ struct Favorite: View { let bookmark: Bookmark // Maintain separate copies of bookmark metadata required by the view, in order to ensure that SwiftUI re-renders correctly. + // Do not remove these properties even if some are not used in the `FavoriteTemplate` view as the view will not re-render correctly. private let bookmarkTitle: String private let bookmarkURL: URL + private let bookmarkParentFolder: String? init?(bookmark: Bookmark) { guard let urlObject = bookmark.urlObject else { return nil } self.bookmark = bookmark self.bookmarkTitle = bookmark.title self.bookmarkURL = urlObject + self.bookmarkParentFolder = bookmark.parentFolderUUID } var body: some View { diff --git a/DuckDuckGo/HomePage/View/HomePageViewController.swift b/DuckDuckGo/HomePage/View/HomePageViewController.swift index ae40aff5df..03f2cb4e08 100644 --- a/DuckDuckGo/HomePage/View/HomePageViewController.swift +++ b/DuckDuckGo/HomePage/View/HomePageViewController.swift @@ -172,8 +172,10 @@ final class HomePageViewController: NSViewController { self?.bookmarkManager.update(bookmark: bookmark) }, deleteBookmark: { [weak self] bookmark in self?.bookmarkManager.remove(bookmark: bookmark) - }, addEdit: { [weak self] bookmark in - self?.showAddEditController(for: bookmark) + }, add: { [weak self] in + self?.showAddController() + }, edit: { [weak self] bookmark in + self?.showEditController(for: bookmark) }, moveFavorite: { [weak self] (bookmark, index) in self?.bookmarkManager.moveFavorites(with: [bookmark.id], toIndex: index) { _ in } }, onFaviconMissing: { [weak self] in @@ -204,7 +206,7 @@ final class HomePageViewController: NSViewController { } func subscribeToBookmarks() { - bookmarkManager.listPublisher.receive(on: RunLoop.main).sink { [weak self] _ in + bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] _ in withAnimation { self?.refreshFavoritesModel() } @@ -230,9 +232,14 @@ final class HomePageViewController: NSViewController { tabCollectionViewModel.selectedTabViewModel?.tab.setContent(.contentFromURL(url, source: .bookmark)) } - private func showAddEditController(for bookmark: Bookmark? = nil) { - AddBookmarkModalView(model: AddBookmarkModalViewModel(originalBookmark: bookmark, isFavorite: true)) - .show(in: self.view.window) + private func showAddController() { + BookmarksDialogViewFactory.makeAddFavoriteView() + .show(in: view.window) + } + + private func showEditController(for bookmark: Bookmark) { + BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark) + .show(in: view.window) } private var burningDataCancellable: AnyCancellable? diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index a00894c192..94ea695d1b 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -908,66 +908,6 @@ } } }, - "add.folder.name" : { - "comment" : "Add Folder popover: folder name text field title", - "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" : "Имя:" - } - } - } - }, "add.link.to.bookmarks" : { "comment" : "Context menu item", "extractionState" : "extracted_with_value", @@ -8959,7 +8899,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Add bookmark" + "value" : "Add Bookmark" } }, "es" : { @@ -9079,7 +9019,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Edit bookmark" + "value" : "Edit Bookmark" } }, "es" : { @@ -15626,66 +15566,6 @@ } } }, - "edit.folder" : { - "comment" : "Header of the view that edits a bookmark folder", - "extractionState" : "extracted_with_value", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ordner bearbeiten" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Edit Folder" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar carpeta" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifier le dossier" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifica cartella" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Map bewerken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edytuj folder" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar pasta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Изменить папку" - } - } - } - }, "email.copied" : { "comment" : "Notification that the Private email address was copied to clipboard after the user generated a new address", "extractionState" : "extracted_with_value", @@ -24584,59 +24464,6 @@ } } }, - "Location:" : { - "comment" : "Add Folder popover: parent folder picker title", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort:" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ubicación:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Emplacement :" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Posizione:" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Locatie:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lokalizacja:" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Localização:" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Папка:" - } - } - } - }, "looking.for.bitwarden" : { "comment" : "Setup of the integration with Bitwarden app", "extractionState" : "extracted_with_value", @@ -47706,59 +47533,6 @@ } } }, - "Title:" : { - "comment" : "Add Bookmark dialog bookmark title field heading", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titel:" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Título:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titre :" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titolo:" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Titel:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tytuł:" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Título:" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Название:" - } - } - } - }, "tooltip.addToFavorites" : { "comment" : "Tooltip for add to favorites button", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift index 01b7b3f4ec..15e6bcdc66 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/VPNLocation/VPNLocationView.swift @@ -286,19 +286,4 @@ private struct VPNLocationViewButtons: View { } -extension View { - /// Applies the given transform if the given condition evaluates to `true`. - /// - Parameters: - /// - condition: The condition to evaluate. - /// - transform: The transform to apply to the source `View`. - /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. - @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { - if condition { - transform(self) - } else { - self - } - } -} - #endif diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialog.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialogs/Dialog.swift similarity index 100% rename from LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialog.swift rename to LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialogs/Dialog.swift diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialogs/TieredDialogView.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialogs/TieredDialogView.swift new file mode 100644 index 0000000000..541941304b --- /dev/null +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/Dialogs/TieredDialogView.swift @@ -0,0 +1,70 @@ +// +// TieredDialogView.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 SwiftUI + +/// A view to arrange its subviews in a three vertical sections separated by dividers. +public struct TieredDialogView: View { + private let verticalSpacing: CGFloat + private let horizontalAlignment: HorizontalAlignment + private let horizontalPadding: CGFloat? + @ViewBuilder private let top: () -> Top + @ViewBuilder private let center: () -> Center + @ViewBuilder private let bottom: () -> Bottom + + /// Creates an instance with the given vertical spacing, horizontal alignment, horizontal padding and views created by the specified view builders. + /// - Parameters: + /// - verticalSpacing: The distance between adjacent sections. + /// - horizontalAlignment: The guide for aligning the sections in the vertical stack. This guide has the same vertical screen coordinate for every subview. + /// - horizontalPadding: The padding amount to add to the horizontal edges of the sections. + /// - top: A view builder that creates the content of the top section of the dialog. + /// - center: A view builder that creates the content of the central section of the dialog. + /// - bottom: A view builder that creates the content of the bottom section of the dialog. + public init( + verticalSpacing: CGFloat = 10.0, + horizontalAlignment: HorizontalAlignment = .leading, + horizontalPadding: CGFloat? = nil, + @ViewBuilder top: @escaping () -> Top, + @ViewBuilder center: @escaping () -> Center, + @ViewBuilder bottom: @escaping () -> Bottom + ) { + self.horizontalAlignment = horizontalAlignment + self.verticalSpacing = verticalSpacing + self.horizontalPadding = horizontalPadding + self.top = top + self.center = center + self.bottom = bottom + } + + public var body: some View { + VStack(alignment: horizontalAlignment, spacing: verticalSpacing) { + top() + .padding(.horizontal, horizontalPadding) + + Divider() + + center() + .padding(.horizontal, horizontalPadding) + + Divider() + + bottom() + .padding(.horizontal, horizontalPadding) + } + } +} diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/TwoColumnsListView.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/TwoColumnsListView.swift new file mode 100644 index 0000000000..55bb57bca0 --- /dev/null +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/TwoColumnsListView.swift @@ -0,0 +1,62 @@ +// +// TwoColumnsListView.swift +// +// Copyright © 2022 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 SwiftUI + +/// A view to arrange its subviews in two-column equally spaced rows. +public struct TwoColumnsListView: View { + private let rowHeight: CGFloat? + private let horizontalSpacing: CGFloat? + private let verticalSpacing: CGFloat? + @ViewBuilder private let leftColumn: () -> Left + @ViewBuilder private let rightColumn: () -> Right + + /// Creates an instance with the given horizontal and vertical spacing, row height and + /// - Parameters: + /// - horizontalSpacing: The horizontal distance between adjacent subviews. + /// - verticalSpacing: The vertical distance between adjacent subviews. + /// - rowHeight: The height of the rows in the stack. + /// - leftColumn: A view builder that creates the content of the left section of the view. + /// - rightColumn: A view builder that creates the content of the right section of the view. + public init( + horizontalSpacing: CGFloat? = nil, + verticalSpacing: CGFloat? = nil, + rowHeight: CGFloat? = nil, + @ViewBuilder leftColumn: @escaping () -> Left, + @ViewBuilder rightColumn: @escaping () -> Right + ) { + self.horizontalSpacing = horizontalSpacing + self.verticalSpacing = verticalSpacing + self.rowHeight = rowHeight + self.leftColumn = leftColumn + self.rightColumn = rightColumn + } + + public var body: some View { + HStack(alignment: .center, spacing: horizontalSpacing) { + VStack(alignment: .leading, spacing: verticalSpacing) { + leftColumn() + .frame(height: rowHeight) + } + VStack(alignment: .leading, spacing: verticalSpacing) { + rightColumn() + .frame(height: rowHeight) + } + } + } +} diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+ConditionalModifiers.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+ConditionalModifiers.swift new file mode 100644 index 0000000000..af223c4439 --- /dev/null +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+ConditionalModifiers.swift @@ -0,0 +1,47 @@ +// +// View+ConditionalModifiers.swift +// +// Copyright © 2022 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 SwiftUI + +public extension View { + /// Applies the given transform if the given condition evaluates to `true`. + /// - Parameters: + /// - condition: The condition to evaluate. + /// - transform: The transform to apply to the source `View`. + /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. + @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } + + /// Applies the given transform if the given optional value is not `nil`. + /// - Parameters: + /// - value: The optional value to evaluate. + /// - transform: The transform to apply to the source `View`. + /// - Returns: Either the original `View` or the modified `View` if the optional value is not `nil`. + @ViewBuilder func `ifLet`(_ value: Value?, transform: (Self, Value) -> Content) -> some View { + if let value = value { + transform(self, value) + } else { + self + } + } +} diff --git a/UnitTests/Bookmarks/Extensions/Bookmarks+TabTests.swift b/UnitTests/Bookmarks/Extensions/Bookmarks+TabTests.swift new file mode 100644 index 0000000000..5226ead0c6 --- /dev/null +++ b/UnitTests/Bookmarks/Extensions/Bookmarks+TabTests.swift @@ -0,0 +1,61 @@ +// +// Bookmarks+TabTests.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 + +@MainActor +final class Bookmarks_TabTests: XCTestCase { + + func testWhenBuildTabsWithContentOfFolderThenItShouldReturnAsManyTabsAsBookmarksWithinTheFolder() { + // GIVEN + let bookmark1 = Bookmark(id: "A", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false) + let bookmark2 = Bookmark(id: "B", url: URL.atb, title: "ATB", isFavorite: false) + let subFolder = BookmarkFolder(id: "C", title: "Child") + let folder = BookmarkFolder(id: "1", title: "Test", children: [bookmark1, subFolder, bookmark2]) + + // WHEN + let tab = Tab.withContentOfBookmark(folder: folder, burnerMode: .regular) + + // THEN + XCTAssertEqual(tab.count, 2) + XCTAssertEqual(tab.first?.url?.absoluteString, bookmark1.url) + XCTAssertEqual(tab.first?.burnerMode, .regular) + XCTAssertEqual(tab.last?.url?.absoluteString, bookmark2.url) + XCTAssertEqual(tab.last?.burnerMode, .regular) + } + + func testWhenBuildTabCollectionWithContentOfFolderThenItShouldReturnACollectionWithAsManyTabsAsBookmarksWithinTheFolder() { + // GIVEN + let bookmark1 = Bookmark(id: "A", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false) + let bookmark2 = Bookmark(id: "B", url: URL.atb, title: "ATB", isFavorite: false) + let subFolder = BookmarkFolder(id: "C", title: "Child") + let folder = BookmarkFolder(id: "1", title: "Test", children: [bookmark1, subFolder, bookmark2]) + + // WHEN + let tabCollection = TabCollection.withContentOfBookmark(folder: folder, burnerMode: .regular) + + // THEN + XCTAssertEqual(tabCollection.tabs.count, 2) + XCTAssertEqual(tabCollection.tabs.first?.url?.absoluteString, bookmark1.url) + XCTAssertEqual(tabCollection.tabs.first?.burnerMode, .regular) + XCTAssertEqual(tabCollection.tabs.last?.url?.absoluteString, bookmark2.url) + XCTAssertEqual(tabCollection.tabs.last?.burnerMode, .regular) + } + +} diff --git a/UnitTests/Bookmarks/Factory/BookmarksBarMenuFactoryTests.swift b/UnitTests/Bookmarks/Factory/BookmarksBarMenuFactoryTests.swift new file mode 100644 index 0000000000..df9f76ec4e --- /dev/null +++ b/UnitTests/Bookmarks/Factory/BookmarksBarMenuFactoryTests.swift @@ -0,0 +1,59 @@ +// +// BookmarksBarMenuFactoryTests.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 BookmarksBarMenuFactoryTests: XCTestCase { + + func testReturnAddFolderAndManageBookmarksWhenAddToMenuWithManageBookmarksSectionIsCalled() { + // GIVEN + let menu = NSMenu(title: "") + let targetMock = BookmarksBarTargetMock() + XCTAssertTrue(menu.items.isEmpty) + + // WHEN + BookmarksBarMenuFactory.addToMenuWithManageBookmarksSection(menu, target: targetMock, addFolderSelector: #selector(targetMock.addFolder(_:)), manageBookmarksSelector: #selector(targetMock.manageBookmarks)) + + // THEN + XCTAssertEqual(menu.items.count, 4) + XCTAssertEqual(menu.items[1].title, "") + XCTAssertNil(menu.items[1].action) + XCTAssertEqual(menu.items[2].title, UserText.addFolder) + XCTAssertEqual(menu.items[2].action, #selector(targetMock.addFolder(_:))) + XCTAssertEqual(menu.items[3].title, UserText.bookmarksManageBookmarks) + XCTAssertEqual(menu.items[3].action, #selector(targetMock.manageBookmarks)) + } + + func testShouldNotReturnAddFolderAndManageBookmarksWhenAddToMenuIsCalled() { + // GIVEN + let menu = NSMenu(title: "") + XCTAssertTrue(menu.items.isEmpty) + + // WHEN + BookmarksBarMenuFactory.addToMenu(menu) + + // THEN + XCTAssertEqual(menu.items.count, 1) + } +} + +private class BookmarksBarTargetMock: NSObject { + @objc func addFolder(_ sender: NSMenuItem) {} + @objc func manageBookmarks() {} +} diff --git a/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift b/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift new file mode 100644 index 0000000000..11ffefab31 --- /dev/null +++ b/UnitTests/Bookmarks/Model/BaseBookmarkEntityTests.swift @@ -0,0 +1,247 @@ +// +// BaseBookmarkEntityTests.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 BaseBookmarkEntityTests: XCTestCase { + + // MARK: - Folders + + func testTwoBookmarkFolderWithSamePropertiesReturnTrueWhenIsEqualCalled() { + // GIVEN + let parentFolder = BookmarkFolder(id: "Parent", title: "Parent") + let lhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: parentFolder.id, children: []) + let rhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: parentFolder.id, children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + func testTwoBookmarkFolderWithDifferentIdReturnFalseWhenIsEqualCalled() { + // GIVEN + let parentFolder = BookmarkFolder(id: "Parent", title: "Parent") + let lhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: parentFolder.id, children: []) + let rhs = BookmarkFolder(id: "2", title: "Child", parentFolderUUID: parentFolder.id, children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkFolderWithDifferentTitleReturnFalseWhenIsEqualCalled() { + // GIVEN + let parentFolder = BookmarkFolder(id: "Parent", title: "Parent") + let lhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: parentFolder.id, children: []) + let rhs = BookmarkFolder(id: "1", title: "Child 1", parentFolderUUID: parentFolder.id, children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkFolderWithDifferentParentReturnFalseWhenIsEqualCalled() { + // GIVEN + let parentFolder = BookmarkFolder(id: "Parent", title: "Parent") + let lhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: parentFolder.id, children: []) + let rhs = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: #function, children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkParentFolderWithSameSubfoldersReturnTrueWhenIsEqualCalled() { + // GIVEN + let folder1 = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: "Parent", children: []) + let folder2 = BookmarkFolder(id: "2", title: "Child", parentFolderUUID: "Parent", children: []) + let lhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [folder1, folder2]) + let rhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [folder1, folder2]) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + func testTwoBookmarkParentFolderWithDifferentSubfoldersReturnFalseWhenIsEqualCalled() { + // GIVEN + let folder1 = BookmarkFolder(id: "1", title: "Child", parentFolderUUID: "Parent", children: []) + let folder2 = BookmarkFolder(id: "2", title: "Child", parentFolderUUID: "Parent", children: []) + let lhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [folder1, folder2]) + let rhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [folder1, folder2, BookmarkFolder(id: "3", title: "")]) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkParentFolderWithSameBookmarksReturnTrueWhenIsEqualCalled() { + // GIVEN + let bookmark1 = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "1-Parent") + let bookmark2 = Bookmark(id: "2", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "1-Parent") + let lhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [bookmark1, bookmark2]) + let rhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [bookmark1, bookmark2]) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + func testTwoBookmarkParentFolderWithDifferentBookmarksReturnFalseWhenIsEqualCalled() { + // GIVEN + let bookmark1 = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "1-Parent") + let bookmark2 = Bookmark(id: "2", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "1-Parent") + let lhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [bookmark1, bookmark2]) + let rhs = BookmarkFolder(id: "1-Parent", title: "Parent", children: [bookmark1, bookmark2, BookmarkFolder(id: "4", title: "New")]) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + // MARK: - Bookmarks + + func testTwoBookmarkWithSamePropertiesReturnTrueWhenIsEqualCalled() { + // GIVEN + let parentFolder = BookmarkFolder(id: "Parent", title: "Parent") + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: parentFolder.id) + let rhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: parentFolder.id) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + func testTwoBookmarkWithDifferentIdReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + let rhs = Bookmark(id: "2", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkWithDifferentURLReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + let rhs = Bookmark(id: "1", url: URL.devMode, title: "DDG", isFavorite: true, parentFolderUUID: "z") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkWithDifferentTitleReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + let rhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG 2", isFavorite: true, parentFolderUUID: "z") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkWithDifferentIsFavoriteReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + let rhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false, parentFolderUUID: "z") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkWithDifferentParentFolderReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true, parentFolderUUID: "z") + let rhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false, parentFolderUUID: "z-a") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + + func testTwoBookmarkFoldersAddedToRootFolderReturnTrueWhenLeftParentIsBookmarksRootAndRightIsNil() { + // GIVEN + let lhs = BookmarkFolder(id: "1", title: "A", parentFolderUUID: "bookmarks_root", children: []) + let rhs = BookmarkFolder(id: "1", title: "A", parentFolderUUID: nil, children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + func testTwoBookmarkFoldersAddedToRootFolderReturnTrueWhenLeftParentIsNilAndRightParentIsRootBookmarks() { + // GIVEN + let lhs = BookmarkFolder(id: "1", title: "A", parentFolderUUID: nil, children: []) + let rhs = BookmarkFolder(id: "1", title: "A", parentFolderUUID: "bookmarks_root", children: []) + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertTrue(result) + } + + // MARK: - Base Entity + + func testDifferentBookmarkEntitiesReturnFalseWhenIsEqualCalled() { + // GIVEN + let lhs = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhs = BookmarkFolder(id: "1", title: "DDG") + + // WHEN + let result = lhs == rhs + + // THEN + XCTAssertFalse(result) + } + +} diff --git a/UnitTests/Bookmarks/Model/BookmarkListTests.swift b/UnitTests/Bookmarks/Model/BookmarkListTests.swift index e959ff9878..f0f8cda903 100644 --- a/UnitTests/Bookmarks/Model/BookmarkListTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkListTests.swift @@ -23,15 +23,20 @@ import XCTest final class BookmarkListTests: XCTestCase { - func testWhenBookmarkIsInserted_ThenItIsPartOfTheList() { + func testWhenBookmarkIsInserted_ThenItIsPartOfTheList() throws { var bookmarkList = BookmarkList() let bookmark = Bookmark.aBookmark bookmarkList.insert(bookmark) + let result = try XCTUnwrap(bookmarkList[bookmark.url]) XCTAssert(bookmarkList.bookmarks().count == 1) XCTAssert((bookmarkList.bookmarks()).first == bookmark.identifiableBookmark) - XCTAssertNotNil(bookmarkList[bookmark.url]) + XCTAssertEqual(result.id, bookmark.id) + XCTAssertEqual(result.title, bookmark.title) + XCTAssertEqual(result.url, bookmark.url) + XCTAssertEqual(result.isFavorite, bookmark.isFavorite) + XCTAssertEqual(result.parentFolderUUID, bookmark.parentFolderUUID) } func testWhenBookmarkIsAlreadyPartOfTheListInserted_ThenItCantBeInserted() { @@ -93,7 +98,7 @@ final class BookmarkListTests: XCTestCase { XCTAssertNil(updateUrlResult) } - func testWhenBookmarkUrlIsUpdated_ThenJustTheBookmarkUrlIsUpdated() { + func testWhenBookmarkUrlIsUpdated_ThenJustTheBookmarkUrlIsUpdated() throws { var bookmarkList = BookmarkList() let bookmarks = [ @@ -104,11 +109,14 @@ final class BookmarkListTests: XCTestCase { bookmarks.forEach { bookmarkList.insert($0) } let bookmarkToReplace = bookmarks[2] - let newBookmark = bookmarkList.updateUrl(of: bookmarkToReplace, to: URL.duckDuckGoAutocomplete.absoluteString) + let newBookmark = try XCTUnwrap(bookmarkList.updateUrl(of: bookmarkToReplace, to: URL.duckDuckGoAutocomplete.absoluteString)) + let result = try XCTUnwrap(bookmarkList[newBookmark.url]) XCTAssert(bookmarkList.bookmarks().count == bookmarks.count) XCTAssertNil(bookmarkList[bookmarkToReplace.url]) - XCTAssertNotNil(bookmarkList[newBookmark!.url]) + XCTAssertEqual(result.title, "Title") + XCTAssertEqual(result.url, URL.duckDuckGoAutocomplete.absoluteString) + XCTAssertTrue(result.isFavorite) } func testWhenBookmarkUrlIsUpdatedToAlreadyBookmarkedUrl_ThenUpdatingMustFail() { @@ -127,10 +135,51 @@ final class BookmarkListTests: XCTestCase { XCTAssert(bookmarkList.bookmarks().count == bookmarks.count) XCTAssertNotNil(bookmarkList[firstUrl.absoluteString]) + XCTAssertEqual(bookmarkList[firstUrl.absoluteString]?.url, firstUrl.absoluteString) XCTAssertNotNil(bookmarkList[bookmarkToReplace.url]) + XCTAssertEqual(bookmarkList[bookmarkToReplace.url]?.url, URL.duckDuckGo.absoluteString) XCTAssertNil(newBookmark) } + func testWhenBookmarkURLTitleAndIsFavoriteIsUpdated_ThenURLTitleAndIsFavoriteIsUpdated() throws { + // GIVEN + var bookmarkList = BookmarkList() + let bookmarks = [ + Bookmark(id: UUID().uuidString, url: "wikipedia.org", title: "Wikipedia", isFavorite: true), + Bookmark(id: UUID().uuidString, url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true), + Bookmark(id: UUID().uuidString, url: "apple.com", title: "Apple", isFavorite: true) + ] + bookmarks.forEach { bookmarkList.insert($0) } + let bookmarkToReplace = bookmarks[2] + XCTAssertEqual(bookmarkList.bookmarks().count, bookmarks.count) + XCTAssertEqual(bookmarkList["wikipedia.org"]?.url, "wikipedia.org") + XCTAssertEqual(bookmarkList["wikipedia.org"]?.title, "Wikipedia") + XCTAssertEqual(bookmarkList["wikipedia.org"]?.isFavorite, true) + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.url, URL.duckDuckGo.absoluteString) + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.title, "DDG") + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.isFavorite, true) + XCTAssertEqual(bookmarkList["apple.com"]?.url, "apple.com") + XCTAssertEqual(bookmarkList["apple.com"]?.title, "Apple") + XCTAssertEqual(bookmarkList["apple.com"]?.isFavorite, true) + + // WHEN + let newBookmark = try XCTUnwrap(bookmarkList.update(bookmark: bookmarkToReplace, newURL: "www.example.com", newTitle: "Example", newIsFavorite: false)) + + // THEN + let result = try XCTUnwrap(bookmarkList[newBookmark.url]) + XCTAssertEqual(bookmarkList.bookmarks().count, bookmarks.count) + XCTAssertNil(bookmarkList[bookmarkToReplace.url]) + XCTAssertEqual(bookmarkList["wikipedia.org"]?.url, "wikipedia.org") + XCTAssertEqual(bookmarkList["wikipedia.org"]?.title, "Wikipedia") + XCTAssertEqual(bookmarkList["wikipedia.org"]?.isFavorite, true) + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.url, URL.duckDuckGo.absoluteString) + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.title, "DDG") + XCTAssertEqual(bookmarkList[URL.duckDuckGo.absoluteString]?.isFavorite, true) + XCTAssertEqual(result.url, "www.example.com") + XCTAssertEqual(result.title, "Example") + XCTAssertEqual(result.isFavorite, false) + } + } fileprivate extension Bookmark { diff --git a/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift b/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift index 8a9060c2f9..fd795b2ebb 100644 --- a/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkNodeTests.swift @@ -217,4 +217,174 @@ class BookmarkNodeTests: XCTestCase { XCTAssertNotEqual(rootNode.findOrCreateChildNode(with: TestObject()), childNode) } + // MARK: - Equality Bookmarks + + func testWhenTwoNodesWithSameIdAndSameBookmarkAsRepresentedObject_ThenIsEqualShouldBeTrue() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertTrue(result) + } + + func testWhenTwoNodesWithDifferentIdAndSameBookmarkAsRepresentedObject_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 2) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainBookmarkAsRepresentedObjectWithDifferentId_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "2", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainBookmarkAsRepresentedObjectWithDifferentURL_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "2", url: URL.ddgLearnMore.absoluteString, title: "DDG", isFavorite: true) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainBookmarkAsRepresentedObjectWithDifferentTitle_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "2", url: URL.duckDuckGo.absoluteString, title: "DDG 2", isFavorite: true) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainBookmarkAsRepresentedObjectWithDifferentIsFavorite_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true) + let rhsBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false) + let lhsNode = BookmarkNode(representedObject: lhsBookmark, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsBookmark, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesWithSameIdAndSameFolderAsRepresentedObject_ThenIsEqualShouldBeTrue() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let rhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertTrue(result) + } + + func testWhenTwoNodesWithDifferentIdAndSameFolderAsRepresentedObject_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let rhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 2) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainFolderAsRepresentedObjectWithDifferentId_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let rhsFolder = BookmarkFolder(id: "2", title: "Folder", parentFolderUUID: "1", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainFolderAsRepresentedObjectWithDifferentName_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let rhsFolder = BookmarkFolder(id: "1", title: "Folder 1", parentFolderUUID: "1", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainBookmarkAsRepresentedObjectWithDifferentParentFolder_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: []) + let rhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "2", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + + func testWhenTwoNodesContainFolderAsRepresentedObjectWithDifferentChildren_ThenIsEqualShouldBeFalse() { + // GIVEN + let lhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "1", children: [Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: true)]) + let rhsFolder = BookmarkFolder(id: "1", title: "Folder", parentFolderUUID: "2", children: []) + let lhsNode = BookmarkNode(representedObject: lhsFolder, parent: nil, uniqueId: 1) + let rhsNode = BookmarkNode(representedObject: rhsFolder, parent: nil, uniqueId: 1) + + // WHEN + let result = lhsNode == rhsNode + + // THEN + XCTAssertFalse(result) + } + } diff --git a/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift b/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift index 8595ebdcc7..d1cc5fa4d8 100644 --- a/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkOutlineViewDataSourceTests.swift @@ -110,7 +110,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockDestinationNode = treeController.node(representing: mockDestinationFolder)! let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) - let pasteboardFolder = PasteboardFolder(id: UUID().uuidString, name: "Pasteboard Folder") + let pasteboardFolder = PasteboardFolder(folder: .init(id: UUID().uuidString, title: "Pasteboard Folder")) let result = dataSource.validateDrop(for: [pasteboardFolder], destination: mockDestinationNode) XCTAssertEqual(result, .move) @@ -130,7 +130,7 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController) let mockDestinationNode = treeController.node(representing: mockDestinationFolder)! - let pasteboardFolder = PasteboardFolder(id: mockDestinationFolder.id, name: "Pasteboard Folder") + let pasteboardFolder = PasteboardFolder(folder: mockDestinationFolder) let result = dataSource.validateDrop(for: [pasteboardFolder], destination: mockDestinationNode) XCTAssertEqual(result, .none) @@ -153,12 +153,64 @@ class BookmarkOutlineViewDataSourceTests: XCTestCase { let mockDestinationNode = treeController.node(representing: childFolder)! // Simulate dragging the root folder onto the child folder: - let draggedFolder = PasteboardFolder(id: rootFolder.id, name: "Root") + let draggedFolder = PasteboardFolder(folder: rootFolder) let result = dataSource.validateDrop(for: [draggedFolder], destination: mockDestinationNode) XCTAssertEqual(result, .none) } + func testWhenCellFiresDelegate_ThenOnMenuRequestedActionShouldFire() throws { + // GIVEN + let mockFolder = BookmarkFolder.mock + let mockOutlineView = NSOutlineView(frame: .zero) + let treeController = createTreeController(with: [mockFolder]) + let mockFolderNode = treeController.node(representing: mockFolder)! + var didFireClosure = false + var capturedCell: BookmarkOutlineCellView? + let dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: LocalBookmarkManager(), treeController: treeController) { cell in + didFireClosure = true + capturedCell = cell + } + let cell = try XCTUnwrap(dataSource.outlineView(mockOutlineView, viewFor: nil, item: mockFolderNode) as? BookmarkOutlineCellView) + + // WHEN + cell.delegate?.outlineCellViewRequestedMenu(cell) + + // THEN + XCTAssertTrue(didFireClosure) + XCTAssertEqual(cell, capturedCell) + } + + func testWhenShowMenuButtonOnHoverIsTrue_ThenCellShouldHaveShouldMenuButtonFlagTrue() throws { + // GIVEN + let mockFolder = BookmarkFolder.mock + 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) + + // WHEN + let cell = try XCTUnwrap(dataSource.outlineView(mockOutlineView, viewFor: nil, item: mockFolderNode) as? BookmarkOutlineCellView) + + // THEN + XCTAssertTrue(cell.shouldShowMenuButton) + } + + func testWhenShowMenuButtonOnHoverIsFalse_ThenCellShouldHaveShouldMenuButtonFlagFalse() throws { + // GIVEN + let mockFolder = BookmarkFolder.mock + 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) + + // WHEN + let cell = try XCTUnwrap(dataSource.outlineView(mockOutlineView, viewFor: nil, item: mockFolderNode) as? BookmarkOutlineCellView) + + // THEN + XCTAssertFalse(cell.shouldShowMenuButton) + } + // MARK: - Private private func createTreeController(with bookmarks: [BaseBookmarkEntity]) -> BookmarkTreeController { diff --git a/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift b/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift index 6c9e8c59da..4b95d7ca90 100644 --- a/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkSidebarTreeControllerTests.swift @@ -28,24 +28,18 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { let defaultNodes = treeController.rootNode.childNodes let representedObjects = defaultNodes.representedObjects() - // The sidebar defines three hardcoded nodes: + // The sidebar defines one hardcoded nodes: // - // 1. Favorites node - // 2. Spacer node - // 3. Bookmarks node + // 1. Bookmarks node - XCTAssertEqual(defaultNodes.count, 3) + XCTAssertEqual(defaultNodes.count, 1) - XCTAssertFalse(defaultNodes[0].canHaveChildNodes) - XCTAssertFalse(defaultNodes[1].canHaveChildNodes) - XCTAssertTrue(defaultNodes[2].canHaveChildNodes) + XCTAssertTrue(defaultNodes[0].canHaveChildNodes) - XCTAssert(representedObjects[0] === PseudoFolder.favorites) - XCTAssert(representedObjects[1] === SpacerNode.blank) - XCTAssert(representedObjects[2] === PseudoFolder.bookmarks) + XCTAssert(representedObjects.first === PseudoFolder.bookmarks) } - func testWhenBookmarkStoreHasNoTopLevelFolders_ThenTheDefaultBookmarksNodeHasNoChildren() { + func testWhenBookmarkStoreHasNoTopLevelFolders_ThenTheDefaultBookmarksNodeHasNoChildren() throws { let bookmarkStoreMock = BookmarkStoreMock() let faviconManagerMock = FaviconManagerMock() let bookmarkManager = LocalBookmarkManager(bookmarkStore: bookmarkStoreMock, faviconManagement: faviconManagerMock) @@ -56,11 +50,13 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) let treeController = BookmarkTreeController(dataSource: dataSource) let defaultNodes = treeController.rootNode.childNodes - XCTAssertEqual(defaultNodes.count, 3) + XCTAssertEqual(defaultNodes.count, 1) // The sidebar tree controller only shows folders, so if there are only bookmarks then the bookmarks default folder will be empty. - let bookmarksNode = defaultNodes[2] - XCTAssert(bookmarksNode.childNodes.isEmpty) + let bookmarksNode = defaultNodes[0] + let pseudoFolder = try XCTUnwrap(bookmarksNode.representedObject as? PseudoFolder) + XCTAssertTrue(bookmarksNode.childNodes.isEmpty) + XCTAssertEqual(pseudoFolder.name, "Bookmarks") } func testWhenBookmarkStoreHasTopLevelFolders_ThenTheDefaultBookmarksNodeHasThemAsChildren() { @@ -75,9 +71,9 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) let treeController = BookmarkTreeController(dataSource: dataSource) let defaultNodes = treeController.rootNode.childNodes - XCTAssertEqual(defaultNodes.count, 3) + XCTAssertEqual(defaultNodes.count, 1) - let bookmarksNode = defaultNodes[2] + let bookmarksNode = defaultNodes[0] XCTAssertEqual(bookmarksNode.childNodes.count, 1) let childNode = bookmarksNode.childNodes[0] @@ -98,9 +94,9 @@ class BookmarkSidebarTreeControllerTests: XCTestCase { let dataSource = BookmarkSidebarTreeController(bookmarkManager: bookmarkManager) let treeController = BookmarkTreeController(dataSource: dataSource) let defaultNodes = treeController.rootNode.childNodes - XCTAssertEqual(defaultNodes.count, 3) + XCTAssertEqual(defaultNodes.count, 1) - let bookmarksNode = defaultNodes[2] + let bookmarksNode = defaultNodes[0] XCTAssertTrue(bookmarksNode.canHaveChildNodes) XCTAssertEqual(bookmarksNode.childNodes.count, 1) diff --git a/UnitTests/Bookmarks/Model/BookmarkTests.swift b/UnitTests/Bookmarks/Model/BookmarkTests.swift index cd21752a06..e6cf1df214 100644 --- a/UnitTests/Bookmarks/Model/BookmarkTests.swift +++ b/UnitTests/Bookmarks/Model/BookmarkTests.swift @@ -90,7 +90,7 @@ class BookmarkTests: XCTestCase { XCTAssertEqual(folder.childFolders.count, 0) XCTAssertEqual(folder.childBookmarks.count, 1) XCTAssertEqual(folder.children, [ - BaseBookmarkEntity.from(managedObject: bookmarkManagedObject, favoritesDisplayMode: .displayNative(.desktop)) + BaseBookmarkEntity.from(managedObject: bookmarkManagedObject, parentFolderUUID: folder.id, favoritesDisplayMode: .displayNative(.desktop)) ]) XCTAssertNil(folder.parentFolderUUID) diff --git a/UnitTests/Bookmarks/Model/ContextualMenuTests.swift b/UnitTests/Bookmarks/Model/ContextualMenuTests.swift new file mode 100644 index 0000000000..89da2c186a --- /dev/null +++ b/UnitTests/Bookmarks/Model/ContextualMenuTests.swift @@ -0,0 +1,267 @@ +// +// ContextualMenuTests.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 ContextualMenuTests: XCTestCase { + + func testWhenAskingBookmarkMenuItemsAndIsNotFavoriteThenItShouldReturnTheItemsInTheCorrectOrder() { + // GIVEN + let isFavorite = false + + // WHEN + let items = ContextualMenu.bookmarkMenuItems(isFavorite: isFavorite) + + // THEN + XCTAssertEqual(items.count, 12) + assertMenu(item: items[0], withTitle: UserText.openInNewTab, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:))) + assertMenu(item: items[1], withTitle: UserText.openInNewWindow, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:))) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.addToFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:))) + assertMenu(item: items[4], withTitle: "", selector: nil) // Separator + assertMenu(item: items[5], withTitle: UserText.editBookmark, selector: #selector(BookmarkMenuItemSelectors.editBookmark(_:))) + assertMenu(item: items[6], withTitle: UserText.bookmarksBarContextMenuCopy, selector: #selector(BookmarkMenuItemSelectors.copyBookmark(_:))) + assertMenu(item: items[7], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteBookmark(_:))) + assertMenu(item: items[8], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(BookmarkMenuItemSelectors.moveToEnd(_:))) + assertMenu(item: items[9], withTitle: "", selector: nil) // Separator + assertMenu(item: items[10], withTitle: UserText.addFolder, selector: #selector(BookmarkMenuItemSelectors.newFolder(_:))) + assertMenu(item: items[11], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) + + } + + func testWhenAskingBookmarkMenuItemsAndIsFavoriteThenItShouldReturnTheItemsInTheCorrectOrder() { + // GIVEN + let isFavorite = true + + // WHEN + let items = ContextualMenu.bookmarkMenuItems(isFavorite: isFavorite) + + // THEN + assertMenu(item: items[0], withTitle: UserText.openInNewTab, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:))) + assertMenu(item: items[1], withTitle: UserText.openInNewWindow, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:))) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.removeFromFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:))) + assertMenu(item: items[4], withTitle: "", selector: nil) // Separator + assertMenu(item: items[5], withTitle: UserText.editBookmark, selector: #selector(BookmarkMenuItemSelectors.editBookmark(_:))) + assertMenu(item: items[6], withTitle: UserText.bookmarksBarContextMenuCopy, selector: #selector(BookmarkMenuItemSelectors.copyBookmark(_:))) + assertMenu(item: items[7], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteBookmark(_:))) + assertMenu(item: items[8], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(BookmarkMenuItemSelectors.moveToEnd(_:))) + assertMenu(item: items[9], withTitle: "", selector: nil) // Separator + assertMenu(item: items[10], withTitle: UserText.addFolder, selector: #selector(BookmarkMenuItemSelectors.newFolder(_:))) + assertMenu(item: items[11], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) + } + + func testWhenAskingFolderItemThenItShouldReturnTheItemsInTheCorrectOrders() { + // WHEN + let items = ContextualMenu.folderMenuItems() + + // THEN + XCTAssertEqual(items.count, 9) + assertMenu(item: items[0], withTitle: UserText.openAllInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:))) + assertMenu(item: items[1], withTitle: UserText.openAllTabsInNewWindow, selector: #selector(FolderMenuItemSelectors.openAllInNewWindow(_:))) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.editBookmark, selector: #selector(FolderMenuItemSelectors.editFolder(_:))) + assertMenu(item: items[4], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(FolderMenuItemSelectors.deleteFolder(_:))) + assertMenu(item: items[5], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(FolderMenuItemSelectors.moveToEnd(_:))) + assertMenu(item: items[6], withTitle: "", selector: nil) // Separator + assertMenu(item: items[7], withTitle: UserText.addFolder, selector: #selector(FolderMenuItemSelectors.newFolder(_:))) + assertMenu(item: items[8], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(FolderMenuItemSelectors.manageBookmarks(_:))) + } + + func testWhenCreateMenuForEmptySelectionThenItReturnsAMenuWithAddFolderOnly() throws { + // WHEN + let menu = ContextualMenu.menu(for: []) + + // THEN + XCTAssertEqual(menu?.items.count, 1) + let menuItem = try XCTUnwrap(menu?.items.first) + assertMenu(item: menuItem, withTitle: UserText.addFolder, selector: #selector(FolderMenuItemSelectors.newFolder(_:))) + } + + func testWhenCreateMenuForBookmarkWithoutParentThenReturnsAMenuWithTheBookmarkMenuItems() throws { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false) + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 12) + assertMenu(item: items[0], withTitle: UserText.openInNewTab, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), representedObject: bookmark) + assertMenu(item: items[1], withTitle: UserText.openInNewWindow, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), representedObject: bookmark) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.addToFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), representedObject: bookmark) + assertMenu(item: items[4], withTitle: "", selector: nil) // Separator + assertMenu(item: items[5], withTitle: UserText.editBookmark, selector: #selector(BookmarkMenuItemSelectors.editBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[6], withTitle: UserText.bookmarksBarContextMenuCopy, selector: #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[7], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[8], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(BookmarkMenuItemSelectors.moveToEnd(_:)), representedObject: BookmarkEntityInfo(entity: bookmark, parent: nil)) + assertMenu(item: items[9], withTitle: "", selector: nil) // Separator + assertMenu(item: items[10], withTitle: UserText.addFolder, selector: #selector(BookmarkMenuItemSelectors.newFolder(_:))) + assertMenu(item: items[11], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) + } + + func testWhenCreateMenuForBookmarkWithParentThenReturnsAMenuWithTheBookmarkMenuItems() throws { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DDG", isFavorite: false, parentFolderUUID: "A") + let parent = BookmarkFolder(id: "A", title: "Folder", children: [bookmark]) + let parentNode = BookmarkNode(representedObject: parent, parent: nil) + let node = BookmarkNode(representedObject: bookmark, parent: parentNode) + + // WHEN + let menu = ContextualMenu.menu(for: [node]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 12) + assertMenu(item: items[0], withTitle: UserText.openInNewTab, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), representedObject: bookmark) + assertMenu(item: items[1], withTitle: UserText.openInNewWindow, selector: #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), representedObject: bookmark) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.addToFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), representedObject: bookmark) + assertMenu(item: items[4], withTitle: "", selector: nil) // Separator + assertMenu(item: items[5], withTitle: UserText.editBookmark, selector: #selector(BookmarkMenuItemSelectors.editBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[6], withTitle: UserText.bookmarksBarContextMenuCopy, selector: #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[7], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), representedObject: bookmark) + assertMenu(item: items[8], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(BookmarkMenuItemSelectors.moveToEnd(_:)), representedObject: BookmarkEntityInfo(entity: bookmark, parent: parent)) + assertMenu(item: items[9], withTitle: "", selector: nil) // Separator + assertMenu(item: items[10], withTitle: UserText.addFolder, selector: #selector(BookmarkMenuItemSelectors.newFolder(_:)), representedObject: parent) + assertMenu(item: items[11], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) + } + + func testWhenCreateMenuForFolderNodeThenReturnsAMenuWithTheFolderMenuItems() throws { + // GIVEN + let folder = BookmarkFolder(id: "1", title: "Child") + let parent = BookmarkFolder(id: "1", title: "Parent", children: [folder]) + let parentNode = BookmarkNode(representedObject: parent, parent: nil) + let node = BookmarkNode(representedObject: folder, parent: parentNode) + + // WHEN + let menu = ContextualMenu.menu(for: [node]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 9) + assertMenu(item: items[0], withTitle: UserText.openAllInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), representedObject: folder) + assertMenu(item: items[1], withTitle: UserText.openAllTabsInNewWindow, selector: #selector(FolderMenuItemSelectors.openAllInNewWindow(_:)), representedObject: folder) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.editBookmark, selector: #selector(FolderMenuItemSelectors.editFolder(_:)), representedObject: BookmarkEntityInfo(entity: folder, parent: parent)) + assertMenu(item: items[4], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(FolderMenuItemSelectors.deleteFolder(_:)), representedObject: folder) + assertMenu(item: items[5], withTitle: UserText.bookmarksBarContextMenuMoveToEnd, selector: #selector(FolderMenuItemSelectors.moveToEnd(_:)), representedObject: BookmarkEntityInfo(entity: folder, parent: parent)) + assertMenu(item: items[6], withTitle: "", selector: nil) // Separator + assertMenu(item: items[7], withTitle: UserText.addFolder, selector: #selector(FolderMenuItemSelectors.newFolder(_:)), representedObject: folder) + assertMenu(item: items[8], withTitle: UserText.bookmarksManageBookmarks, selector: #selector(FolderMenuItemSelectors.manageBookmarks(_:))) + } + + func testWhenCreateMenuForMultipleUnfavoriteBookmarksThenReturnsMenuWithOpenInNewTabsAddToFavoritesAndDelete() throws { + // GIVEN + let bookmark1 = Bookmark(id: "1", url: "", title: "", isFavorite: false) + let bookmark2 = Bookmark(id: "2", url: "", title: "", isFavorite: false) + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark1, bookmark2]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 4) + assertMenu(item: items[0], withTitle: UserText.bookmarksOpenInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), representedObject: [bookmark1, bookmark2]) + assertMenu(item: items[1], withTitle: UserText.addToFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), representedObject: [bookmark1, bookmark2]) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), representedObject: [bookmark1, bookmark2]) + } + + func testWhenCreateMenuForMultipleFavoriteBookmarksThenReturnsMenuWithOpenInNewTabsRemoveFromFavoritesAndDelete() throws { + // GIVEN + let bookmark1 = Bookmark(id: "1", url: "", title: "", isFavorite: true) + let bookmark2 = Bookmark(id: "2", url: "", title: "", isFavorite: true) + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark1, bookmark2]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 4) + assertMenu(item: items[0], withTitle: UserText.bookmarksOpenInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), representedObject: [bookmark1, bookmark2]) + assertMenu(item: items[1], withTitle: UserText.removeFromFavorites, selector: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), representedObject: [bookmark1, bookmark2]) + assertMenu(item: items[2], withTitle: "", selector: nil) // Separator + assertMenu(item: items[3], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), representedObject: [bookmark1, bookmark2]) + } + + func testWhenCreateMenuForMultipleMixedFavoriteBookmarksThenReturnsMenuWithOpenInNewTabsAndDelete() throws { + // GIVEN + let bookmark1 = Bookmark(id: "1", url: "", title: "", isFavorite: true) + let bookmark2 = Bookmark(id: "2", url: "", title: "", isFavorite: false) + + // WHEN + let menu = ContextualMenu.menu(for: [bookmark1, bookmark2]) + + // THEN + let items = try XCTUnwrap(menu?.items) + XCTAssertEqual(items.count, 3) + assertMenu(item: items[0], withTitle: UserText.bookmarksOpenInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), representedObject: [bookmark1, bookmark2]) + assertMenu(item: items[1], withTitle: "", selector: nil) // Separator + assertMenu(item: items[2], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), representedObject: [bookmark1, bookmark2]) + } + + func testWhenCreateMenuForBookmarkAndFolderThenReturnsMenuWithOpenInNewTabsOnlyForBookmarkAndDelete() 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) + assertMenu(item: items[0], withTitle: UserText.bookmarksOpenInNewTabs, selector: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), representedObject: [bookmark]) + assertMenu(item: items[1], withTitle: "", selector: nil) // Separator + assertMenu(item: items[2], withTitle: UserText.bookmarksBarContextMenuDelete, selector: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), representedObject: [bookmark, folder]) + } + +} + +private extension ContextualMenuTests { + + func assertMenu(item: NSMenuItem, withTitle title: String, selector: Selector?, representedObject: T = Empty() ) { + XCTAssertEqual(item.title, title) + XCTAssertEqual(item.action, selector) + if representedObject is Empty { + XCTAssertNil(item.representedObject) + } else { + XCTAssertEqualValue(item.representedObject, representedObject) + } + } + +} + +private struct Empty: Equatable {} + +private func XCTAssertEqualValue(_ expression1: @autoclosure () throws -> Any?, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) where T: Equatable { + do { + guard let firstValue = try expression1() as? T else { + XCTFail("Type of expression1 \(type(of: try? expression1())) and expression2 \(type(of: try? expression2())) are different.") + return + } + let secondValue = try expression2() + XCTAssertEqual(firstValue, secondValue, message(), file: file, line: line) + } catch { + XCTFail("Failed evaluating expression.") + } +} diff --git a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift index b82636a7c5..97ce23202d 100644 --- a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift +++ b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Combine import Foundation import XCTest @@ -157,6 +158,26 @@ final class LocalBookmarkManagerTests: XCTestCase { XCTAssert(bookmarkStoreMock.updateBookmarkCalled) } + func testWhenBookmarkFolderIsUpdatedAndMoved_ThenManagerUpdatesItAlsoInStore() throws { + let (bookmarkManager, bookmarkStoreMock) = LocalBookmarkManager.aManager + let parent = BookmarkFolder(id: "1", title: "Parent") + let folder = BookmarkFolder(id: "2", title: "Child") + var bookmarkList: BookmarkList? + let cancellable = bookmarkManager.listPublisher + .dropFirst() + .sink { list in + bookmarkList = list + } + + bookmarkManager.update(folder: folder, andMoveToParent: .parent(uuid: parent.id)) + + withExtendedLifetime(cancellable) {} + XCTAssertTrue(bookmarkStoreMock.updateFolderAndMoveToParentCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolder, folder) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .parent(uuid: parent.id)) + XCTAssertNotNil(bookmarkList) + } + } fileprivate extension LocalBookmarkManager { diff --git a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift index 0dff288b23..2ebff62e7d 100644 --- a/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift +++ b/UnitTests/Bookmarks/Services/LocalBookmarkStoreTests.swift @@ -56,7 +56,7 @@ final class LocalBookmarkStoreTests: XCTestCase { let savingExpectation = self.expectation(description: "Saving") let loadingExpectation = self.expectation(description: "Loading") - let bookmark = Bookmark(id: UUID().uuidString, url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: true) + let bookmark = Bookmark(id: UUID().uuidString, url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: true, parentFolderUUID: "bookmarks_root") bookmarkStore.save(bookmark: bookmark, parent: nil, index: nil) { (success, error) in XCTAssert(success) @@ -128,7 +128,7 @@ final class LocalBookmarkStoreTests: XCTestCase { savingExpectation.fulfill() - let modifiedBookmark = Bookmark(id: bookmark.id, url: URL.duckDuckGo.absoluteString, title: "New Title", isFavorite: false) + let modifiedBookmark = Bookmark(id: bookmark.id, url: URL.duckDuckGo.absoluteString, title: "New Title", isFavorite: false, parentFolderUUID: "bookmarks_root") bookmarkStore.update(bookmark: modifiedBookmark) bookmarkStore.loadAll(type: .bookmarks) { bookmarks, error in @@ -152,7 +152,7 @@ final class LocalBookmarkStoreTests: XCTestCase { let savingExpectation = self.expectation(description: "Saving") let loadingExpectation = self.expectation(description: "Loading") - let folder = BookmarkFolder(id: UUID().uuidString, title: "Folder") + let folder = BookmarkFolder(id: UUID().uuidString, title: "Folder", parentFolderUUID: "bookmarks_root") bookmarkStore.save(folder: folder, parent: nil) { (success, error) in XCTAssert(success) @@ -181,8 +181,9 @@ final class LocalBookmarkStoreTests: XCTestCase { let saveChildExpectation = self.expectation(description: "Save Child Folder") let loadingExpectation = self.expectation(description: "Loading") - let parentFolder = BookmarkFolder(id: UUID().uuidString, title: "Parent") - let childFolder = BookmarkFolder(id: UUID().uuidString, title: "Child") + let parentId = UUID().uuidString + let childFolder = BookmarkFolder(id: UUID().uuidString, title: "Child", parentFolderUUID: parentId) + let parentFolder = BookmarkFolder(id: parentId, title: "Parent", parentFolderUUID: "bookmarks_root", children: [childFolder]) bookmarkStore.save(folder: parentFolder, parent: nil) { (success, error) in XCTAssert(success) @@ -224,8 +225,9 @@ final class LocalBookmarkStoreTests: XCTestCase { let saveBookmarkExpectation = self.expectation(description: "Save Bookmark") let loadingExpectation = self.expectation(description: "Loading") - let folder = BookmarkFolder(id: UUID().uuidString, title: "Parent") - let bookmark = Bookmark(id: UUID().uuidString, url: "https://example.com", title: "Example", isFavorite: false) + let parentId = UUID().uuidString + let bookmark = Bookmark(id: UUID().uuidString, url: "https://example.com", title: "Example", isFavorite: false, parentFolderUUID: parentId) + let folder = BookmarkFolder(id: parentId, title: "Parent", parentFolderUUID: "bookmarks_root", children: [bookmark]) bookmarkStore.save(folder: folder, parent: nil) { (success, error) in XCTAssert(success) @@ -468,6 +470,148 @@ final class LocalBookmarkStoreTests: XCTestCase { XCTAssertEqual(topLevelEntityIDs, [testState.initialParentFolder.id, testState.bookmark3.id]) } + func testWhenUpdatingBookmarkFolder_ThenBookmarkFolderTitleIsUpdated() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + let folder1 = BookmarkFolder(id: UUID().uuidString, title: "Folder 1", parentFolderUUID: "bookmarks_root") + + // Save the initial bookmarks state: + + _ = await bookmarkStore.save(folder: folder1, parent: nil) + + // Fetch persisted bookmark folders back from the store: + + let folders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(folders.count, 1) + XCTAssertEqual(folders.first, folder1) + + // Update the folder title and parent: + + let folderToMove = folder1 + folderToMove.title = #function + bookmarkStore.update(folder: folder1) + + // Check the new bookmark folders order: + + let newFolders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(newFolders.count, 1) + XCTAssertEqual(newFolders.first, folderToMove) + } + + func testWhenUpdatingAndMovingBookmarkFolder_ThenBookmarkFolderIsMovedAndTitleUpdated() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + + let folder1 = BookmarkFolder(id: UUID().uuidString, title: "Folder 1", parentFolderUUID: "bookmarks_root") + let folder2 = BookmarkFolder(id: UUID().uuidString, title: "Folder 2", parentFolderUUID: "bookmarks_root") + let folder3 = BookmarkFolder(id: UUID().uuidString, title: "Folder 3", parentFolderUUID: "bookmarks_root") + + // Save the initial bookmarks state: + + _ = await bookmarkStore.save(folder: folder1, parent: nil) + _ = await bookmarkStore.save(folder: folder2, parent: nil) + _ = await bookmarkStore.save(folder: folder3, parent: nil) + + // Fetch persisted bookmark folders back from the store: + + let folders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(folders.count, 3) + XCTAssertEqual(folders[0], folder1) + XCTAssertEqual(folders[1], folder2) + XCTAssertEqual(folders[2], folder3) + + // Update the folder title and parent: + + let folderToMove = folder1 + folderToMove.title = #function + bookmarkStore.update(folder: folder1, andMoveToParent: .parent(uuid: folder2.id)) + let expectedFolderAfterMove = BookmarkFolder(id: folder1.id, title: folder1.title, parentFolderUUID: folder2.id, children: folder1.children) + + // Check the new bookmark folders order: + + let newFolders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(newFolders.count, 2) + XCTAssertEqual(newFolders[0].id, folder2.id) + XCTAssertEqual(newFolders[0].children, [expectedFolderAfterMove]) + XCTAssertEqual(newFolders[1], folder3) + } + + func testWhenMovingBookmarkFolderToSubfolder_ThenBookmarkFolderLocationIsUpdated() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + + let folder1 = BookmarkFolder(id: UUID().uuidString, title: "Folder 1", parentFolderUUID: "bookmarks_root") + let folder2 = BookmarkFolder(id: UUID().uuidString, title: "Folder 2", parentFolderUUID: "bookmarks_root") + + // Save the initial bookmarks state: + + _ = await bookmarkStore.save(folder: folder1, parent: nil) + _ = await bookmarkStore.save(folder: folder2, parent: nil) + + // Fetch persisted bookmark folders back from the store: + + let folders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(folders.count, 2) + XCTAssertEqual(folders.first, folder1) + XCTAssertEqual(folders.last, folder2) + + // Update the folder parent: + + _ = await bookmarkStore.move(objectUUIDs: [folder2.id], toIndex: nil, withinParentFolder: .parent(uuid: folder1.id)) + let expectedChildFolderAfterMove = BookmarkFolder(id: folder2.id, title: folder2.title, parentFolderUUID: folder1.id, children: folder2.children) + let expectedParentFolderAfterMove = BookmarkFolder(id: folder1.id, title: folder1.title, parentFolderUUID: folder1.parentFolderUUID, children: [expectedChildFolderAfterMove]) + + // Check the new bookmark folders order: + + let newFolders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(newFolders.count, 1) + XCTAssertEqual(newFolders.first, expectedParentFolderAfterMove) + XCTAssertEqual(newFolders.first?.children, [expectedChildFolderAfterMove]) + } + + func testWhenMovingBookmarkFolderToRootFolder_ThenBookmarkFolderLocationIsUpdated() async throws { + let context = container.viewContext + let bookmarkStore = LocalBookmarkStore(context: context) + + let folder2Id = UUID().uuidString + let folder1 = BookmarkFolder(id: UUID().uuidString, title: "Folder 1", parentFolderUUID: folder2Id) + let folder2 = BookmarkFolder(id: folder2Id, title: "Folder 2", parentFolderUUID: "bookmarks_root", children: [folder1]) + + // Save the initial bookmarks state: + + _ = await bookmarkStore.save(folder: folder2, parent: nil) + _ = await bookmarkStore.save(folder: folder1, parent: folder2) + + // Fetch persisted bookmark folders back from the store: + + let folders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + + XCTAssertEqual(folders.count, 1) + XCTAssertEqual(folders.first, folder2) + XCTAssertEqual(folders.first?.children, [folder1]) + + // Update the folder parent: + + _ = await bookmarkStore.move(objectUUIDs: [folder1.id], toIndex: 0, withinParentFolder: .root) + + // Check the new bookmark folders order: + + let newFolders = try await bookmarkStore.loadAll(type: .topLevelEntities).get().compactMap { $0 as? BookmarkFolder } + let expectedFolder1AfterMove = BookmarkFolder(id: folder1.id, title: folder1.title, parentFolderUUID: "bookmarks_root", children: folder1.children) + let expectedFolder2AfterMove = BookmarkFolder(id: folder2.id, title: folder2.title, parentFolderUUID: "bookmarks_root", children: []) + + XCTAssertEqual(newFolders.count, 2) + XCTAssertEqual(newFolders.first, expectedFolder1AfterMove) + XCTAssertEqual(newFolders.last, expectedFolder2AfterMove) + XCTAssertEqual(newFolders.last?.children, []) + } + // MARK: Favorites func testThatTopLevelEntitiesDoNotContainFavoritesFolder() async { diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift new file mode 100644 index 0000000000..53072513c4 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogCoordinatorViewModelTests.swift @@ -0,0 +1,170 @@ +// +// AddEditBookmarkDialogCoordinatorViewModelTests.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 Combine +@testable import DuckDuckGo_Privacy_Browser + +@MainActor +final class AddEditBookmarkDialogCoordinatorViewModelTests: XCTestCase { + private var sut: AddEditBookmarkDialogCoordinatorViewModel! + private var bookmarkViewModelMock: AddEditBookmarkDialogViewModelMock! + private var bookmarkFolderViewModelMock: AddEditBookmarkFolderDialogViewModelMock! + private var cancellables: Set! + + override func setUpWithError() throws { + try super.setUpWithError() + + cancellables = [] + bookmarkViewModelMock = .init() + bookmarkFolderViewModelMock = .init() + sut = .init(bookmarkModel: bookmarkViewModelMock, folderModel: bookmarkFolderViewModelMock) + } + + override func tearDownWithError() throws { + cancellables = nil + bookmarkViewModelMock = nil + bookmarkFolderViewModelMock = nil + sut = nil + try super.tearDownWithError() + } + + func testShouldReturnViewStateBookmarkWhenInit() { + XCTAssertEqual(sut.viewState, .bookmark) + } + + func testShouldReturnViewStateBookmarkWhenDismissActionIsCalled() { + // GIVEN + sut.addFolderAction() + XCTAssertEqual(sut.viewState, .folder) + + // WHEN + sut.dismissAction() + + // THEN + XCTAssertEqual(sut.viewState, .bookmark) + + } + + func testShouldSetSelectedFolderOnFolderViewModelAndReturnFolderViewStateWhenAddFolderActionIsCalled() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: "Folder") + bookmarkViewModelMock.selectedFolder = folder + XCTAssertNil(bookmarkFolderViewModelMock.selectedFolder) + + // WHEN + sut.addFolderAction() + + // THEN + XCTAssertEqual(bookmarkFolderViewModelMock.selectedFolder, folder) + } + + func testShouldReceiveEventsWhenBookmarkModelChanges() { + // GIVEN + let expectation = self.expectation(description: #function) + var didCallChangeValue = false + sut.objectWillChange.sink { _ in + didCallChangeValue = true + expectation.fulfill() + } + .store(in: &cancellables) + + // WHEN + sut.bookmarkModel.objectWillChange.send() + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertTrue(didCallChangeValue) + } + + func testShouldReceiveEventsWhenBookmarkFolderModelChanges() { + // GIVEN + let expectation = self.expectation(description: #function) + var didCallChangeValue = false + sut.objectWillChange.sink { _ in + didCallChangeValue = true + expectation.fulfill() + } + .store(in: &cancellables) + + // WHEN + sut.folderModel.objectWillChange.send() + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertTrue(didCallChangeValue) + } + + func testShouldSetSelectedFolderOnBookmarkViewModelWhenAddFolderPublisherSendsEvent() { + // GIVEN + let expectation = self.expectation(description: #function) + bookmarkViewModelMock.selectedFolderExpectation = expectation + let folder = BookmarkFolder(id: "ABCDE", title: #function) + XCTAssertNil(bookmarkViewModelMock.selectedFolder) + + // WHEN + sut.folderModel.subject.send(folder) + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertEqual(bookmarkViewModelMock.selectedFolder, folder) + } + +} + +final class AddEditBookmarkDialogViewModelMock: BookmarkDialogEditing { + var bookmarkName: String = "" + var bookmarkURLPath: String = "" + var isBookmarkFavorite: Bool = false + var isURLFieldHidden: Bool = false + var title: String = "" + var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] + var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? { + didSet { + selectedFolderExpectation?.fulfill() + } + } + var cancelActionTitle: String = "" + var isOtherActionDisabled: Bool = false + var defaultActionTitle: String = "" + var isDefaultActionDisabled: Bool = false + + func cancel(dismiss: () -> Void) {} + func addOrSave(dismiss: () -> Void) {} + + var selectedFolderExpectation: XCTestExpectation? +} + +final class AddEditBookmarkFolderDialogViewModelMock: BookmarkFolderDialogEditing { + let subject = PassthroughSubject() + + var addFolderPublisher: AnyPublisher { + subject.eraseToAnyPublisher() + } + var folderName: String = "" + var title: String = "" + var folders: [DuckDuckGo_Privacy_Browser.FolderViewModel] = [] + var selectedFolder: DuckDuckGo_Privacy_Browser.BookmarkFolder? + var cancelActionTitle: String = "" + var isOtherActionDisabled: Bool = false + var defaultActionTitle: String = "" + var isDefaultActionDisabled: Bool = false + + func cancel(dismiss: () -> Void) {} + func addOrSave(dismiss: () -> Void) {} +} diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift new file mode 100644 index 0000000000..b409df5b9c --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkDialogViewModelTests.swift @@ -0,0 +1,745 @@ +// +// AddEditBookmarkDialogViewModelTests.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 + +@MainActor +final class AddEditBookmarkDialogViewModelTests: XCTestCase { + private var bookmarkManager: LocalBookmarkManager! + private var bookmarkStoreMock: BookmarkStoreMock! + + override func setUpWithError() throws { + try super.setUpWithError() + bookmarkStoreMock = BookmarkStoreMock() + bookmarkStoreMock.bookmarks = [BookmarkFolder.mock] + bookmarkManager = .init(bookmarkStore: bookmarkStoreMock, faviconManagement: FaviconManagerMock()) + bookmarkManager.loadBookmarks() + } + + override func tearDownWithError() throws { + bookmarkStoreMock = nil + bookmarkManager = nil + try super.tearDownWithError() + } + + // MARK: - Copy + + func testReturnAddBookmarkTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.title + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Title.addBookmark) + } + + func testReturnEditBookmarkTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.title + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Title.editBookmark) + } + + func testReturnCancelActionTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.cancelActionTitle + + // THEN + XCTAssertEqual(title, UserText.cancel) + } + + func testReturnCancelActionTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.cancelActionTitle + + // THEN + XCTAssertEqual(title, UserText.cancel) + } + + func testReturnAddBookmarkActionTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.defaultActionTitle + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Action.addBookmark) + } + + func testReturnSaveActionTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.defaultActionTitle + + // THEN + XCTAssertEqual(title, UserText.save) + } + + // MARK: State + + func testShouldSetBookmarkNameToEmptyWhenInitModeIsAddAndTabInfoIsNil() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.bookmarkName + + // THEN + XCTAssertTrue(result.isEmpty) + } + + func testShouldSetNameAndURLToValueWhenInitModeIsAddTabInfoIsNotNilAndURLIsNotAlreadyBookmarked() { + // GIVEN + let tab = Tab(content: .url(URL.duckDuckGo, source: .link), title: "Test") + let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) + + // WHEN + let name = sut.bookmarkName + let url = sut.bookmarkURLPath + + // THEN + XCTAssertEqual(name, "Test") + XCTAssertEqual(url, URL.duckDuckGo.absoluteString) + } + + func testShouldSetNameAndURLToEmptyWhenInitModeIsAddTabInfoIsNotNilAndURLIsAlreadyBookmarked() throws { + // GIVEN + let tab = Tab(content: .url(URL.duckDuckGo, source: .link), title: "Test") + let websiteInfo = try XCTUnwrap(WebsiteInfo(tab)) + let bookmark = Bookmark(id: "1", url: websiteInfo.url.absoluteString, title: websiteInfo.title ?? "", isFavorite: false) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: WebsiteInfo(tab)), bookmarkManager: bookmarkManager) + + // WHEN + let name = sut.bookmarkName + let url = sut.bookmarkURLPath + + // THEN + XCTAssertEqual(name, "") + XCTAssertEqual(url, "") + } + + func testShouldSetBookmarkNameToValueWhenInitAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.bookmarkName + + // THEN + XCTAssertEqual(result, #function) + } + + func testShouldSetFoldersFromBookmarkListWhenInitAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folders + + // THEN + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.entity, folder) + } + + func testShouldSetFoldersFromBookmarkListWhenInitAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folders + + // THEN + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.entity, folder) + } + + func testShouldSetSelectedFolderToNilWhenBookmarkParentFolderIsNilAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertNil(result) + } + + func testShouldSetSelectedFolderToValueWhenParentFolderIsNotNilAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .add(parentFolder: folder), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertEqual(result, folder) + } + + func testShouldSetSelectedFolderToNilWhenParentFolderIsNilAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false, parentFolderUUID: "2") + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertNil(result) + } + + func testShouldSetSelectedFolderToValueWhenParentFolderIsNotNilAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false, parentFolderUUID: "1") + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertEqual(result, folder) + } + + func testShouldSetIsBookmarkFavoriteToTrueWhenModeIsAddAndShouldPresetFavoriteIsTrue() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(shouldPresetFavorite: true), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isBookmarkFavorite + + // THEN + XCTAssertTrue(result) + } + + func testShouldNotSetIsBookmarkFavoriteToTrueWhenModeIsAddAndShouldPresetFavoriteIsFalse() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(shouldPresetFavorite: false), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isBookmarkFavorite + + // THEN + XCTAssertFalse(result) + } + + // MARK: - Actions + + func testReturnIsCancelActionDisabledFalseWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isOtherActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsCancelActionDisabledFalseWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isOtherActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenBookmarkNameIsEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.bookmarkName = "" + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenBookmarkNameIsEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + sut.bookmarkName = "" + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenBookmarkNameIsNotEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.bookmarkName = " DuckDuckGo " + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenBookmarkNameIsNotEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + sut.bookmarkName = " DuckDuckGo " + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenBookmarURLIsEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.bookmarkName = "DuckDuckGo" + sut.bookmarkURLPath = "" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenBookmarkURLIsEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + sut.bookmarkName = "DuckDuckGo" + sut.bookmarkURLPath = "" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenBookmarkURLIsNotEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.bookmarkName = " DuckDuckGo " + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenBookmarkURLIsNotEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: .mock), bookmarkManager: bookmarkManager) + sut.bookmarkName = " DuckDuckGo " + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testShouldCallDismissWhenCancelIsCalled() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + var didCallDismiss = false + + // WHEN + sut.cancel { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + + func testShouldCallDismissWhenAddOrSaveIsCalled() { + // GIVEN + let sut = AddEditBookmarkDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.bookmarkName = "DuckDuckGo" + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + var didCallDismiss = false + + // WHEN + sut.addOrSave { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + + func testShouldAskBookmarkStoreToSaveBookmarkWhenModeIsAddAndURLIsNotAnExistingBookmark() { + // GIVEN + let folder = BookmarkFolder(id: #file, title: #function) + let existingBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: true) + bookmarkStoreMock.bookmarks = [existingBookmark] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkDialogViewModel(mode: .add(parentFolder: folder), bookmarkManager: bookmarkManager) + sut.bookmarkName = "DDG" + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + XCTAssertNil(bookmarkStoreMock.capturedParentFolder) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertTrue(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertTrue(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertEqual(bookmarkStoreMock.capturedObjectUUIDs, [existingBookmark.id]) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .parent(uuid: folder.id)) + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark?.title, "DDG") + XCTAssertEqual(bookmarkStoreMock.capturedBookmark?.url, URL.duckDuckGo.absoluteString) + XCTAssertNil(bookmarkStoreMock.capturedParentFolder) + } + + func testShouldAskBookmarkStoreToUpdateBookmarkWhenModeIsAddAndURLIsAnExistingBookmark() { + // GIVEN + let folder = BookmarkFolder(id: #file, title: #function) + let sut = AddEditBookmarkDialogViewModel(mode: .add(parentFolder: folder), bookmarkManager: bookmarkManager) + sut.bookmarkName = #function + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + XCTAssertNil(bookmarkStoreMock.capturedParentFolder) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertTrue(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark?.title, #function) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark?.url, URL.duckDuckGo.absoluteString) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolder, folder) + } + + func testShouldAskBookmarkStoreToUpdateBookmarkWhenURLIsUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let expectedBookmark = Bookmark(id: "1", url: URL.exti, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + sut.bookmarkURLPath = expectedBookmark.url + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertTrue(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark, expectedBookmark) + } + + func testShouldNotAskBookmarkStoreToUpdateBookmarkWhenURLIsNotUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + sut.bookmarkURLPath = URL.duckDuckGo.absoluteString + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + } + + func testShouldAskBookmarkStoreToUpdateBookmarkWhenNameIsUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let expectedBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.bookmarkName = expectedBookmark.title + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertTrue(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark, expectedBookmark) + } + + func testShouldNotAskBookmarkStoreToUpdateBookmarkWhenNameIsNotUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.bookmarkName = #function + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + } + + func testShouldAskBookmarkStoreToUpdateBookmarkWhenIsFavoriteIsUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let expectedBookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: true) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.isBookmarkFavorite = expectedBookmark.isFavorite + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertTrue(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark, expectedBookmark) + } + + func testShouldNotAskBookmarkStoreToUpdateBookmarkWhenIsFavoriteIsNotUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.isBookmarkFavorite = false + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + } + + func testShouldAskBookmarkStoreToUpdateBookmarkWhenURLAndTitleAndIsFavoriteIsUpdatedAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let expectedBookmark = Bookmark(id: "1", url: URL.exti, title: "DDG", isFavorite: true) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.bookmarkURLPath = expectedBookmark.url + sut.bookmarkName = expectedBookmark.title + sut.isBookmarkFavorite = expectedBookmark.isFavorite + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertNil(bookmarkStoreMock.capturedBookmark) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertTrue(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertEqual(bookmarkStoreMock.capturedBookmark, expectedBookmark) + } + + func testShouldAskBookmarkStoreToMoveBookmarkWhenSelectedFolderIsDifferentFromOriginalFolderAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "ABCDE", title: "Test Folder") + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [folder, bookmark] + bookmarkManager.loadBookmarks() + sut.selectedFolder = folder + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertTrue(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertEqual(bookmarkStoreMock.capturedObjectUUIDs, [bookmark.id]) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .parent(uuid: folder.id)) + } + + func testShouldAskBookmarkStoreToMoveBookmarkWhenSelectedFolderIsNilAndOriginalFolderIsNotRootFolderAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false, parentFolderUUID: "ABCDE") + let folder = BookmarkFolder(id: "ABCDE", title: "Test Folder", children: [bookmark]) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [folder, bookmark] + bookmarkManager.loadBookmarks() + sut.selectedFolder = nil + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertTrue(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertEqual(bookmarkStoreMock.capturedObjectUUIDs, [bookmark.id]) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .root) + } + + func testShouldNotAskBookmarkStoreToMoveBookmarkWhenSelectedFolderIsNotDifferentFromOriginalFolderAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false, parentFolderUUID: "ABCDE") + let folder = BookmarkFolder(id: "ABCDE", title: "Test Folder", children: [bookmark]) + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.selectedFolder = folder + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + } + + func testShouldNotAskBookmarkStoreToMoveBookmarkWhenSelectedFolderIsNilAndOriginalFolderIsRootAndModeIsEdit() { + // GIVEN + let bookmark = Bookmark(id: "1", url: URL.duckDuckGo.absoluteString, title: #function, isFavorite: false, parentFolderUUID: "bookmarks_root") + let sut = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) + bookmarkStoreMock.bookmarks = [bookmark] + bookmarkManager.loadBookmarks() + sut.selectedFolder = nil + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.updateBookmarkCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + } +} diff --git a/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift new file mode 100644 index 0000000000..a48f18d2ab --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/AddEditBookmarkFolderDialogViewModelTests.swift @@ -0,0 +1,425 @@ +// +// AddEditBookmarkFolderDialogViewModelTests.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 + +@MainActor +final class AddEditBookmarkFolderDialogViewModelTests: XCTestCase { + private var bookmarkManager: LocalBookmarkManager! + private var bookmarkStoreMock: BookmarkStoreMock! + + override func setUpWithError() throws { + try super.setUpWithError() + bookmarkStoreMock = BookmarkStoreMock() + bookmarkStoreMock.bookmarks = [BookmarkFolder.mock] + bookmarkManager = .init(bookmarkStore: bookmarkStoreMock, faviconManagement: FaviconManagerMock()) + bookmarkManager.loadBookmarks() + } + + override func tearDownWithError() throws { + bookmarkStoreMock = nil + bookmarkManager = nil + try super.tearDownWithError() + } + + // MARK: - Copy + + func testReturnAddBookmarkFolderTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.title + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Title.addFolder) + } + + func testReturnEditBookmarkFolderTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.title + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Title.editFolder) + } + + func testReturnCancelActionTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.cancelActionTitle + + // THEN + XCTAssertEqual(title, UserText.cancel) + } + + func testReturnCancelActionTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.cancelActionTitle + + // THEN + XCTAssertEqual(title, UserText.cancel) + } + + func testReturnAddBookmarkFolderActionTitleWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.defaultActionTitle + + // THEN + XCTAssertEqual(title, UserText.Bookmarks.Dialog.Action.addFolder) + } + + func testReturnSaveActionTitleWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let title = sut.defaultActionTitle + + // THEN + XCTAssertEqual(title, UserText.save) + } + + // MARK: State + + func testShouldSetFolderNameToEmptyWhenInitAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folderName + + // THEN + XCTAssertTrue(result.isEmpty) + } + + func testShouldSetFolderNameToValueWhenInitAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folderName + + // THEN + XCTAssertEqual(result, #function) + } + + func testShouldSetFoldersFromBookmarkListWhenInitAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folders + + // THEN + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.entity, folder) + } + + func testShouldSetFoldersFromBookmarkListWhenInitAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + bookmarkManager.loadBookmarks() + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.folders + + // THEN + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.entity, folder) + } + + func testShouldSetSelectedFolderToNilWhenParentFolderIsNilAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertNil(result) + } + + func testShouldSetSelectedFolderToValueWhenParentFolderIsNotNilAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertEqual(result, .mock) + } + + func testShouldSetSelectedFolderToNilWhenParentFolderIsNilAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertNil(result) + } + + func testShouldSetSelectedFolderToValueWhenParentFolderIsNotNilAndModeIsEdit() { + // GIVEN + let folder = BookmarkFolder(id: "1", title: #function) + bookmarkStoreMock.bookmarks = [folder] + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: .mock), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.selectedFolder + + // THEN + XCTAssertEqual(result, .mock) + } + + // MARK: - Actions + + func testReturnIsCancelActionDisabledFalseWhenModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isOtherActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsCancelActionDisabledFalseWhenModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + + // WHEN + let result = sut.isOtherActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenFolderNameIsEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.folderName = "" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledTrueWhenFolderNameIsEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + sut.folderName = "" + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertTrue(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenFolderNameIsNotEmptyAndModeIsAdd() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + sut.folderName = " Test " + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testReturnIsDefaultActionButtonDisabledFalseWhenFolderNameIsNotEmptyAndModeIsEdit() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + sut.folderName = " Test " + + // WHEN + let result = sut.isDefaultActionDisabled + + // THEN + XCTAssertFalse(result) + } + + func testShouldCallDismissWhenCancelIsCalled() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + var didCallDismiss = false + + // WHEN + sut.cancel { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + + func testShouldCallDismissWhenAddOrSaveIsCalled() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(), bookmarkManager: bookmarkManager) + var didCallDismiss = false + sut.folderName = "DuckDuckGo" + + // WHEN + sut.addOrSave { + didCallDismiss = true + } + + // THEN + XCTAssertTrue(didCallDismiss) + } + + func testShouldAskBookmarkStoreToSaveFolderWhenAddOrSaveIsCalledAndModeIsAdd() { + // GIVEN + let folder = BookmarkFolder(id: #file, title: #function) + let sut = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: folder), bookmarkManager: bookmarkManager) + sut.folderName = #function + XCTAssertFalse(bookmarkStoreMock.saveFolderCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder) + XCTAssertNil(bookmarkStoreMock.capturedParentFolder) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.updateFolderCalled) + XCTAssertTrue(bookmarkStoreMock.saveFolderCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, sut.folderName) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolder, folder) + } + + func testShouldAskBookmarkStoreToUpdateFolderWhenNameIsChanged() { + // GIVEN + let folder = BookmarkFolder(id: #file, title: #function) + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: nil), bookmarkManager: bookmarkManager) + sut.folderName = "TEST" + XCTAssertFalse(bookmarkStoreMock.updateFolderCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertTrue(bookmarkStoreMock.updateFolderCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolder?.title, sut.folderName) + } + + func testShouldNotAskBookmarkStoreToUpdateFolderWhenNameIsNotChanged() { + // GIVEN + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: .mock, parentFolder: nil), bookmarkManager: bookmarkManager) + XCTAssertFalse(bookmarkStoreMock.updateFolderCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveFolderCalled) + XCTAssertFalse(bookmarkStoreMock.updateFolderCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder?.title) + } + + func testShouldAskBookmarkStoreToMoveFolderToSubfolderWhenSelectedFolderIsDifferentFromOriginalFolder() { + // GIVEN + let location = BookmarkFolder(id: #file, title: #function) + let folder = BookmarkFolder.mock + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: nil), bookmarkManager: bookmarkManager) + sut.selectedFolder = location + XCTAssertFalse(bookmarkStoreMock.updateFolderAndMoveToParentCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveFolderCalled) + XCTAssertTrue(bookmarkStoreMock.updateFolderAndMoveToParentCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolder, folder) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .parent(uuid: #file)) + } + + func testShouldAskBookmarkStoreToMoveFolderToRootFolderWhenSelectedFolderIsDifferentFromOriginalFolder() { + // GIVEN + let folder = BookmarkFolder.mock + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: .mock), bookmarkManager: bookmarkManager) + sut.selectedFolder = nil + XCTAssertFalse(bookmarkStoreMock.updateFolderAndMoveToParentCalled) + XCTAssertNil(bookmarkStoreMock.capturedFolder) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveFolderCalled) + XCTAssertTrue(bookmarkStoreMock.updateFolderAndMoveToParentCalled) + XCTAssertEqual(bookmarkStoreMock.capturedFolder, folder) + XCTAssertEqual(bookmarkStoreMock.capturedParentFolderType, .root) + } + + func testShouldNotAskBookmarkStoreToMoveFolderWhenSelectedFolderIsNotDifferentFromOriginalFolder() { + // GIVEN + let folder = BookmarkFolder.mock + let sut = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: nil), bookmarkManager: bookmarkManager) + sut.selectedFolder = nil + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + + // WHEN + sut.addOrSave {} + + // THEN + XCTAssertFalse(bookmarkStoreMock.saveFolderCalled) + XCTAssertFalse(bookmarkStoreMock.moveObjectUUIDCalled) + XCTAssertNil(bookmarkStoreMock.capturedObjectUUIDs) + XCTAssertNil(bookmarkStoreMock.capturedParentFolderType) + } + +} diff --git a/UnitTests/BookmarksBar/ViewModel/BookmarksBarViewModelTests.swift b/UnitTests/BookmarksBar/ViewModel/BookmarksBarViewModelTests.swift index 32d827186b..269980c361 100644 --- a/UnitTests/BookmarksBar/ViewModel/BookmarksBarViewModelTests.swift +++ b/UnitTests/BookmarksBar/ViewModel/BookmarksBarViewModelTests.swift @@ -91,6 +91,199 @@ class BookmarksBarViewModelTests: XCTestCase { XCTAssert(bookmarksBarViewModel.clippedItems.isEmpty) } + // MARK: - Bookmarks Delegate + + func testWhenItemFiresClickedActionThenDelegateReceivesClickItemActionAndPreventClickIsFalse() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemClicked(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .clickItem) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + + } + + func testWhenItemFiresOpenInNewTabActionThenDelegateReceivesOpenInNewTabAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemOpenInNewTabAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .openInNewTab) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresOpenInNewWindowActionThenDelegateReceivesOpenInNewWindowAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemOpenInNewWindowAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .openInNewWindow) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresToggleFavoritesActionThenDelegateReceivesToggleFavoritesAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemToggleFavoritesAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .toggleFavorites) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresEditActionThenDelegateReceivesEditAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewEditAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .edit) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresMoveToEndActionThenDelegateReceivesMoveToEndAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemMoveToEndAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .moveToEnd) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresCopyBookmarkURLActionThenDelegateReceivesCopyBookmarkURLAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemCopyBookmarkURLAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .copyURL) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresDeleteEntityActionThenDelegateReceivesDeleteEntityAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemDeleteEntityAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .deleteEntity) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresAddEntityActionThenDelegateReceivesAddEntityAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemAddEntityAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .addFolder) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + + func testWhenItemFiresManageBookmarksActionThenDelegateReceivesManageBookmarksAction() { + // GIVEN + let sut = BookmarksBarViewModel(bookmarkManager: createMockBookmarksManager(), tabCollectionViewModel: .mock()) + let collectionViewItem = BookmarksBarCollectionViewItem() + let delegateMock = BookmarksBarViewModelDelegateMock() + sut.delegate = delegateMock + XCTAssertFalse(delegateMock.didCallViewModelReceivedAction) + XCTAssertNil(delegateMock.capturedAction) + XCTAssertNil(delegateMock.capturedItem) + + // WHEN + sut.bookmarksBarCollectionViewItemManageBookmarksAction(collectionViewItem) + + // THEN + XCTAssertTrue(delegateMock.didCallViewModelReceivedAction) + XCTAssertEqual(delegateMock.capturedAction, .manageBookmarks) + XCTAssertEqual(delegateMock.capturedItem, collectionViewItem) + } + private func createMockBookmarksManager(mockBookmarkStore: BookmarkStoreMock = BookmarkStoreMock()) -> BookmarkManager { let mockFaviconManager = FaviconManagerMock() return LocalBookmarkManager(bookmarkStore: mockBookmarkStore, faviconManagement: mockFaviconManager) @@ -107,3 +300,24 @@ fileprivate extension TabCollectionViewModel { } } + +// MARK: - BookmarksBarViewModelDelegateMock + +final class BookmarksBarViewModelDelegateMock: BookmarksBarViewModelDelegate { + private(set) var didCallViewModelReceivedAction = false + private(set) var capturedAction: BookmarksBarViewModel.BookmarksBarItemAction? + private(set) var capturedItem: BookmarksBarCollectionViewItem? + + func bookmarksBarViewModelReceived(action: BookmarksBarViewModel.BookmarksBarItemAction, for item: BookmarksBarCollectionViewItem) { + didCallViewModelReceivedAction = true + capturedAction = action + capturedItem = item + } + + func bookmarksBarViewModelWidthForContainer() -> CGFloat { + 0 + } + + func bookmarksBarViewModelReloadedData() {} + +} diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index c6a0840629..28f65fdf59 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -59,8 +59,12 @@ class MockBookmarkManager: BookmarkManager { func update(bookmark: DuckDuckGo_Privacy_Browser.Bookmark) {} + func update(bookmark: DuckDuckGo_Privacy_Browser.Bookmark, withURL url: URL, title: String, isFavorite: Bool) {} + func update(folder: DuckDuckGo_Privacy_Browser.BookmarkFolder) {} + func update(folder: DuckDuckGo_Privacy_Browser.BookmarkFolder, andMoveToParent parent: DuckDuckGo_Privacy_Browser.ParentFolderType) {} + func updateUrl(of bookmark: DuckDuckGo_Privacy_Browser.Bookmark, to newUrl: URL) -> DuckDuckGo_Privacy_Browser.Bookmark? { return nil }