From ca7539751e5716da8084bc551e584de9ec988b3f Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 30 Aug 2024 16:22:45 +0500 Subject: [PATCH] Unify bookmarks context menu across different views (#3138) Task URL: https://app.asana.com/0/1202406491309510/1207775978136617/f Includes: https://github.com/duckduckgo/macos-browser/pull/3106 https://github.com/duckduckgo/macos-browser/pull/3107 https://github.com/duckduckgo/macos-browser/pull/3110 https://github.com/duckduckgo/macos-browser/pull/3111 https://github.com/duckduckgo/macos-browser/pull/3138 https://github.com/duckduckgo/macos-browser/pull/3112 https://github.com/duckduckgo/macos-browser/pull/3113 --- DuckDuckGo.xcodeproj/project.pbxproj | 124 ++- DuckDuckGo/Application/AppDelegate.swift | 1 + DuckDuckGo/Application/Application.swift | 1 - .../Extensions/NSOutlineViewExtensions.swift | 6 + DuckDuckGo/Bookmarks/Model/Bookmark.swift | 27 +- .../Model/BookmarkDragDropManager.swift | 199 ++++ ...rkListTreeControllerSearchDataSource.swift | 2 +- DuckDuckGo/Bookmarks/Model/BookmarkNode.swift | 33 +- .../Model/BookmarkOutlineViewDataSource.swift | 363 +++---- .../Model/BookmarkSidebarTreeController.swift | 1 + .../Model/BookmarkTreeController.swift | 79 +- DuckDuckGo/Bookmarks/Model/MenuItemNode.swift | 37 + .../Bookmarks/Services/BookmarkStore.swift | 2 +- .../Services/BookmarkStoreMock.swift | 29 +- .../Services/BookmarksContextMenu.swift | 461 +++++++++ .../Bookmarks/Services/ContextualMenu.swift | 278 ----- .../Services/LocalBookmarkStore.swift | 40 +- .../Services/MenuItemSelectors.swift | 2 +- .../Bookmarks/View/BookmarkListPopover.swift | 63 ++ .../View/BookmarkListViewController.swift | 772 ++++++-------- ...okmarkManagementDetailViewController.swift | 296 +----- ...kmarkManagementSidebarViewController.swift | 121 +-- .../View/BookmarkOutlineCellView.swift | 310 +++++- .../View/BookmarkTableCellView.swift | 1 + .../Bookmarks/View/BookmarksOutlineView.swift | 324 +++++- .../Dialog/BookmarksDialogViewFactory.swift | 14 +- .../View/OutlineSeparatorViewCell.swift | 20 +- .../View/RoundedSelectionRowView.swift | 12 +- .../AddEditBookmarkDialogViewModel.swift | 2 +- .../BookmarkManagementDetailViewModel.swift | 59 +- .../ViewModel/SortBookmarksViewModel.swift | 2 +- .../BookmarksBar/View/BookmarksBar.storyboard | 25 +- .../View/BookmarksBarCollectionViewItem.swift | 148 +-- .../View/BookmarksBarCollectionViewItem.xib | 15 +- .../View/BookmarksBarMenuPopover.swift | 168 +++ .../View/BookmarksBarMenuViewController.swift | 969 ++++++++++++++++++ .../View/BookmarksBarViewController.swift | 400 ++++++-- .../View/BookmarksBarViewModel.swift | 253 ++--- .../View/HorizontallyCenteredLayout.swift | 41 +- .../View/ItemCachingCollectionView.swift | 88 ++ .../Extensions/DispatchQueueExtensions.swift | 26 - .../NSDragOperationExtension.swift} | 13 +- .../Common/Extensions/NSEventExtension.swift | 33 + .../NSLayoutConstraintExtension.swift | 13 + .../Extensions/NSPopoverExtension.swift | 31 +- .../Extensions/NSTableViewExtension.swift | 31 + .../Common/Extensions/NSViewExtension.swift | 2 +- .../Common/Extensions/NSWindowExtension.swift | 4 + .../View/AppKit/HoverTrackingArea.swift | 15 +- .../AppKit/MouseOverAnimationButton.swift | 2 +- .../Common/View/AppKit/MouseOverButton.swift | 64 +- .../Common/View/AppKit/MouseOverView.swift | 58 +- .../View/AppKit/SteppedScrollView.swift | 56 + .../ContentBlocker/ContentBlocking.swift | 1 + .../ScriptSourceProviding.swift | 4 +- .../DBP/DataBrokerProtectionDebugMenu.swift | 1 - .../Favicons/Model/FaviconManager.swift | 8 +- .../FileDownload/View/DownloadsCellView.swift | 6 +- DuckDuckGo/Fire/Model/Fire.swift | 8 +- DuckDuckGo/Menus/HistoryMenu.swift | 6 +- DuckDuckGo/Menus/MainMenu.swift | 12 +- .../NavigationBar/View/MoreOptionsMenu.swift | 30 +- .../View/NavigationBarViewController.swift | 51 +- .../ContextualOnboardingDialogs.swift | 2 +- .../View/RecentlyClosedMenu.swift | 3 +- DuckDuckGo/Sharing/SharingMenu.swift | 3 +- .../Subscription/SubscriptionUIHandler.swift | 2 +- DuckDuckGo/Sync/SyncDebugMenu.swift | 1 - DuckDuckGo/Tab/Model/Tab.swift | 4 +- .../Tab/Model/UserContentUpdating.swift | 1 + .../Tab/UserScripts/DebugUserScript.swift | 1 + .../View/WindowControllersManager.swift | 37 + .../AutoconsentMessageProtocolTests.swift | 1 + .../Bookmarks/Model/BookmarkNodeTests.swift | 16 +- .../BookmarkOutlineViewDataSourceTests.swift | 157 ++- .../Bookmarks/Model/ContextualMenuTests.swift | 743 +++++++++++--- .../Model/LocalBookmarkManagerTests.swift | 9 +- .../Model/PasteboardBookmarkTests.swift | 2 +- .../Model/PasteboardFolderTests.swift | 2 +- .../Bookmarks/Model/TreeControllerTests.swift | 4 +- .../BookmarkAllTabsDialogViewModelTests.swift | 12 +- ...okmarkManagementDetailViewModelTests.swift | 156 +-- .../BookmarksBarViewModelTests.swift | 198 +--- .../Extensions/NSPasteboardExtension.swift | 27 + .../ContentBlockingUpdatingTests.swift | 1 + .../HomePage/Mocks/MockBookmarkManager.swift | 43 +- .../RecentlyClosedCoordinatorTests.swift | 28 +- 87 files changed, 5261 insertions(+), 2455 deletions(-) create mode 100644 DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift create mode 100644 DuckDuckGo/Bookmarks/Model/MenuItemNode.swift create mode 100644 DuckDuckGo/Bookmarks/Services/BookmarksContextMenu.swift delete mode 100644 DuckDuckGo/Bookmarks/Services/ContextualMenu.swift create mode 100644 DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift create mode 100644 DuckDuckGo/BookmarksBar/View/BookmarksBarMenuPopover.swift create mode 100644 DuckDuckGo/BookmarksBar/View/BookmarksBarMenuViewController.swift create mode 100644 DuckDuckGo/BookmarksBar/View/ItemCachingCollectionView.swift rename DuckDuckGo/{Bookmarks/Model/BookmarkFolderInfo.swift => Common/Extensions/NSDragOperationExtension.swift} (64%) create mode 100644 DuckDuckGo/Common/Extensions/NSTableViewExtension.swift create mode 100644 DuckDuckGo/Common/View/AppKit/SteppedScrollView.swift create mode 100644 UnitTests/Common/Extensions/NSPasteboardExtension.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 1c924956d6..afbe6a0a02 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -409,7 +409,7 @@ 3706FB3D293F65D500E42796 /* FocusRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693953E26F04BE70015B914 /* FocusRingView.swift */; }; 3706FB3E293F65D500E42796 /* BookmarksBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */; }; 3706FB3F293F65D500E42796 /* NSPopUpButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1E6EEF27AB5E5D00F51793 /* NSPopUpButtonView.swift */; }; - 3706FB40293F65D500E42796 /* ContextualMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */; }; + 3706FB40293F65D500E42796 /* BookmarksContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292DA2667125D00AD2C21 /* BookmarksContextMenu.swift */; }; 3706FB41293F65D500E42796 /* NavigationBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA68C3D22490ED62001B8783 /* NavigationBarViewController.swift */; }; 3706FB42293F65D500E42796 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA585DAE2490E6E600E9A3E2 /* MainViewController.swift */; }; 3706FB43293F65D500E42796 /* DuckPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F19A6928E2F2D000740DC6 /* DuckPlayer.swift */; }; @@ -1284,7 +1284,7 @@ 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 */; }; - 4B9292DB2667125D00AD2C21 /* ContextualMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */; }; + 4B9292DB2667125D00AD2C21 /* BookmarksContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292DA2667125D00AD2C21 /* BookmarksContextMenu.swift */; }; 4B9579212AC687170062CA31 /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; 4B9579222AC687170062CA31 /* HardwareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9579202AC687170062CA31 /* HardwareModel.swift */; }; 4B9754EC2984300100D7B834 /* EmailManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B3B848F297A0E1000A384BD /* EmailManagerExtension.swift */; }; @@ -1664,8 +1664,28 @@ 7BFF35732C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; 7BFF35742C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; 7BFF850F2B0C09DA00ECACA2 /* DuckDuckGo Personal Information Removal.app in Embed Login Items */ = {isa = PBXBuildFile; fileRef = 9D9AE8D12AAA39A70026E7DC /* DuckDuckGo Personal Information Removal.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8400DC4B2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */; }; + 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */; }; + 8400DC4E2C6E2770006509D2 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */; }; + 8400DC4F2C6E2770006509D2 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */; }; + 841BE93B2C6F1CB200E9C2B5 /* MenuItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841BE93A2C6F1CB200E9C2B5 /* MenuItemNode.swift */; }; + 841BE93C2C6F1CB200E9C2B5 /* MenuItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841BE93A2C6F1CB200E9C2B5 /* MenuItemNode.swift */; }; + 841BE93E2C6F236000E9C2B5 /* BookmarkDragDropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841BE93D2C6F235F00E9C2B5 /* BookmarkDragDropManager.swift */; }; + 841BE93F2C6F236000E9C2B5 /* BookmarkDragDropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841BE93D2C6F235F00E9C2B5 /* BookmarkDragDropManager.swift */; }; + 843965122C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */; }; + 843965132C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */; }; + 843965152C737022004C8899 /* NSPasteboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965142C737022004C8899 /* NSPasteboardExtension.swift */; }; + 843965162C737022004C8899 /* NSPasteboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843965142C737022004C8899 /* NSPasteboardExtension.swift */; }; + 843D73BB2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */; }; + 843D73BC2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */; }; + 848648A12C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */; }; + 848648A22C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */; }; 84DC715A2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */; }; 84DC715B2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */; }; + 84F1C8CF2C7705B500716446 /* BookmarksBarMenuPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8CE2C7705B500716446 /* BookmarksBarMenuPopover.swift */; }; + 84F1C8D02C7705B500716446 /* BookmarksBarMenuPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8CE2C7705B500716446 /* BookmarksBarMenuPopover.swift */; }; + 84F1C8DE2C774D4200716446 /* NSTableViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DD2C774D4200716446 /* NSTableViewExtension.swift */; }; + 84F1C8DF2C774D4200716446 /* NSTableViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1C8DD2C774D4200716446 /* NSTableViewExtension.swift */; }; 85012B0229133F9F003D0DCC /* NavigationBarPopovers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */; }; 850E8DFB2A6FEC5E00691187 /* BookmarksBarAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */; }; 8511E18425F82B34002F516B /* 01_Fire_really_small.json in Resources */ = {isa = PBXBuildFile; fileRef = 8511E18325F82B34002F516B /* 01_Fire_really_small.json */; }; @@ -1854,8 +1874,6 @@ 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 */; }; 9F8D57322BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */; }; 9F8D57332BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */; }; 9F982F0D2B8224BF00231028 /* AddEditBookmarkFolderDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */; }; @@ -3406,7 +3424,7 @@ 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 = ""; }; - 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextualMenu.swift; sourceTree = ""; }; + 4B9292DA2667125D00AD2C21 /* BookmarksContextMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksContextMenu.swift; sourceTree = ""; }; 4B9579202AC687170062CA31 /* HardwareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareModel.swift; sourceTree = ""; }; 4B980E202817604000282EE1 /* NSNotificationName+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSNotificationName+Debug.swift"; sourceTree = ""; }; 4B98D27928D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumFaviconsReaderTests.swift; sourceTree = ""; }; @@ -3627,7 +3645,17 @@ 7BEC20412B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkFolderPopoverView.swift; sourceTree = ""; }; 7BF1A9D72AE054D300FCA683 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCServiceLauncher.swift; sourceTree = ""; }; + 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemCachingCollectionView.swift; sourceTree = ""; }; + 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SteppedScrollView.swift; sourceTree = ""; }; + 841BE93A2C6F1CB200E9C2B5 /* MenuItemNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuItemNode.swift; sourceTree = ""; }; + 841BE93D2C6F235F00E9C2B5 /* BookmarkDragDropManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkDragDropManager.swift; sourceTree = ""; }; + 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSDragOperationExtension.swift; sourceTree = ""; }; + 843965142C737022004C8899 /* NSPasteboardExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPasteboardExtension.swift; sourceTree = ""; }; + 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuViewController.swift; sourceTree = ""; }; 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsWrapperTests.swift; sourceTree = ""; }; + 84F1C8CE2C7705B500716446 /* BookmarksBarMenuPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarMenuPopover.swift; sourceTree = ""; }; + 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListPopover.swift; sourceTree = ""; }; + 84F1C8DD2C774D4200716446 /* NSTableViewExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSTableViewExtension.swift; sourceTree = ""; }; 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopovers.swift; sourceTree = ""; }; 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarAppearance.swift; sourceTree = ""; }; 8511E18325F82B34002F516B /* 01_Fire_really_small.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = 01_Fire_really_small.json; sourceTree = ""; }; @@ -3766,7 +3794,6 @@ 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 = ""; }; 9F8D57312BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsBookmarkFoldersStoreTests.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 = ""; }; @@ -6040,13 +6067,16 @@ isa = PBXGroup; children = ( 859F30622A72A7A900C20372 /* Prompt */, - 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */, - 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */, 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */, 4BE5336A286912D40019DBFD /* BookmarksBarCollectionViewItem.swift */, 4BE53369286912D40019DBFD /* BookmarksBarCollectionViewItem.xib */, - 4BE5336D286915A10019DBFD /* HorizontallyCenteredLayout.swift */, 85774AFE2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift */, + 84F1C8CE2C7705B500716446 /* BookmarksBarMenuPopover.swift */, + 848648A02C76F4B20082282D /* BookmarksBarMenuViewController.swift */, + 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */, + 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */, + 4BE5336D286915A10019DBFD /* HorizontallyCenteredLayout.swift */, + 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */, ); path = View; sourceTree = ""; @@ -6439,6 +6469,7 @@ B6E3E5572BBFD51400A41922 /* PreviewViewController.swift */, B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */, B693954226F04BE90015B914 /* ShadowView.swift */, + 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */, 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */, B693954526F04BEA0015B914 /* WindowDraggingView.swift */, ); @@ -6470,8 +6501,8 @@ 859F30622A72A7A900C20372 /* Prompt */ = { isa = PBXGroup; children = ( - 859F30632A72A7BB00C20372 /* BookmarksBarPromptPopover.swift */, 859F30662A72B38500C20372 /* BookmarksBarPromptAssets.xcassets */, + 859F30632A72A7BB00C20372 /* BookmarksBarPromptPopover.swift */, ); path = Prompt; sourceTree = ""; @@ -7576,16 +7607,16 @@ children = ( B69A14F12B4D6FE800B9417D /* AddBookmarkFolderPopoverViewModel.swift */, B69A14F52B4D701F00B9417D /* AddBookmarkPopoverViewModel.swift */, - AAB549DE25DAB8F80058460B /* BookmarkViewModel.swift */, - 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */, - 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */, - 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */, 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */, + 9F56CFAC2B84326C00BB7F11 /* AddEditBookmarkDialogViewModel.swift */, + 9F982F0C2B8224BE00231028 /* AddEditBookmarkFolderDialogViewModel.swift */, 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */, 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */, - BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */, BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */, + 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */, BBE013E92C5BFD660025F2C6 /* BookmarksEmptyStateContent.swift */, + AAB549DE25DAB8F80058460B /* BookmarkViewModel.swift */, + BBFB727E2C48047C0088884C /* SortBookmarksViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -7660,14 +7691,15 @@ AAC5E4C425D6A6E8007F5990 /* AddBookmarkPopover.swift */, 7BEC20402B0F505F00243D3E /* AddBookmarkPopoverView.swift */, B69A14F92B4D705D00B9417D /* BookmarkFolderPicker.swift */, + 84F1C8DA2C774CA900716446 /* BookmarkListPopover.swift */, 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */, 4B9292CD2667123700AD2C21 /* BookmarkManagementDetailViewController.swift */, 4B9292C72667123700AD2C21 /* BookmarkManagementSidebarViewController.swift */, 4B9292C82667123700AD2C21 /* BookmarkManagementSplitViewController.swift */, - 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */, 4B92928726670D1600AD2C21 /* BookmarkOutlineCellView.swift */, 4B92928526670D1600AD2C21 /* BookmarksOutlineView.swift */, 4B92928926670D1700AD2C21 /* BookmarkTableCellView.swift */, + 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */, 4B9292C62667123700AD2C21 /* BrowserTabSelectionDelegate.swift */, 4B92928626670D1600AD2C21 /* OutlineSeparatorViewCell.swift */, 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */, @@ -7678,25 +7710,26 @@ AAC5E4C225D6A6C7007F5990 /* Model */ = { isa = PBXGroup; children = ( + AAC5E4CD25D6A709007F5990 /* Bookmark.swift */, + 841BE93D2C6F235F00E9C2B5 /* BookmarkDragDropManager.swift */, + AAC5E4CF25D6A709007F5990 /* BookmarkList.swift */, 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */, + BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, 4B92929926670D2A00AD2C21 /* BookmarkManagedObject.swift */, + AAC5E4CE25D6A709007F5990 /* BookmarkManager.swift */, 4B92929326670D2A00AD2C21 /* BookmarkNode.swift */, 4B92929126670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift */, + 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, 4B92929426670D2A00AD2C21 /* BookmarkSidebarTreeController.swift */, + BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, + 4B92929726670D2A00AD2C21 /* BookmarkTreeController.swift */, + 841BE93A2C6F1CB200E9C2B5 /* MenuItemNode.swift */, 4B92929526670D2A00AD2C21 /* PasteboardBookmark.swift */, 4B92929226670D2A00AD2C21 /* PasteboardFolder.swift */, 4B92929A26670D2A00AD2C21 /* PasteboardWriting.swift */, 4B92929826670D2A00AD2C21 /* PseudoFolder.swift */, 4B92929626670D2A00AD2C21 /* SpacerNode.swift */, - 4B92929726670D2A00AD2C21 /* BookmarkTreeController.swift */, - AAC5E4CD25D6A709007F5990 /* Bookmark.swift */, - AAC5E4CF25D6A709007F5990 /* BookmarkList.swift */, - AAC5E4CE25D6A709007F5990 /* BookmarkManager.swift */, - 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */, - 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, - BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, - BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, ); path = Model; sourceTree = ""; @@ -7705,11 +7738,11 @@ isa = PBXGroup; children = ( 987799F52999996B005D8EB6 /* BookmarkDatabase.swift */, - AA652CDA25DDAB32009059CC /* BookmarkStoreMock.swift */, - 4B9292DA2667125D00AD2C21 /* ContextualMenu.swift */, - B6DA06E52913F39400225DE2 /* MenuItemSelectors.swift */, + 4B9292DA2667125D00AD2C21 /* BookmarksContextMenu.swift */, AAC5E4D625D6A710007F5990 /* BookmarkStore.swift */, + AA652CDA25DDAB32009059CC /* BookmarkStoreMock.swift */, 987799F829999973005D8EB6 /* LocalBookmarkStore.swift */, + B6DA06E52913F39400225DE2 /* MenuItemSelectors.swift */, 9FA5A0A42BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift */, ); path = Services; @@ -7812,8 +7845,8 @@ B6DB3CF826A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift */, B6106B9D26A565DA0013B453 /* BundleExtension.swift */, B626A75F2992407C00053070 /* CancellableExtension.swift */, - 4BA1A6C1258B0A1300F6F690 /* ContiguousBytesExtension.swift */, B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */, + 4BA1A6C1258B0A1300F6F690 /* ContiguousBytesExtension.swift */, 85AC3AF625D5DBFD00C7D2AA /* DataExtension.swift */, B6040855274B830F00680351 /* DictionaryExtension.swift */, B63D467025BFA6C100874977 /* DispatchQueueExtensions.swift */, @@ -7822,7 +7855,6 @@ B634DBE6293C98C500C3C99E /* FutureExtension.swift */, 1DA6D0FC2A1FF9A100540406 /* HTTPCookie.swift */, AAECA41F24EEA4AC00EFA63A /* IndexPathExtension.swift */, - EEE50C282C38249C003DD7FF /* OptionalExtension.swift */, 0230C0A2272080090018F728 /* KeyedCodingExtension.swift */, 4B8D9061276D1D880078DB17 /* LocaleExtension.swift */, B66B9C5B29A5EBAD0010E8F3 /* NavigationActionExtension.swift */, @@ -7834,6 +7866,7 @@ B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */, B63D467925BFC3E100874977 /* NSCoderExtensions.swift */, F41D174025CB131900472416 /* NSColorExtension.swift */, + 843965112C6F2FFE004C8899 /* NSDragOperationExtension.swift */, 31B4AF522901A4F20013585E /* NSEventExtension.swift */, B657841825FA484B00D8DB33 /* NSException+Catch.h */, B657841925FA484B00D8DB33 /* NSException+Catch.m */, @@ -7854,6 +7887,7 @@ B6B3E0DC2657E9CF0040E0A2 /* NSScreenExtension.swift */, AAC5E4E325D6BA9C007F5990 /* NSSizeExtension.swift */, 4B39AAF527D9B2C700A73FD5 /* NSStackViewExtension.swift */, + 84F1C8DD2C774D4200716446 /* NSTableViewExtension.swift */, AA5C8F58258FE21F00748EB7 /* NSTextFieldExtension.swift */, 858A798426A8BB5D00A75A42 /* NSTextViewExtension.swift */, 4B0511E0262CAA8600F6079C /* NSViewControllerExtension.swift */, @@ -7861,9 +7895,10 @@ AA9E9A5525A3AE8400D1959D /* NSWindowExtension.swift */, B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */, B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */, + EEE50C282C38249C003DD7FF /* OptionalExtension.swift */, B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */, - B684592125C93BE000DC17B6 /* Publisher.asVoid.swift */, B68C2FB127706E6A00BF2C7D /* ProcessExtension.swift */, + B684592125C93BE000DC17B6 /* Publisher.asVoid.swift */, B684592625C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift */, 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */, 1DFAB51C2A8982A600A0F7F6 /* SetExtension.swift */, @@ -7879,11 +7914,11 @@ B65C7DFA2B886CF0001E2D5C /* WKPDFHUDViewWrapper.swift */, B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */, AAA0CC69253CC43C0079BC96 /* WKUserContentControllerExtension.swift */, + 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */, B63D466725BEB6C200874977 /* WKWebView+Private.h */, B63D466825BEB6C200874977 /* WKWebView+SessionState.swift */, B68458CC25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift */, AA92127625ADA07900600CD4 /* WKWebViewExtension.swift */, - 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -7993,6 +8028,7 @@ 4B8AD0B027A86D9200AE44D6 /* WKWebsiteDataStoreExtensionTests.swift */, B6AA64722994B43300D99CD6 /* FutureExtensionTests.swift */, B684121F2B6A30680092F66A /* StringExtensionTests.swift */, + 843965142C737022004C8899 /* NSPasteboardExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -10006,6 +10042,7 @@ 3706FA85293F65D500E42796 /* KeyedCodingExtension.swift in Sources */, 3706FA87293F65D500E42796 /* DownloadListStore.swift in Sources */, 37197EAB2942443D00394917 /* WebViewContainerView.swift in Sources */, + 84F1C8DF2C774D4200716446 /* NSTableViewExtension.swift in Sources */, 3706FA88293F65D500E42796 /* Logger+Multiple.swift in Sources */, 3706FA89293F65D500E42796 /* CrashReportPromptPresenter.swift in Sources */, 3706FA8B293F65D500E42796 /* PreferencesRootView.swift in Sources */, @@ -10132,6 +10169,7 @@ 1D36E65C298ACD2900AA485D /* AppIconChanger.swift in Sources */, 3706FAE9293F65D500E42796 /* PDFSearchTextMenuItemHandler.swift in Sources */, 3706FAEB293F65D500E42796 /* HistoryMenu.swift in Sources */, + 84F1C8D02C7705B500716446 /* BookmarksBarMenuPopover.swift in Sources */, 3706FAEC293F65D500E42796 /* ContentScopeFeatureFlagging.swift in Sources */, 3706FAED293F65D500E42796 /* OnboardingButtonStyles.swift in Sources */, 3706FAEE293F65D500E42796 /* SaveIdentityPopover.swift in Sources */, @@ -10141,6 +10179,7 @@ 566B736A2BECC02D00FF1959 /* SyncAlertsPresenter.swift in Sources */, 3706FAF1293F65D500E42796 /* PreferencesAboutView.swift in Sources */, 3706FAF2293F65D500E42796 /* ContentBlocking.swift in Sources */, + 843965132C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */, 31F2D2002AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, 9FA173E02B7A0EFE00EE4E6E /* BookmarkDialogButtonsView.swift in Sources */, 3706FAF3293F65D500E42796 /* LocalAuthenticationService.swift in Sources */, @@ -10258,7 +10297,7 @@ 3706FB3F293F65D500E42796 /* NSPopUpButtonView.swift in Sources */, F1FDC9392BF51F41006B1435 /* VPNSettings+Environment.swift in Sources */, 1ED910D62B63BFB300936947 /* IdentityTheftRestorationPagesUserScript.swift in Sources */, - 3706FB40293F65D500E42796 /* ContextualMenu.swift in Sources */, + 3706FB40293F65D500E42796 /* BookmarksContextMenu.swift in Sources */, 3706FB41293F65D500E42796 /* NavigationBarViewController.swift in Sources */, 3707C71C294B5D1900682A9F /* TabExtensionsBuilder.swift in Sources */, 3706FB42293F65D500E42796 /* MainViewController.swift in Sources */, @@ -10272,6 +10311,7 @@ 3168506E2AF3AD1D009A2828 /* WaitlistViewControllerPresenter.swift in Sources */, 3706FB49293F65D500E42796 /* NSException+Catch.swift in Sources */, B6B71C592B23379600487131 /* NSLayoutConstraintExtension.swift in Sources */, + 848648A22C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */, 3706FB4A293F65D500E42796 /* PasswordManagementNoteModel.swift in Sources */, 3706FB4B293F65D500E42796 /* CookieNotificationAnimationModel.swift in Sources */, 3706FB4C293F65D500E42796 /* SharingMenu.swift in Sources */, @@ -10458,6 +10498,7 @@ 3706FBBE293F65D500E42796 /* DownloadListViewModel.swift in Sources */, 3706FBBF293F65D500E42796 /* BookmarkManagementDetailViewController.swift in Sources */, 7BB4BC642C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */, + 841BE93F2C6F236000E9C2B5 /* BookmarkDragDropManager.swift in Sources */, B6B4D1CB2B0C8C9200C26286 /* FirefoxCompatibilityPreferences.swift in Sources */, F188268E2BBF01C400D9AC4F /* PixelDataModel.xcdatamodeld in Sources */, 3706FBC0293F65D500E42796 /* CSVImporter.swift in Sources */, @@ -10721,6 +10762,7 @@ B6DE57F72B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, 3706FEB9293F6EFF00E42796 /* BWVault.swift in Sources */, B6B5F5852B03580A008DB58A /* RequestFilePermissionView.swift in Sources */, + 8400DC4F2C6E2770006509D2 /* SteppedScrollView.swift in Sources */, 3706FC60293F65D500E42796 /* TabBarCollectionView.swift in Sources */, 3706FC61293F65D500E42796 /* NSAlertExtension.swift in Sources */, 3706FC62293F65D500E42796 /* ThirdPartyBrowser.swift in Sources */, @@ -10732,7 +10774,6 @@ 3706FC67293F65D500E42796 /* OperatingSystemVersionExtension.swift in Sources */, 7B6545EE2C0779D500115BEA /* VPNControllerUDSClient+ConvenienceInitializers.swift in Sources */, B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */, - 9F872DA42B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, 7B60AFFE2C514269008E32A3 /* VPNURLEventHandler.swift in Sources */, B677FC502B06376B0099EB04 /* ReportFeedbackView.swift in Sources */, 3706FC68293F65D500E42796 /* ToggleableScrollView.swift in Sources */, @@ -10751,6 +10792,7 @@ 9FDA6C222B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, C1372EF52BBC5BAD003F8793 /* SecureTextField.swift in Sources */, 316913242BD2B6250051B46D /* DataBrokerProtectionPixelsHandler.swift in Sources */, + 843D73BC2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */, 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, 4BF0E5132AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, 3706FC78293F65D500E42796 /* SecureVaultReporter.swift in Sources */, @@ -10804,6 +10846,7 @@ 3706FC9A293F65D500E42796 /* AutoconsentManagement.swift in Sources */, 3706FC9C293F65D500E42796 /* BookmarkStore.swift in Sources */, 3706FC9D293F65D500E42796 /* PrivacyDashboardViewController.swift in Sources */, + 841BE93C2C6F1CB200E9C2B5 /* MenuItemNode.swift in Sources */, B6A22B632B1E29D000ECD2BA /* DataImportSummaryViewModel.swift in Sources */, 7BB4BC6B2C5CD96200E06FC8 /* ActiveDomainPublisher.swift in Sources */, 3706FC9E293F65D500E42796 /* PreferencesAppearanceView.swift in Sources */, @@ -10819,6 +10862,7 @@ 372A0FED2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */, 1D0DE93F2C3BA9840037ABC2 /* AppRestarter.swift in Sources */, 3706FCA4293F65D500E42796 /* RecentlyClosedMenu.swift in Sources */, + 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */, ); @@ -10890,6 +10934,7 @@ 3706FE01293F661700E42796 /* PixelStoreMock.swift in Sources */, 3706FE02293F661700E42796 /* BookmarksBarViewModelTests.swift in Sources */, 3706FE03293F661700E42796 /* CoreDataStoreTests.swift in Sources */, + 843965162C737022004C8899 /* NSPasteboardExtension.swift in Sources */, 1D9FDEC42B9B63C90040B78C /* DataClearingPreferencesTests.swift in Sources */, 3706FE04293F661700E42796 /* TreeControllerTests.swift in Sources */, 56CE77622C7DFCF800AC1ED2 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */, @@ -11448,6 +11493,7 @@ 3158B1532B0BF75700AF130C /* LoginItem+DataBrokerProtection.swift in Sources */, 1D2DC0082901679E008083A1 /* BWResponse.swift in Sources */, AADCBF3A26F7C2CE00EF67A8 /* LottieAnimationCache.swift in Sources */, + 841BE93B2C6F1CB200E9C2B5 /* MenuItemNode.swift in Sources */, 4B9DB0412A983B24000927DB /* WaitlistDialogView.swift in Sources */, 37D2377A287EB8CA00BCE03B /* TabIndex.swift in Sources */, B60C6F8D29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */, @@ -11495,6 +11541,7 @@ B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */, 1D1A33492A6FEB170080ACED /* BurnerMode.swift in Sources */, 14505A08256084EF00272CC6 /* UserAgent.swift in Sources */, + 841BE93E2C6F236000E9C2B5 /* BookmarkDragDropManager.swift in Sources */, 1D39E5772C2BFD5700757339 /* ReleaseNotesTabExtension.swift in Sources */, 987799F12999993C005D8EB6 /* LegacyBookmarkStore.swift in Sources */, 1D220BF82B86192200F8BBC6 /* PreferencesEmailProtectionView.swift in Sources */, @@ -11542,6 +11589,7 @@ B62B483E2ADE48DE000DECE5 /* ArrayBuilder.swift in Sources */, 4B92929B26670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift in Sources */, 56D145EB29E6C99B00E3488A /* DataImportStatusProviding.swift in Sources */, + 843965122C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */, 31D5375C291D944100407A95 /* PasswordManagementBitwardenItemView.swift in Sources */, 1D9297BF2C1B062900A38521 /* ApplicationUpdateDetector.swift in Sources */, B6104E9B2BA9C173008636B2 /* DownloadResumeData.swift in Sources */, @@ -11590,6 +11638,7 @@ F18826912BC0105800D9AC4F /* PixelDataStore.swift in Sources */, B69B503E2726A12500758A2B /* AtbParser.swift in Sources */, 37F19A6528E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift in Sources */, + 84F1C8DE2C774D4200716446 /* NSTableViewExtension.swift in Sources */, 7B7F5D212C526CE600826256 /* AddExcludedDomainView.swift in Sources */, 4B92929E26670D2A00AD2C21 /* BookmarkSidebarTreeController.swift in Sources */, EEC4A6712B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, @@ -11632,7 +11681,6 @@ AAAB9114288EB1D600A057A9 /* CleanThisHistoryMenuItem.swift in Sources */, 1D0DE9412C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */, B6C0B23626E732000031CB7F /* DownloadListItem.swift in Sources */, - 9F872DA32B90920F00138637 /* BookmarkFolderInfo.swift in Sources */, 4B9DB0232A983B24000927DB /* WaitlistRequest.swift in Sources */, 4BE3A6C12C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */, B6B1E87E26D5DA0E0062C350 /* DownloadsPopover.swift in Sources */, @@ -11736,7 +11784,7 @@ 4B1E6EF127AB5E5D00F51793 /* NSPopUpButtonView.swift in Sources */, 372A0FEC2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */, 85774B032A71CDD000DE0561 /* BlockMenuItem.swift in Sources */, - 4B9292DB2667125D00AD2C21 /* ContextualMenu.swift in Sources */, + 4B9292DB2667125D00AD2C21 /* BookmarksContextMenu.swift in Sources */, AA68C3D32490ED62001B8783 /* NavigationBarViewController.swift in Sources */, AA585DAF2490E6E600E9A3E2 /* MainViewController.swift in Sources */, F1D43AEE2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */, @@ -11857,6 +11905,7 @@ 4B9292D02667123700AD2C21 /* BookmarkManagementSplitViewController.swift in Sources */, 31F2D1FF2AF026D800BF0144 /* WaitlistTermsAndConditionsActionHandler.swift in Sources */, B6B140882ABDBCC1004F8E85 /* HoverTrackingArea.swift in Sources */, + 84F1C8CF2C7705B500716446 /* BookmarksBarMenuPopover.swift in Sources */, 3171D6DB2889B64D0068632A /* CookieManagedNotificationContainerView.swift in Sources */, B6E61EE3263AC0C8004E11AB /* FileManagerExtension.swift in Sources */, B6DB3CFB26A17CB800D459B7 /* PermissionModel.swift in Sources */, @@ -11892,6 +11941,7 @@ 3158B14D2B0BF74D00AF130C /* DataBrokerProtectionManager.swift in Sources */, BBFB727F2C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, 4BA1A6A5258B07DF00F6F690 /* EncryptedValueTransformer.swift in Sources */, + 8400DC4B2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, B634DBE1293C8FD500C3C99E /* Tab+Dialogs.swift in Sources */, 4B92929F26670D2A00AD2C21 /* PasteboardBookmark.swift in Sources */, 37BF3F14286D8A6500BD9014 /* PinnedTabsManager.swift in Sources */, @@ -11978,6 +12028,7 @@ F118EA852BEACC7000F77634 /* NonStandardPixel.swift in Sources */, 377D8D642C105B8B001F5F6B /* NSAccessibilityProtocolExtension.swift in Sources */, 1DB9618329F67F6200CF5568 /* FaviconNullStore.swift in Sources */, + 8400DC4E2C6E2770006509D2 /* SteppedScrollView.swift in Sources */, BB5789722B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift in Sources */, B693954F26F04BEB0015B914 /* PaddedImageButton.swift in Sources */, 4BA1A6B8258B081600F6F690 /* EncryptionKeyStoring.swift in Sources */, @@ -12033,6 +12084,7 @@ 4B0135CE2729F1AA00D54834 /* NSPasteboardExtension.swift in Sources */, 85707F31276A7DCA00DC0649 /* OnboardingViewModel.swift in Sources */, 85AC3B0525D6B1D800C7D2AA /* ScriptSourceProviding.swift in Sources */, + 848648A12C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */, 4BB99D0026FE191E001E4761 /* CoreDataBookmarkImporter.swift in Sources */, C168B9AC2B31DC7E001AFAD9 /* AutofillNeverPromptWebsitesManager.swift in Sources */, 9FA173E72B7B122E00EE4E6E /* BookmarkDialogStackedContentView.swift in Sources */, @@ -12265,6 +12317,7 @@ 4B4D60B62A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, FD23FD2D2886A81D007F6985 /* AutoconsentManagement.swift in Sources */, 4B4D60D32A0C84F700BCD287 /* UserText+NetworkProtection.swift in Sources */, + 843D73BB2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */, B6B2400E28083B49001B8F3A /* WebViewContainerView.swift in Sources */, AAC5E4D925D6A711007F5990 /* BookmarkStore.swift in Sources */, B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */, @@ -12430,6 +12483,7 @@ 4B723E1926B000DC00E14D75 /* TemporaryFileCreator.swift in Sources */, 98EB5D1027516A4800681FE6 /* AppPrivacyConfigurationTests.swift in Sources */, 1D9FDEBA2B9B5E090040B78C /* WebTrackingProtectionPreferencesTests.swift in Sources */, + 843965152C737022004C8899 /* NSPasteboardExtension.swift in Sources */, 4B9292C22667103100AD2C21 /* BookmarkTests.swift in Sources */, 5601FECD29B7973D00068905 /* TabBarViewItemTests.swift in Sources */, 1D8C2FE52B70F4C4005E4BBD /* TabSnapshotExtensionTests.swift in Sources */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 3699ba82d2..23088b7bd8 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -149,6 +149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return firstLaunchDate >= Date.weekAgo } + @MainActor override init() { // will not add crash handlers and will fire pixel on applicationDidFinishLaunching if didCrashDuringCrashHandlersSetUp == true let didCrashDuringCrashHandlersSetUp = UserDefaultsWrapper(key: .didCrashDuringCrashHandlersSetUp, defaultValue: false) diff --git a/DuckDuckGo/Application/Application.swift b/DuckDuckGo/Application/Application.swift index 675aa6a793..09b13d6c87 100644 --- a/DuckDuckGo/Application/Application.swift +++ b/DuckDuckGo/Application/Application.swift @@ -23,7 +23,6 @@ import Foundation final class Application: NSApplication { private let copyHandler = CopyHandler() -// private var _delegate: AppDelegate! public static var appDelegate: AppDelegate! override init() { diff --git a/DuckDuckGo/Bookmarks/Extensions/NSOutlineViewExtensions.swift b/DuckDuckGo/Bookmarks/Extensions/NSOutlineViewExtensions.swift index d55067cf53..bf576d65f7 100644 --- a/DuckDuckGo/Bookmarks/Extensions/NSOutlineViewExtensions.swift +++ b/DuckDuckGo/Bookmarks/Extensions/NSOutlineViewExtensions.swift @@ -38,6 +38,12 @@ extension NSOutlineView { selectedNodes.compactMap { $0.representedObject as? PseudoFolder } } + func rowIfValid(forItem item: Any?) -> Int? { + let row = row(forItem: item) + guard row >= 0, row != NSNotFound else { return nil } + return row + } + func revealAndSelect(nodePath: BookmarkNode.Path) { let totalNodePathComponents = nodePath.components.count if totalNodePathComponents < 2 { diff --git a/DuckDuckGo/Bookmarks/Model/Bookmark.swift b/DuckDuckGo/Bookmarks/Model/Bookmark.swift index f4cd289182..3edb44f2ac 100644 --- a/DuckDuckGo/Bookmarks/Model/Bookmark.swift +++ b/DuckDuckGo/Bookmarks/Model/Bookmark.swift @@ -55,14 +55,13 @@ internal class BaseBookmarkEntity: Identifiable, Equatable, Hashable { let id: String var title: String let isFolder: Bool + let parentFolderUUID: String? - fileprivate init(id: String, - title: String, - isFolder: Bool) { - + fileprivate init(id: String, title: String, isFolder: Bool, parentFolderUUID: String?) { self.id = id self.title = title self.isFolder = isFolder + self.parentFolderUUID = parentFolderUUID } static func from( @@ -129,7 +128,6 @@ final class BookmarkFolder: BaseBookmarkEntity { return request } - let parentFolderUUID: String? let children: [BaseBookmarkEntity] let totalChildBookmarks: Int @@ -141,11 +139,7 @@ final class BookmarkFolder: BaseBookmarkEntity { return children.compactMap { $0 as? BookmarkFolder } } - init(id: String, - title: String, - parentFolderUUID: String? = nil, - children: [BaseBookmarkEntity] = []) { - self.parentFolderUUID = parentFolderUUID + init(id: String, title: String, parentFolderUUID: String? = nil, children: [BaseBookmarkEntity] = []) { self.children = children let childFolders = children.compactMap({ $0 as? BookmarkFolder }) @@ -154,7 +148,7 @@ final class BookmarkFolder: BaseBookmarkEntity { self.totalChildBookmarks = childBookmarks.count + subfolderBookmarksCount - super.init(id: id, title: title, isFolder: true) + super.init(id: id, title: title, isFolder: true, parentFolderUUID: parentFolderUUID) } override func isEqual(to instance: BaseBookmarkEntity) -> Bool { @@ -203,7 +197,6 @@ final class Bookmark: BaseBookmarkEntity { let url: String var isFavorite: Bool - private(set) var parentFolderUUID: String? public var urlObject: URL? { return url.isBookmarklet() ? url.toEncodedBookmarklet() : URL(string: url) @@ -223,18 +216,12 @@ final class Bookmark: BaseBookmarkEntity { } } - init(id: String, - url: String, - title: String, - isFavorite: Bool, - parentFolderUUID: String? = nil, - faviconManagement: FaviconManagement = FaviconManager.shared) { + init(id: String, url: String, title: String, isFavorite: Bool, parentFolderUUID: String? = nil, faviconManagement: FaviconManagement = FaviconManager.shared) { self.url = url self.isFavorite = isFavorite - self.parentFolderUUID = parentFolderUUID self.faviconManagement = faviconManagement - super.init(id: id, title: title, isFolder: false) + super.init(id: id, title: title, isFolder: false, parentFolderUUID: parentFolderUUID) } convenience init(from bookmark: Bookmark, with newUrl: String) { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift new file mode 100644 index 0000000000..9eaabcc4f0 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift @@ -0,0 +1,199 @@ +// +// BookmarkDragDropManager.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 Common +import Foundation +import os.log + +final class BookmarkDragDropManager { + + static let shared = BookmarkDragDropManager() + + static let draggedTypes: [NSPasteboard.PasteboardType] = [ + .string, + .URL, + BookmarkPasteboardWriter.bookmarkUTIInternalType, + FolderPasteboardWriter.folderUTIInternalType + ] + + private let bookmarkManager: BookmarkManager + + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + self.bookmarkManager = bookmarkManager + } + + func validateDrop(_ info: NSDraggingInfo, to destination: Any) -> NSDragOperation { + let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: info.draggingPasteboard.pasteboardItems) + let folders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard.pasteboardItems) + + let bookmarksDragOperation = bookmarks.flatMap { validateMove(for: $0, destination: destination) } + let foldersDragOperation = folders.flatMap { validateMove(for: $0, destination: destination) } + + switch (bookmarksDragOperation, foldersDragOperation) { + // If the dragged values contain both folders and bookmarks, only validate the move if all objects can be moved. + case (true, true), (true, nil), (nil, true): + return .move + default: + return .none + } + } + + private func validateMove(for draggedBookmarks: Set, destination: Any) -> Bool? { + guard !draggedBookmarks.isEmpty else { return nil } + guard destination is BookmarkFolder || destination is PseudoFolder else { return false } + + return true + } + + private func validateMove(for draggedFolders: Set, destination: Any) -> Bool? { + guard !draggedFolders.isEmpty else { return nil } + + guard let destinationFolder = destination as? BookmarkFolder else { + if destination as? PseudoFolder == .bookmarks { + return true + } + return false + } + + // Folders cannot be dragged onto themselves or any of their descendants: + return draggedFolders.allSatisfy { folder in + bookmarkManager.canMoveObjectWithUUID(objectUUID: folder.id, to: destinationFolder) + } + } + + @discardableResult + @MainActor + func acceptDrop(_ info: NSDraggingInfo, to destination: Any, at index: Int) -> Bool { + defer { + // prevent other drop targets accepting the dragged items twice + info.draggingPasteboard.clearContents() + } + guard let draggedObjectIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), !draggedObjectIdentifiers.isEmpty else { + return createBookmarks(from: info.draggingPasteboard.pasteboardItems ?? [], in: destination, at: index, window: info.draggingDestinationWindow) + } + + switch destination { + case let folder as BookmarkFolder: + if folder.id == PseudoFolder.bookmarks.id { fallthrough } + + let index = (index == -1 || index == NSNotFound) ? 0 : index + let parent: ParentFolderType = (folder.id == PseudoFolder.bookmarks.id) ? .root : .parent(uuid: folder.id) + bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: parent) { error in + if let error = error { + Logger.general.error("Failed to accept existing parent drop via outline view: \(error.localizedDescription)") + } + } + + case is PseudoFolder where (destination as? PseudoFolder) == .bookmarks: + if index == -1 || index == NSNotFound { + bookmarkManager.add(objectsWithUUIDs: draggedObjectIdentifiers, to: nil) { error in + if let error = error { + Logger.general.error("Failed to accept nil parent drop via outline view: \(error.localizedDescription)") + } + } + } else { + bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: .root) { error in + if let error = error { + Logger.general.error("Failed to accept nil parent drop via outline view: \(error.localizedDescription)") + } + } + } + + case let pseudoFolder as PseudoFolder where pseudoFolder == .favorites: + if index == -1 || index == NSNotFound { + bookmarkManager.update(objectsWithUUIDs: draggedObjectIdentifiers, update: { entity in + let bookmark = entity as? Bookmark + bookmark?.isFavorite = true + }, completion: { error in + if let error = error { + Logger.general.error("Failed to update entities during drop via outline view: \(error.localizedDescription)") + } + }) + } else { + bookmarkManager.moveFavorites(with: draggedObjectIdentifiers, toIndex: index) { error in + if let error = error { + Logger.general.error("Failed to update entities during drop via outline view: \(error.localizedDescription)") + } + } + } + + default: + assertionFailure("Unknown destination: \(destination)") + return false + } + + return true + } + + @MainActor + private func createBookmarks(from pasteboardItems: [NSPasteboardItem], in destination: Any, at index: Int, window: NSWindow?) -> Bool { + var parent: BookmarkFolder? + var isFavorite = false + + switch destination { + case let pseudoFolder as PseudoFolder where pseudoFolder == .favorites: + isFavorite = true + case let pseudoFolder as PseudoFolder where pseudoFolder == .bookmarks: + isFavorite = false + + case let folder as BookmarkFolder: + parent = folder + + default: + assertionFailure("Unknown destination: \(destination)") + return false + } + + var currentIndex = index + for item in pasteboardItems { + let url: URL + let title: String + func titleFromUrlDroppingSchemeIfNeeded(_ url: URL) -> String { + let title = url.absoluteString + // drop `http[s]://` from bookmark URL used as its title + if let scheme = url.navigationalScheme, scheme.isHypertextScheme { + return title.dropping(prefix: scheme.separated()) + } + return title + } + if let webViewItem = item.draggedWebViewValues() { + url = webViewItem.url + title = webViewItem.title ?? self.title(forTabWith: webViewItem.url, in: window) ?? titleFromUrlDroppingSchemeIfNeeded(url) + } else if let draggedString = item.string(forType: .string), + let draggedURL = URL(string: draggedString) { + url = draggedURL + title = self.title(forTabWith: draggedURL, in: window) ?? titleFromUrlDroppingSchemeIfNeeded(url) + } else { + continue + } + + self.bookmarkManager.makeBookmark(for: url, title: title, isFavorite: isFavorite, index: currentIndex, parent: parent) + currentIndex += 1 + } + + return currentIndex > index + } + + @MainActor + private func title(forTabWith url: URL, in window: NSWindow?) -> String? { + guard let mainViewController = window?.contentViewController as? MainViewController else { return nil } + return mainViewController.tabCollectionViewModel.title(forTabWithURL: url) + } + +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift index c7f9afbcd1..d249aa59fd 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift @@ -25,7 +25,7 @@ final class BookmarkListTreeControllerSearchDataSource: BookmarkTreeControllerSe self.bookmarkManager = bookmarkManager } - func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { + func nodes(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { let searchResults = bookmarkManager.search(by: searchQuery) return rebuildChildNodes(for: searchResults.sorted(by: sortMode)) diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift index 8ef9a2ac86..e6c3751ab7 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift @@ -39,7 +39,7 @@ final class BookmarkNode: Hashable { var childNodes = [BookmarkNode]() var isRoot: Bool { - return parent == nil + return representedObject is RootNode } var numberOfChildNodes: Int { @@ -79,6 +79,17 @@ final class BookmarkNode: Hashable { self.uniqueID = uniqueId } + var canBeHighlighted: Bool { + switch representedObject { + case is SpacerNode: + return false + case let menuItem as MenuItemNode: + return menuItem.isEnabled + default: + return true + } + } + /// Creates an instance of a bookmark node. /// - Parameters: /// - representedObject: The represented object contained in the node. @@ -144,11 +155,7 @@ final class BookmarkNode: Hashable { } func childNodeRepresenting(object: AnyObject) -> BookmarkNode? { - return findNodeRepresenting(object: object, recursively: false) - } - - func descendantNodeRepresenting(object: AnyObject) -> BookmarkNode? { - return findNodeRepresenting(object: object, recursively: true) + return childNodes.first { $0.representedObjectEquals(object) } } func isAncestor(of node: BookmarkNode) -> Bool { @@ -169,20 +176,6 @@ final class BookmarkNode: Hashable { } } - func findNodeRepresenting(object: AnyObject, recursively: Bool = false) -> BookmarkNode? { - for childNode in childNodes { - if childNode.representedObjectEquals(object) { - return childNode - } - - if recursively, let foundNode = childNode.descendantNodeRepresenting(object: object) { - return foundNode - } - } - - return nil - } - // MARK: - Hashable func hash(into hasher: inout Hasher) { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift index 7cf8b69414..c80c02f163 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift @@ -21,70 +21,93 @@ import Common import Foundation import os.log -final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { +final class BookmarkOutlineViewDataSource: NSObject, BookmarksOutlineViewDataSource, NSOutlineViewDelegate { - enum ContentMode { + enum ContentMode: CaseIterable { case bookmarksAndFolders case foldersOnly + case bookmarksMenu + + var isSeparatorVisible: Bool { + switch self { + case .bookmarksAndFolders, .bookmarksMenu: true + case .foldersOnly: false + } + } + + var showMenuButtonOnHover: Bool { + switch self { + case .bookmarksAndFolders, .bookmarksMenu: true + case .foldersOnly: false + } + } } @Published var selectedFolders: [BookmarkFolder] = [] - let treeController: BookmarkTreeController + private var outlineView: BookmarksOutlineView? private let contentMode: ContentMode private(set) var expandedNodesIDs = Set() private(set) var isSearching = false + /// Currently highlighted drag destination folder. /// When a drag and drop to a folder happens while in search, we need to stor the destination folder /// so we can expand the tree to the destination folder once the drop finishes. - private(set) var dragDestinationFolderInSearchMode: BookmarkFolder? + @Published private(set) var dragDestinationFolder: BookmarkFolder? + + /// Represents currently highlighted drag&drop target row + @PublishedAfter var targetRowForDropOperation: Int? { + didSet { + guard let outlineView else { return } + // unhighlight old highlighted row + if let oldValue, oldValue != targetRowForDropOperation, + oldValue < outlineView.numberOfRows, + let oldTargetRowViewForDropOperation = outlineView.rowView(atRow: oldValue, makeIfNecessary: false), + oldTargetRowViewForDropOperation.isTargetForDropOperation { + oldTargetRowViewForDropOperation.isTargetForDropOperation = false + } + // highlight newly highlighted row on value change from outside + if let targetRowForDropOperation, + targetRowForDropOperation < outlineView.numberOfRows, + let targetRowViewForDropOperation = outlineView.rowView(atRow: targetRowForDropOperation, makeIfNecessary: false), + !targetRowViewForDropOperation.isTargetForDropOperation { + targetRowViewForDropOperation.isTargetForDropOperation = true + } + } + } + private let treeController: BookmarkTreeController private let bookmarkManager: BookmarkManager - private let showMenuButtonOnHover: Bool - private let onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? + private let dragDropManager: BookmarkDragDropManager private let presentFaviconsFetcherOnboarding: (() -> Void)? - private var favoritesPseudoFolder = PseudoFolder.favorites - private var bookmarksPseudoFolder = PseudoFolder.bookmarks - init( contentMode: ContentMode, bookmarkManager: BookmarkManager, treeController: BookmarkTreeController, + dragDropManager: BookmarkDragDropManager = .shared, sortMode: BookmarksSortMode, - showMenuButtonOnHover: Bool = true, - onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? = nil, presentFaviconsFetcherOnboarding: (() -> Void)? = nil ) { self.contentMode = contentMode self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager self.treeController = treeController - self.showMenuButtonOnHover = showMenuButtonOnHover - self.onMenuRequestedAction = onMenuRequestedAction self.presentFaviconsFetcherOnboarding = presentFaviconsFetcherOnboarding super.init() - - reloadData(with: sortMode) } - func reloadData(with sortMode: BookmarksSortMode) { + func reloadData(with sortMode: BookmarksSortMode, withRootFolder rootFolder: BookmarkFolder? = nil) { isSearching = false - dragDestinationFolderInSearchMode = nil - setFolderCount() - treeController.rebuild(for: sortMode) + dragDestinationFolder = nil + treeController.rebuild(for: sortMode, withRootFolder: rootFolder) } - func reloadData(for searchQuery: String, and sortMode: BookmarksSortMode) { + func reloadData(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) { isSearching = true - setFolderCount() - treeController.rebuild(for: searchQuery, sortMode: sortMode) - } - - private func setFolderCount() { - favoritesPseudoFolder.count = bookmarkManager.list?.favoriteBookmarks.count ?? 0 - bookmarksPseudoFolder.count = bookmarkManager.list?.totalBookmarks ?? 0 + treeController.rebuild(forSearchQuery: searchQuery, sortMode: sortMode) } // MARK: - Private @@ -114,6 +137,12 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if self.outlineView == nil { + self.outlineView = outlineView as? BookmarksOutlineView ?? { + assertionFailure("BookmarksOutlineView subclass expected instead of \(outlineView)") + return nil + }() + } return nodeForItem(item).numberOfChildNodes } @@ -122,7 +151,8 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { - return nodeForItem(item).canHaveChildNodes + // don‘t display disclosure indicator for “empty” nodes when no indentation level + return contentMode == .bookmarksMenu ? false : nodeForItem(item).canHaveChildNodes } func outlineViewSelectionDidChange(_ notification: Notification) { @@ -142,217 +172,138 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } } - func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { - guard let node = item as? BookmarkNode else { - assertionFailure("\(#file): Failed to cast item to Node") - return nil - } - 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, isSearch: isSearching) - - if bookmark.favicon(.small) == nil { - presentFaviconsFetcherOnboarding?() - } - return cell - } + func firstHighlightableRow(for _: BookmarksOutlineView) -> Int? { + nodeForItem(nil).childNodes.firstIndex { $0.canBeHighlighted } + } - if let folder = node.representedObject as? BookmarkFolder { - cell.update(from: folder, isSearch: isSearching) - return cell + func nextHighlightableRow(inNextSection: Bool, for outlineView: BookmarksOutlineView, after row: Int) -> Int? { + if inNextSection { + return lastHighlightableRow(for: outlineView) // no sections support for now } + let nodes = nodeForItem(nil).childNodes + guard nodes.indices.contains(row + 1) else { return nil } + return nodes[(row + 1)...].firstIndex { $0.canBeHighlighted } + } - if let folder = node.representedObject as? PseudoFolder { - if folder == .bookmarks { - cell.update(from: bookmarksPseudoFolder) - } else if folder == .favorites { - cell.update(from: favoritesPseudoFolder) - } else { - assertionFailure("\(#file): Tried to update PseudoFolder cell with invalid type") - } - - return cell + func previousHighlightableRow(inPreviousSection: Bool, for outlineView: BookmarksOutlineView, before row: Int) -> Int? { + if inPreviousSection { + return firstHighlightableRow(for: outlineView) // no sections support for now } - - return OutlineSeparatorViewCell(separatorVisible: contentMode == .bookmarksAndFolders) + let nodes = nodeForItem(nil).childNodes + guard nodes.indices.contains(row) else { return nil } + return nodes[.. NSPasteboardWriting? { - guard let node = item as? BookmarkNode, let entity = node.representedObject as? BaseBookmarkEntity else { return nil } - return entity.pasteboardWriter + func lastHighlightableRow(for _: BookmarksOutlineView) -> Int? { + nodeForItem(nil).childNodes.lastIndex { $0.canBeHighlighted } } - func outlineView(_ outlineView: NSOutlineView, - validateDrop info: NSDraggingInfo, - proposedItem item: Any?, - proposedChildIndex index: Int) -> NSDragOperation { - let destinationNode = nodeForItem(item) - - if contentMode == .foldersOnly { - // when in folders sidebar mode only allow moving a folder to another folder (or root) - if destinationNode.representedObject is BookmarkFolder - || (destinationNode.representedObject as? PseudoFolder == .bookmarks) { - return .move - } - return .none + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + guard let node = item as? BookmarkNode else { + assertionFailure("\(#file): Failed to cast item to Node") + return nil + } + if node.representedObject is SpacerNode { + return outlineView.makeView(withIdentifier: contentMode.isSeparatorVisible + ? OutlineSeparatorViewCell.separatorIdentifier + : OutlineSeparatorViewCell.blankIdentifier, owner: self) as? OutlineSeparatorViewCell + ?? OutlineSeparatorViewCell(isSeparatorVisible: contentMode.isSeparatorVisible) } - if isSearching { - if let destinationFolder = destinationNode.representedObject as? BookmarkFolder { - self.dragDestinationFolderInSearchMode = destinationFolder - return .move - } + let cell = outlineView.makeView(withIdentifier: BookmarkOutlineCellView.identifier(for: contentMode), owner: self) as? BookmarkOutlineCellView + ?? BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.identifier(for: contentMode)) + cell.delegate = self + cell.update(from: node, isSearch: isSearching) - return .none + if let bookmark = node.representedObject as? Bookmark, bookmark.favicon(.small) == nil { + presentFaviconsFetcherOnboarding?() } - let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: info.draggingPasteboard.pasteboardItems) - let folders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard.pasteboardItems) - - if let bookmarks = bookmarks, let folders = folders { - let canMoveBookmarks = validateDrop(for: bookmarks, destination: destinationNode) == .move - let canMoveFolders = validateDrop(for: folders, destination: destinationNode) == .move + return cell + } - // If the dragged values contain both folders and bookmarks, only validate the move if all objects can be moved. - if canMoveBookmarks, canMoveFolders { - return .move - } else { - return .none + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + let row = outlineView.row(forItem: item) + let rowView = RoundedSelectionRowView() + rowView.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + // observe row drag&drop target highlight state and update `targetRowForDropOperation` + let cancellable = rowView.publisher(for: \.isTargetForDropOperation).sink { [weak self] isTargetForDropOperation in + guard let self else { return } + if isTargetForDropOperation { + if self.targetRowForDropOperation != row { + self.targetRowForDropOperation = row + } + } else if self.targetRowForDropOperation == row { + self.targetRowForDropOperation = nil } } - - if let bookmarks = bookmarks { - return validateDrop(for: bookmarks, destination: destinationNode) - } - - if let folders = folders { - return validateDrop(for: folders, destination: destinationNode) + rowView.onDeinit { + withExtendedLifetime(cancellable) {} } - - return .none + return rowView } - func validateDrop(for draggedBookmarks: Set, destination: BookmarkNode) -> NSDragOperation { - guard destination.representedObject is BookmarkFolder || destination.representedObject is PseudoFolder || destination.isRoot else { - return .none + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + if let node = item as? BookmarkNode, node.representedObject is SpacerNode { + return OutlineSeparatorViewCell.rowHeight(for: contentMode) } - - return .move + return BookmarkOutlineCellView.rowHeight } - func validateDrop(for draggedFolders: Set, destination: BookmarkNode) -> NSDragOperation { - if destination.isRoot { - return .move - } - - if let pseudoFolder = destination.representedObject as? PseudoFolder, pseudoFolder == .bookmarks { - return .move - } - - guard let destinationFolder = destination.representedObject as? BookmarkFolder else { - return .none - } - - // Folders cannot be dragged onto themselves: + func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { + guard let node = item as? BookmarkNode, let entity = node.representedObject as? BaseBookmarkEntity else { return nil } + return entity.pasteboardWriter + } - let containsDestination = draggedFolders.contains { draggedFolder in - return draggedFolder.id == destinationFolder.id - } + func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { + let destinationNode = nodeForItem(item) - if containsDestination { + if contentMode == .foldersOnly, destinationNode.isRoot { + // disable dropping at Root in Bookmark Manager tree view return .none } - // Folders cannot be dragged onto any of their descendants: + let destination = destinationNode.isRoot ? PseudoFolder.bookmarks : destinationNode.representedObject + let operation = dragDropManager.validateDrop(info, to: destination) + self.dragDestinationFolder = (operation == .none || item == nil) ? nil : destinationNode.representedObject as? BookmarkFolder - let containsDescendantOfDestination = draggedFolders.contains { draggedFolder in - let folder = BookmarkFolder(id: draggedFolder.id, title: draggedFolder.name, parentFolderUUID: draggedFolder.parentFolderUUID, children: draggedFolder.children) - - guard let draggedNode = treeController.findNodeWithId(representing: folder) else { - return false - } - - let descendant = draggedNode.descendantNodeRepresenting(object: destination.representedObject) - - return descendant != nil - } - - if containsDescendantOfDestination { - return .none - } - - return .move + return operation } func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { - guard let draggedObjectIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), - !draggedObjectIdentifiers.isEmpty else { - return false + var representedObject = (item as? BookmarkNode)?.representedObject ?? (treeController.rootNode.isRoot ? nil : treeController.rootNode.representedObject) + if (representedObject as? BookmarkFolder)?.id == PseudoFolder.bookmarks.id { + // BookmarkFolder with id == PseudoFolder.bookmarks.id is used for Clipped Items menu + // use the PseudoFolder.bookmarks root as the destination and calculate drop index based on nearest items indices. + representedObject = PseudoFolder.bookmarks } - let representedObject = (item as? BookmarkNode)?.representedObject - - // Handle the nil destination case: - - if contentMode == .bookmarksAndFolders, - let pseudoFolder = representedObject as? PseudoFolder { - if pseudoFolder == .favorites { - bookmarkManager.update(objectsWithUUIDs: draggedObjectIdentifiers, update: { entity in - let bookmark = entity as? Bookmark - bookmark?.isFavorite = true - }, completion: { error in - if let error = error { - Logger.bookmarks.error("Failed to update entities during drop via outline view: \(error.localizedDescription, privacy: .public)") - } - }) - } else if pseudoFolder == .bookmarks { - bookmarkManager.add(objectsWithUUIDs: draggedObjectIdentifiers, to: nil) { error in - if let error = error { - Logger.bookmarks.error("Failed to accept nil parent drop via outline view: \(error.localizedDescription, privacy: .public)") - } - } + let index = { + // for folders-only calculate new real index based on the nearest folder index + if contentMode == .foldersOnly || representedObject is PseudoFolder, + index > -1, + // get folder before the insertion point (or the first one) + let nearestObject = (outlineView.child(max(0, index - 1), ofItem: item) as? BookmarkNode)?.representedObject as? BookmarkFolder, + // get all the children of a new parent folder (take actual bookmark list for the root) + let siblings = ((representedObject is PseudoFolder ? nil : representedObject) as? BookmarkFolder)?.children ?? bookmarkManager.list?.topLevelEntities { + + // insert after the nearest item (or in place of the nearest item for index == 0) + return (siblings.firstIndex(of: nearestObject) ?? 0) + (index == 0 ? 0 : 1) + } else if index == -1 { + // drop onto folder + return 0 } + return index + }() - return true - } - - // Handle the existing destination case: - - var index = index - // for folders-only calculate new real index based on the nearest folder index - if contentMode == .foldersOnly, - index > -1, - // get folder before the insertion point (or the first one) - let nearestObject = (outlineView.child(max(0, index - 1), ofItem: item) as? BookmarkNode)?.representedObject as? BookmarkFolder, - // get all the children of a new parent folder - let siblings = (representedObject as? BookmarkFolder)?.children ?? bookmarkManager.list?.topLevelEntities { - - // insert after the nearest item (or in place of the nearest item for index == 0) - index = (siblings.firstIndex(of: nearestObject) ?? 0) + (index == 0 ? 0 : 1) - } else if index == -1 { - // drop onto folder - index = 0 - } - - let parent: ParentFolderType = (representedObject as? BookmarkFolder).map { .parent(uuid: $0.id) } ?? .root - bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: parent) { error in - if let error = error { - Logger.bookmarks.error("Failed to accept existing parent drop via outline view: \(error.localizedDescription, privacy: .public)") - } - } - - return true + return dragDropManager.acceptDrop(info, to: representedObject ?? PseudoFolder.bookmarks, at: index) } - func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { - let view = RoundedSelectionRowView() - view.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - - return view + func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + let windowPoint = outlineView.window?.convertPoint(fromScreen: screenPoint) + if (windowPoint.map({ outlineView.isMouseLocationInsideBounds($0) }) ?? false) == false { + dragDestinationFolder = nil + } // else: leave the dragDestinationFolder set for folder expansion in Search mode } // MARK: - NSTableViewDelegate @@ -366,11 +317,13 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } } - // MARK: - BookmarkOutlineCellViewDelegate - extension BookmarkOutlineViewDataSource: BookmarkOutlineCellViewDelegate { func outlineCellViewRequestedMenu(_ cell: BookmarkOutlineCellView) { - onMenuRequestedAction?(cell) + guard let outlineView = cell.superview?.superview as? NSOutlineView else { + assertionFailure("cell.superview?.superview is not NSOutlineView") + return + } + outlineView.menu?.popUpAtMouseLocation(in: cell) } } diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift index 9735a5d677..fc484a2472 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift @@ -34,6 +34,7 @@ final class BookmarkSidebarTreeController: BookmarkTreeControllerDataSource { private func childNodesForRootNode(_ node: BookmarkNode) -> [BookmarkNode] { let bookmarks = PseudoFolder.bookmarks + bookmarks.count = bookmarkManager.list?.totalBookmarks ?? 0 let bookmarksNode = BookmarkNode(representedObject: bookmarks, parent: node) bookmarksNode.canHaveChildNodes = true diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift index ecf145ba66..7fee6cf8da 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift @@ -19,18 +19,20 @@ import Foundation protocol BookmarkTreeControllerDataSource: AnyObject { - func treeController(childNodesFor: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] } protocol BookmarkTreeControllerSearchDataSource: AnyObject { - - func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] + func nodes(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] } final class BookmarkTreeController { - let rootNode: BookmarkNode + static let openAllInNewTabsIdentifier = "openAllInNewTabs" + static let emptyPlaceholderIdentifier = "empty" + + private(set) var rootNode: BookmarkNode + private let isBookmarksBarMenu: Bool private weak var dataSource: BookmarkTreeControllerDataSource? private weak var searchDataSource: BookmarkTreeControllerSearchDataSource? @@ -38,27 +40,53 @@ final class BookmarkTreeController { init(dataSource: BookmarkTreeControllerDataSource, sortMode: BookmarksSortMode, searchDataSource: BookmarkTreeControllerSearchDataSource? = nil, - rootNode: BookmarkNode) { + rootNode: BookmarkNode? = nil, + isBookmarksBarMenu: Bool = false) { self.dataSource = dataSource self.searchDataSource = searchDataSource - self.rootNode = rootNode + self.rootNode = rootNode ?? BookmarkNode.genericRootNode() + self.isBookmarksBarMenu = isBookmarksBarMenu rebuild(for: sortMode) } convenience init(dataSource: BookmarkTreeControllerDataSource, sortMode: BookmarksSortMode, - searchDataSource: BookmarkTreeControllerSearchDataSource? = nil) { - self.init(dataSource: dataSource, sortMode: sortMode, searchDataSource: searchDataSource, rootNode: BookmarkNode.genericRootNode()) + searchDataSource: BookmarkTreeControllerSearchDataSource? = nil, + rootFolder: BookmarkFolder?, + isBookmarksBarMenu: Bool) { + self.init(dataSource: dataSource, sortMode: sortMode, searchDataSource: searchDataSource, rootNode: rootFolder.map(Self.rootNode(from:)), isBookmarksBarMenu: isBookmarksBarMenu) + } + + private static func rootNode(from rootFolder: BookmarkFolder) -> BookmarkNode { + let genericRootNode = BookmarkNode.genericRootNode() + let bookmarksNode = BookmarkNode(representedObject: rootFolder, parent: genericRootNode) + bookmarksNode.canHaveChildNodes = true + return bookmarksNode + } + + private static func separatorNode(for parentNode: BookmarkNode) -> BookmarkNode { + let spacerObject = SpacerNode.divider + let node = BookmarkNode(representedObject: spacerObject, parent: parentNode) + return node + } + + private static func menuItemNode(withIdentifier identifier: String, title: String, isEnabled: Bool = true, for parentNode: BookmarkNode) -> BookmarkNode { + let spacerObject = MenuItemNode(identifier: identifier, title: title, isEnabled: isEnabled) + let node = BookmarkNode(representedObject: spacerObject, parent: parentNode) + return node } // MARK: - Public - func rebuild(for searchQuery: String, sortMode: BookmarksSortMode) { - rootNode.childNodes = searchDataSource?.nodes(for: searchQuery, sortMode: sortMode) ?? [] + func rebuild(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) { + rootNode.childNodes = searchDataSource?.nodes(forSearchQuery: searchQuery, sortMode: sortMode) ?? [] } - func rebuild(for sortMode: BookmarksSortMode) { + func rebuild(for sortMode: BookmarksSortMode, withRootFolder rootFolder: BookmarkFolder? = nil) { + if let rootFolder { + self.rootNode = Self.rootNode(from: rootFolder) + } rebuildChildNodes(node: rootNode, sortMode: sortMode) } @@ -101,9 +129,7 @@ final class BookmarkTreeController { @discardableResult private func rebuildChildNodes(node: BookmarkNode, sortMode: BookmarksSortMode = .manual) -> Bool { - guard node.canHaveChildNodes else { - return false - } + guard node.canHaveChildNodes else { return false } let childNodes: [BookmarkNode] = dataSource?.treeController(childNodesFor: node, sortMode: sortMode) ?? [] var childNodesDidChange = childNodes != node.childNodes @@ -112,12 +138,31 @@ final class BookmarkTreeController { node.childNodes = childNodes } - childNodes.forEach { childNode in - if rebuildChildNodes(node: childNode, sortMode: sortMode) { - childNodesDidChange = true + if isBookmarksBarMenu { + // count up to 2 bookmarks in the node – add “Open all in new tabs” item if 2 bookmarks and more + var bookmarksCount = 0 + for childNode in childNodes where childNode.representedObject is Bookmark { + bookmarksCount += 1 + if bookmarksCount > 1 { + break + } + } + if bookmarksCount > 1 { + node.childNodes.append(Self.separatorNode(for: node)) + node.childNodes.append(Self.menuItemNode(withIdentifier: Self.openAllInNewTabsIdentifier, title: UserText.bookmarksOpenInNewTabs, for: node)) + + } else if childNodes.isEmpty { + node.childNodes.append(Self.menuItemNode(withIdentifier: Self.emptyPlaceholderIdentifier, title: UserText.bookmarksBarFolderEmpty, isEnabled: false, for: node)) + } + } else { + childNodes.forEach { childNode in + if rebuildChildNodes(node: childNode, sortMode: sortMode) { + childNodesDidChange = true + } } } return childNodesDidChange } + } diff --git a/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift b/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift new file mode 100644 index 0000000000..fbe69b2d57 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift @@ -0,0 +1,37 @@ +// +// MenuItemNode.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +final class MenuItemNode: Equatable { + + let identifier: String + let title: String + let isEnabled: Bool + + init(identifier: String, title: String, isEnabled: Bool) { + self.identifier = identifier + self.title = title + self.isEnabled = isEnabled + } + + static func == (lhs: MenuItemNode, rhs: MenuItemNode) -> Bool { + return lhs.identifier == rhs.identifier + } + +} diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index 8c8f4a6d06..8fc3cc0aae 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -25,7 +25,7 @@ enum BookmarkStoreFetchPredicateType { case favorites } -enum ParentFolderType { +enum ParentFolderType: Equatable { case root case parent(uuid: String) } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift index 003ba7bb59..40d362c077 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift @@ -106,11 +106,11 @@ public final class BookmarkStoreMock: BookmarkStore { var bookmarkFolderWithIdCalled = false var capturedFolderId: String? - var bookmarkFolder: BookmarkFolder? + var bookmarkFolderWithId: ((String) -> BookmarkFolder?)? func bookmarkFolder(withId id: String) -> BookmarkFolder? { bookmarkFolderWithIdCalled = true capturedFolderId = id - return bookmarkFolder + return bookmarkFolderWithId?(id) } var updateFolderCalled = false @@ -155,7 +155,15 @@ public final class BookmarkStoreMock: BookmarkStore { var canMoveObjectWithUUIDCalled = false func canMoveObjectWithUUID(objectUUID uuid: String, to parent: BookmarkFolder) -> Bool { canMoveObjectWithUUIDCalled = true - return true + return LocalBookmarkStore.validateObjectMove(withUUID: uuid, to: mockBookmarkEntity(from: parent)) + } + + private func mockBookmarkEntity(from folder: BookmarkFolder) -> MockBookmarkEntity { + MockBookmarkEntity(uuid: folder.id, parent: { + guard let parentUUID = folder.parentFolderUUID, + let folder = self.bookmarkFolderWithId!(parentUUID) else { return nil } + return self.mockBookmarkEntity(from: folder) + }) } var moveObjectUUIDCalled = false @@ -173,17 +181,12 @@ public final class BookmarkStoreMock: BookmarkStore { func applyFavoritesDisplayMode(_ configuration: FavoritesDisplayMode) {} 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 + struct MockBookmarkEntity: BookmarkEntityProtocol { + var uuid: String? + var parent: () -> BookmarkEntityProtocol? + var bookmarkEntityParent: (any BookmarkEntityProtocol)? { + parent() } } } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarksContextMenu.swift b/DuckDuckGo/Bookmarks/Services/BookmarksContextMenu.swift new file mode 100644 index 0000000000..24e6b3ef28 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Services/BookmarksContextMenu.swift @@ -0,0 +1,461 @@ +// +// BookmarksContextMenu.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 + +protocol BookmarksContextMenuDelegate: NSMenuDelegate, BookmarkSearchMenuItemSelectors { + var isSearching: Bool { get } + var parentFolder: BookmarkFolder? { get } + var shouldIncludeManageBookmarksItem: Bool { get } + + func selectedItems() -> [Any] + func showDialog(_ dialog: any ModalView) + func closePopoverIfNeeded() +} + +final class BookmarksContextMenu: NSMenu { + + let bookmarkManager: BookmarkManager + let windowControllersManager: WindowControllersManagerProtocol + + private weak var bookmarksContextMenuDelegate: BookmarksContextMenuDelegate? { + guard let delegate = delegate as? BookmarksContextMenuDelegate else { + assertionFailure("BookmarksContextMenu delegate is not BookmarksContextMenuDelegate") + return nil + } + return delegate + } + + @MainActor + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, windowControllersManager: WindowControllersManagerProtocol? = nil, delegate: BookmarksContextMenuDelegate) { + self.bookmarkManager = bookmarkManager + self.windowControllersManager = windowControllersManager ?? WindowControllersManager.shared + super.init(title: "") + self.delegate = delegate + self.autoenablesItems = false + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func update() { + items = Self.menuItems(for: bookmarksContextMenuDelegate?.selectedItems() ?? [], + target: self, + forSearch: bookmarksContextMenuDelegate?.isSearching ?? false, + includeManageBookmarksItem: bookmarksContextMenuDelegate?.shouldIncludeManageBookmarksItem ?? true) + } + +} + +extension BookmarksContextMenu { + + /// Creates menu items for the specified Objects and target. + /// - Parameters: + /// - objects: The objects to create the menu for. + /// - forSearch: Boolean that indicates if a bookmark search is currently happening. + /// - Returns: An instance of NSMenu or nil if `objects` is not a `Bookmark` or a `Folder`. + static func menuItems(for objects: [Any]?, target: AnyObject?, forSearch: Bool, includeManageBookmarksItem: Bool) -> [NSMenuItem] { + guard let objects, !objects.isEmpty else { + return [addNewFolderMenuItem(entity: nil, target: target)] + } + + if objects.count > 1, let entities = objects as? [BaseBookmarkEntity] { + return menuItems(for: entities, target: target) + } + + let object: BaseBookmarkEntity + switch objects.first { + case let node as BookmarkNode: + guard let entity = node.representedObject as? BaseBookmarkEntity else { return [] } + object = entity + case let entity as BaseBookmarkEntity: + object = entity + default: + assertionFailure("Unexpected object \(objects.first!)") + return [] + } + + let menuItems = menuItems(for: object, target: target, forSearch: forSearch, includeManageBookmarksItem: includeManageBookmarksItem) + + return menuItems + } + + /// Creates an instance of NSMenu for the specified `BaseBookmarkEntity`and parent `BookmarkFolder`. + /// + /// - Parameters: + /// - entity: The bookmark entity to create the menu for. + /// - target: The target to associate to the `NSMenuItem` + /// - forSearch: Boolean that indicates if a bookmark search is currently happening. + /// - Returns: An instance of NSMenu or nil if `entity` is not a `Bookmark` or a `Folder`. + static func menuItems(for entity: BaseBookmarkEntity, target: AnyObject?, forSearch: Bool, includeManageBookmarksItem: Bool) -> [NSMenuItem] { + switch entity { + case let bookmark as Bookmark: + return menuItems(for: bookmark, target: target, isFavorite: bookmark.isFavorite, forSearch: forSearch, includeManageBookmarksItem: includeManageBookmarksItem) + case let folder as BookmarkFolder: + return menuItems(for: folder, target: target, forSearch: forSearch, includeManageBookmarksItem: includeManageBookmarksItem) + default: + assertionFailure("Unexpected entity \(entity)") + return [] + } + } + + static func menuItems(for bookmark: Bookmark, target: AnyObject?, isFavorite: Bool, forSearch: Bool, includeManageBookmarksItem: Bool) -> [NSMenuItem] { + var items = [ + openBookmarkInNewTabMenuItem(bookmark: bookmark, target: target), + openBookmarkInNewWindowMenuItem(bookmark: bookmark, target: target), + NSMenuItem.separator(), + addBookmarkToFavoritesMenuItem(isFavorite: isFavorite, bookmark: bookmark, target: target), + NSMenuItem.separator(), + editBookmarkMenuItem(bookmark: bookmark, target: target), + copyBookmarkMenuItem(bookmark: bookmark, target: target), + deleteBookmarkMenuItem(bookmark: bookmark, target: target), + moveToEndMenuItem(entity: bookmark, target: target), + NSMenuItem.separator(), + addNewFolderMenuItem(entity: bookmark, target: target), + ] + + if includeManageBookmarksItem { + items.append(manageBookmarksMenuItem(target: target)) + } + if forSearch { + let showInFolderItem = showInFolderMenuItem(bookmark: bookmark, target: target) + items.insert(showInFolderItem, at: 5) + } + + return items + } + + static func menuItems(for folder: BookmarkFolder, target: AnyObject?, forSearch: Bool, includeManageBookmarksItem: Bool) -> [NSMenuItem] { + // disable "Open All" if no Bookmarks in folder + var hasBookmarks = folder.children.contains(where: { $0 is Bookmark }) + var items = [ + openInNewTabsMenuItem(folder: folder, target: target, enabled: hasBookmarks), + openAllInNewWindowMenuItem(folder: folder, target: target, enabled: hasBookmarks), + NSMenuItem.separator(), + editFolderMenuItem(folder: folder, target: target), + deleteFolderMenuItem(folder: folder, target: target), + moveToEndMenuItem(entity: folder, target: target), + NSMenuItem.separator(), + addNewFolderMenuItem(entity: folder, target: target), + ] + + if includeManageBookmarksItem { + items.append(manageBookmarksMenuItem(target: target)) + } + if forSearch { + let showInFolderItem = showInFolderMenuItem(folder: folder, target: target) + items.insert(showInFolderItem, at: 3) + } + + return items + } + + // MARK: - Single Bookmark Menu Items + + static func openBookmarkInNewTabMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.openInNewTab, action: #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), target: target, representedObject: bookmark) + } + + static func openBookmarkInNewWindowMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.openInNewWindow, action: #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), target: target, representedObject: bookmark) + } + + static func manageBookmarksMenuItem(target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.bookmarksManageBookmarks, action: #selector(BookmarkMenuItemSelectors.manageBookmarks(_:)), target: target) + } + + static func addBookmarkToFavoritesMenuItem(isFavorite: Bool, bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + let title = isFavorite ? UserText.removeFromFavorites : UserText.addToFavorites + return NSMenuItem(title: title, action: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), target: target, representedObject: bookmark) + .withAccessibilityIdentifier(isFavorite == false ? "ContextualMenu.addBookmarkToFavoritesMenuItem" : + "ContextualMenu.removeBookmarkFromFavoritesMenuItem") + } + + static func addBookmarksToFavoritesMenuItem(bookmarks: [Bookmark], allFavorites: Bool, target: AnyObject?) -> NSMenuItem { + let title = allFavorites ? UserText.removeFromFavorites : UserText.addToFavorites + let accessibilityValue = allFavorites ? "Favorited" : "Unfavorited" + return NSMenuItem(title: title, action: #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), target: target, representedObject: bookmarks) + .withAccessibilityIdentifier("ContextualMenu.addBookmarksToFavoritesMenuItem").withAccessibilityValue(accessibilityValue) + } + + static func editBookmarkMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.editBookmark, action: #selector(BookmarkMenuItemSelectors.editBookmark(_:)), target: target, representedObject: bookmark) + } + + static func copyBookmarkMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.copy, action: #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), target: target, representedObject: bookmark) + } + + static func deleteBookmarkMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.bookmarksBarContextMenuDelete, action: #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), target: target, representedObject: bookmark) + .withAccessibilityIdentifier("ContextualMenu.deleteBookmark") + } + + static func moveToEndMenuItem(entity: BaseBookmarkEntity?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.bookmarksBarContextMenuMoveToEnd, action: #selector(BookmarkMenuItemSelectors.moveToEnd(_:)), target: target, representedObject: entity) + } + + static func showInFolderMenuItem(bookmark: Bookmark?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.showInFolder, action: #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), target: target, representedObject: bookmark) + } + + // MARK: - Bookmark Folder Menu Items + + static func openInNewTabsMenuItem(folder: BookmarkFolder?, target: AnyObject?, enabled: Bool) -> NSMenuItem { + let item = NSMenuItem(title: UserText.openAllInNewTabs, action: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), target: target, representedObject: folder) + item.isEnabled = enabled + return item + } + + static func openAllInNewWindowMenuItem(folder: BookmarkFolder?, target: AnyObject?, enabled: Bool) -> NSMenuItem { + let item = NSMenuItem(title: UserText.openAllTabsInNewWindow, action: #selector(FolderMenuItemSelectors.openAllInNewWindow(_:)), target: target, representedObject: folder) + item.isEnabled = enabled + return item + } + + static func addNewFolderMenuItem(entity: BaseBookmarkEntity?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.addFolder, action: #selector(FolderMenuItemSelectors.newFolder(_:)), target: target, representedObject: entity) + } + + static func showInFolderMenuItem(folder: BookmarkFolder?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.showInFolder, action: #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), target: target, representedObject: folder) + } + + static func editFolderMenuItem(folder: BookmarkFolder?, target: AnyObject?) -> NSMenuItem { + return NSMenuItem(title: UserText.editBookmark, action: #selector(FolderMenuItemSelectors.editFolder(_:)), target: target, representedObject: folder) + } + + static func deleteFolderMenuItem(folder: BookmarkFolder?, target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.bookmarksBarContextMenuDelete, action: #selector(FolderMenuItemSelectors.deleteFolder(_:)), target: target, representedObject: folder) + } + + // MARK: - Multi-Item Menu Creation + + static func openBookmarksInNewTabsMenuItem(bookmarks: [Bookmark], target: AnyObject?) -> NSMenuItem { + NSMenuItem(title: UserText.bookmarksOpenInNewTabs, action: #selector(FolderMenuItemSelectors.openInNewTabs(_:)), target: target, representedObject: bookmarks) + } + + static func menuItems(for entities: [BaseBookmarkEntity], target: AnyObject?) -> [NSMenuItem] { + var menuItems: [NSMenuItem] = [] + + let bookmarks = entities.compactMap({ $0 as? Bookmark }) + + if !bookmarks.isEmpty { + menuItems.append(openBookmarksInNewTabsMenuItem(bookmarks: bookmarks, target: target)) + + // If all selected items are bookmarks and they all have the same favourite status, show a menu item to add/remove them all as favourites. + if bookmarks.count == entities.count { + if bookmarks.allSatisfy({ $0.isFavorite }) { + menuItems.append(addBookmarksToFavoritesMenuItem(bookmarks: bookmarks, allFavorites: true, target: target)) + } else if bookmarks.allSatisfy({ !$0.isFavorite }) { + menuItems.append(addBookmarksToFavoritesMenuItem(bookmarks: bookmarks, allFavorites: false, target: target)) + } + } + + menuItems.append(NSMenuItem.separator()) + } + + let deleteItem = NSMenuItem(title: UserText.bookmarksBarContextMenuDelete, action: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), target: target, keyEquivalent: "") + deleteItem.representedObject = entities + menuItems.append(deleteItem) + + return menuItems + } + +} +// MARK: - BookmarkMenuItemSelectors +extension BookmarksContextMenu: BookmarkMenuItemSelectors { + + @MainActor + @objc func openBookmarkInNewTab(_ sender: NSMenuItem) { + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to cast menu represented object to Bookmark") + return + } + + windowControllersManager.show(url: bookmark.urlObject, source: .bookmark, newTab: true) + } + + @MainActor + @objc func openBookmarkInNewWindow(_ sender: NSMenuItem) { + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to cast menu represented object to Bookmark") + return + } + guard let urlObject = bookmark.urlObject else { + return + } + let tabCollection = TabCollection(tabs: [Tab(content: .contentFromURL(urlObject, source: .bookmark))]) + let tabCollectionViewModel = TabCollectionViewModel(tabCollection: tabCollection, burnerMode: .regular) + windowControllersManager.openNewWindow(with: tabCollectionViewModel, burnerMode: .regular) + } + + @objc func toggleBookmarkAsFavorite(_ sender: NSMenuItem) { + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to cast menu represented object to Bookmark") + return + } + + bookmark.isFavorite.toggle() + bookmarkManager.update(bookmark: bookmark) + } + + @MainActor + @objc func editBookmark(_ sender: NSMenuItem) { + 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, bookmarkManager: bookmarkManager) + bookmarksContextMenuDelegate?.showDialog(view) + } + + @MainActor + @objc func copyBookmark(_ sender: NSMenuItem) { + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to cast menu represented object to Bookmark") + return + } + bookmark.copyUrlToPasteboard() + } + + @objc func deleteBookmark(_ sender: NSMenuItem) { + guard let bookmark = sender.representedObject as? Bookmark else { + assertionFailure("Failed to cast menu represented object to Bookmark") + return + } + + bookmarkManager.remove(bookmark: bookmark) + } + + @objc func deleteEntities(_ sender: NSMenuItem) { + guard let uuids = sender.representedObject as? [String] ?? (sender.representedObject as? [BaseBookmarkEntity])?.map(\.id) else { + assertionFailure("Failed to cast menu item's represented object to UUID array") + return + } + + bookmarkManager.remove(objectsWithUUIDs: uuids) + } + + @MainActor + @objc func manageBookmarks(_ sender: NSMenuItem) { + windowControllersManager.showBookmarksTab() + bookmarksContextMenuDelegate?.closePopoverIfNeeded() + } + + @objc func moveToEnd(_ sender: NSMenuItem) { + guard let entity = sender.representedObject as? BaseBookmarkEntity else { + assertionFailure("Failed to cast menu item's represented object to BaseBookmarkEntity") + return + } + + let parentFolderType: ParentFolderType = entity.parentFolderUUID.flatMap { .parent(uuid: $0) } ?? .root + bookmarkManager.move(objectUUIDs: [entity.id], toIndex: nil, withinParentFolder: parentFolderType) { _ in } + } + +} +// MARK: - FolderMenuItemSelectors +extension BookmarksContextMenu: FolderMenuItemSelectors { + + @MainActor + @objc func newFolder(_ sender: Any?) { + var representedObject: Any? + switch sender { + case let menuItem as NSMenuItem: + representedObject = menuItem.representedObject + case let button as NSControl: + representedObject = button.cell?.representedObject + default: + assertionFailure("Unexpected sender \(String(describing: sender))") + } + var parentFolder: BookmarkFolder? + switch representedObject { + case let folder as BookmarkFolder: + parentFolder = folder + case let bookmark as Bookmark: + parentFolder = bookmark.parentFolderUUID.flatMap(bookmarkManager.getBookmarkFolder(withId:)) + case .none: + parentFolder = bookmarksContextMenuDelegate?.parentFolder + case .some(let object): + assertionFailure("Unexpected representedObject: \(object)") + } + + let view = BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parentFolder, bookmarkManager: bookmarkManager) + bookmarksContextMenuDelegate?.showDialog(view) + } + + @MainActor + @objc func editFolder(_ sender: NSMenuItem) { + guard let folder = sender.representedObject as? BookmarkFolder else { + assertionFailure("Failed to retrieve Bookmark from Edit Folder context menu item") + return + } + let parent = folder.parentFolderUUID.flatMap(bookmarkManager.getBookmarkFolder(withId:)) + let view = BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: parent, bookmarkManager: bookmarkManager) + bookmarksContextMenuDelegate?.showDialog(view) + } + + @objc func deleteFolder(_ sender: NSMenuItem) { + guard let folder = sender.representedObject as? BookmarkFolder else { + assertionFailure("Failed to retrieve Bookmark from Delete Folder context menu item") + return + } + + bookmarkManager.remove(folder: folder) + } + + @MainActor + @objc func openInNewTabs(_ sender: NSMenuItem) { + guard let tabCollection = windowControllersManager.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, + let folder = sender.representedObject as? BookmarkFolder + else { + assertionFailure("Cannot open all in new tabs") + return + } + + let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) + tabCollection.append(tabs: tabs) + PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + } + + @MainActor + @objc func openAllInNewWindow(_ sender: NSMenuItem) { + guard let tabCollection = windowControllersManager.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) + let tabCollectionViewModel = TabCollectionViewModel(tabCollection: newTabCollection, burnerMode: tabCollection.burnerMode) + windowControllersManager.openNewWindow(with: tabCollectionViewModel, burnerMode: tabCollection.burnerMode) + PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + } + +} + +extension BookmarksContextMenu: BookmarkSearchMenuItemSelectors { + + func showInFolder(_ sender: NSMenuItem) { + bookmarksContextMenuDelegate?.showInFolder(sender) + } + +} diff --git a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift b/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift deleted file mode 100644 index 4ac1d9b902..0000000000 --- a/DuckDuckGo/Bookmarks/Services/ContextualMenu.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// ContextualMenu.swift -// -// Copyright © 2021 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 - -enum ContextualMenu { - - static func menu(for objects: [Any]?, forSearch: Bool = false) -> NSMenu? { - menu(for: objects, target: nil, forSearch: forSearch) - } - - /// Creates an instance of NSMenu for the specified Objects and target. - /// - Parameters: - /// - objects: The objects to create the menu for. - /// - target: The target to associate to the `NSMenuItem` - /// - forSearch: Boolean that indicates if a bookmark search is currently happening. - /// - Returns: An instance of NSMenu or nil if `objects` is not a `Bookmark` or a `Folder`. - static func menu(for objects: [Any]?, target: AnyObject?, forSearch: Bool = false) -> NSMenu? { - - guard let objects = objects, objects.count > 0 else { - return menuForNoSelection() - } - - if objects.count > 1, let entities = objects as? [BaseBookmarkEntity] { - return menu(for: entities) - } - - let node = objects.first as? BookmarkNode - let object = node?.representedObject as? BaseBookmarkEntity ?? objects.first as? BaseBookmarkEntity - let parentFolder = node?.parent?.representedObject as? BookmarkFolder - - guard let object else { return nil } - - let menu = menu(for: object, parentFolder: parentFolder, forSearch: forSearch) - - menu?.items.forEach { item in - item.target = target - } - - return menu - } - - /// 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`. - /// - forSearch: Boolean that indicates if a bookmark search is currently happening. - /// - Returns: An instance of NSMenu or nil if `entity` is not a `Bookmark` or a `Folder`. - static func menu(for entity: BaseBookmarkEntity, parentFolder: BookmarkFolder?, forSearch: Bool = false) -> NSMenu? { - let menu: NSMenu? - if let bookmark = entity as? Bookmark { - menu = self.menu(for: bookmark, parent: parentFolder, isFavorite: bookmark.isFavorite, forSearch: forSearch) - } else if let folder = entity as? BookmarkFolder { - // When the user edits a folder we need to show the parent in the folder picker. Folders directly child of PseudoFolder `Bookmarks` have nil parent because their parent is not an instance of `BookmarkFolder` - menu = self.menu(for: folder, parent: parentFolder, forSearch: forSearch) - } else { - menu = nil - } - - return menu - } - - /// 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) - } - - /// 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) - } - -} - -private extension ContextualMenu { - - static func menuForNoSelection() -> NSMenu { - NSMenu(items: [addFolderMenuItem(folder: nil)]) - } - - static func menu(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool, forSearch: Bool = false) -> NSMenu { - NSMenu(items: menuItems(for: bookmark, parent: parent, isFavorite: isFavorite, forSearch: forSearch)) - } - - static func menu(for folder: BookmarkFolder?, parent: BookmarkFolder?, forSearch: Bool) -> NSMenu { - NSMenu(items: menuItems(for: folder, parent: parent, forSearch: forSearch)) - } - - static func menuItems(for bookmark: Bookmark?, parent: BookmarkFolder?, isFavorite: Bool, forSearch: Bool = false) -> [NSMenuItem] { - var items = [ - 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(), - ] - - if forSearch { - let showInFolderItem = showInFolderMenuItem(bookmark: bookmark, parent: parent) - items.insert(showInFolderItem, at: 5) - } - - return items - } - - static func menuItems(for folder: BookmarkFolder?, parent: BookmarkFolder?, forSearch: Bool = false) -> [NSMenuItem] { - var items = [ - 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(), - ] - - if forSearch { - let showInFolderItem = showInFolderMenuItem(folder: folder, parent: parent) - items.insert(showInFolderItem, at: 3) - } - - return items - } - - static func menuItem(_ title: String, _ action: Selector, _ representedObject: Any? = nil) -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: "") - item.representedObject = representedObject - return item - } - - // MARK: - Single Bookmark Menu Items - - static func openBookmarkInNewTabMenuItem(bookmark: Bookmark?) -> NSMenuItem { - menuItem(UserText.openInNewTab, #selector(BookmarkMenuItemSelectors.openBookmarkInNewTab(_:)), bookmark) - } - - static func openBookmarkInNewWindowMenuItem(bookmark: Bookmark?) -> NSMenuItem { - menuItem(UserText.openInNewWindow, #selector(BookmarkMenuItemSelectors.openBookmarkInNewWindow(_:)), bookmark) - } - - static func manageBookmarksMenuItem() -> NSMenuItem { - menuItem(UserText.bookmarksManageBookmarks, #selector(BookmarkMenuItemSelectors.manageBookmarks(_:))) - } - - static func addBookmarkToFavoritesMenuItem(isFavorite: Bool, bookmark: Bookmark?) -> NSMenuItem { - let title = isFavorite ? UserText.removeFromFavorites : UserText.addToFavorites - return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmark) - .withAccessibilityIdentifier(isFavorite == false ? "ContextualMenu.addBookmarkToFavoritesMenuItem" : - "ContextualMenu.removeBookmarkFromFavoritesMenuItem") - } - - static func addBookmarksToFavoritesMenuItem(bookmarks: [Bookmark], allFavorites: Bool) -> NSMenuItem { - let title = allFavorites ? UserText.removeFromFavorites : UserText.addToFavorites - let accessibilityValue = allFavorites ? "Favorited" : "Unfavorited" - return menuItem(title, #selector(BookmarkMenuItemSelectors.toggleBookmarkAsFavorite(_:)), bookmarks) - .withAccessibilityIdentifier("ContextualMenu.addBookmarksToFavoritesMenuItem").withAccessibilityValue(accessibilityValue) - } - - static func editBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { - menuItem(UserText.editBookmark, #selector(BookmarkMenuItemSelectors.editBookmark(_:)), bookmark) - } - - static func copyBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { - menuItem(UserText.copy, #selector(BookmarkMenuItemSelectors.copyBookmark(_:)), bookmark) - } - - static func deleteBookmarkMenuItem(bookmark: Bookmark?) -> NSMenuItem { - menuItem(UserText.bookmarksBarContextMenuDelete, #selector(BookmarkMenuItemSelectors.deleteBookmark(_:)), bookmark) - .withAccessibilityIdentifier("ContextualMenu.deleteBookmark") - } - - 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) - } - - static func showInFolderMenuItem(bookmark: Bookmark?, parent: BookmarkFolder?) -> NSMenuItem { - return menuItem(UserText.showInFolder, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), bookmark) - } - - // MARK: - Bookmark Folder Menu Items - - static func openInNewTabsMenuItem(folder: BookmarkFolder?) -> NSMenuItem { - menuItem(UserText.openAllInNewTabs, #selector(FolderMenuItemSelectors.openInNewTabs(_:)), folder) - } - - static func openAllInNewWindowMenuItem(folder: BookmarkFolder?) -> NSMenuItem { - menuItem(UserText.openAllTabsInNewWindow, #selector(FolderMenuItemSelectors.openAllInNewWindow(_:)), folder) - } - - static func addFolderMenuItem(folder: BookmarkFolder?) -> NSMenuItem { - menuItem(UserText.addFolder, #selector(FolderMenuItemSelectors.newFolder(_:)), folder) - } - - static func showInFolderMenuItem(folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenuItem { - return menuItem(UserText.showInFolder, #selector(BookmarkSearchMenuItemSelectors.showInFolder(_:)), folder) - } - - static func editFolderMenuItem(folder: BookmarkFolder?, parent: BookmarkFolder?) -> NSMenuItem { - let folderEntityInfo = folder.flatMap { BookmarkEntityInfo(entity: $0, parent: parent) } - return menuItem(UserText.editBookmark, #selector(FolderMenuItemSelectors.editFolder(_:)), folderEntityInfo) - } - - static func deleteFolderMenuItem(folder: BookmarkFolder?) -> NSMenuItem { - menuItem(UserText.bookmarksBarContextMenuDelete, #selector(FolderMenuItemSelectors.deleteFolder(_:)), folder) - } - - // MARK: - Multi-Item Menu Creation - - 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] = [] - - let bookmarks = entities.compactMap({ $0 as? Bookmark }) - - if !bookmarks.isEmpty { - menuItems.append(openBookmarksInNewTabsMenuItem(bookmarks: bookmarks)) - - // If all selected items are bookmarks and they all have the same favourite status, show a menu item to add/remove them all as favourites. - if bookmarks.count == entities.count { - if bookmarks.allSatisfy({ $0.isFavorite }) { - menuItems.append(addBookmarksToFavoritesMenuItem(bookmarks: bookmarks, allFavorites: true)) - } else if bookmarks.allSatisfy({ !$0.isFavorite }) { - menuItems.append(addBookmarksToFavoritesMenuItem(bookmarks: bookmarks, allFavorites: false)) - } - } - - menuItems.append(NSMenuItem.separator()) - } - - let deleteItem = NSMenuItem(title: UserText.bookmarksBarContextMenuDelete, action: #selector(BookmarkMenuItemSelectors.deleteEntities(_:)), keyEquivalent: "") - deleteItem.representedObject = entities - menuItems.append(deleteItem) - - menu.items = menuItems - - return menu - } - -} diff --git a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift index 970ed26503..be4f94c7c1 100644 --- a/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/LocalBookmarkStore.swift @@ -27,6 +27,16 @@ import Persistence import PixelKit import os.log +protocol BookmarkEntityProtocol { + var uuid: String? { get } + var bookmarkEntityParent: BookmarkEntityProtocol? { get } +} +extension BookmarkEntity: BookmarkEntityProtocol { + var bookmarkEntityParent: (any BookmarkEntityProtocol)? { + parent + } +} + final class LocalBookmarkStore: BookmarkStore { convenience init(bookmarkDatabase: BookmarkDatabase) { @@ -598,24 +608,28 @@ final class LocalBookmarkStore: BookmarkStore { guard let folderToMove = folderToMoveFetchRequestResults?.first, let parentFolder = parentFolderFetchRequestResults?.first, folderToMove.isFolder, - parentFolder.isFolder else { - return - } + parentFolder.isFolder, + let uuid = folderToMove.uuid else { return } - var currentParentFolder = parentFolder.parent + canMoveObject = Self.validateObjectMove(withUUID: uuid, to: parentFolder) + } - // Check each parent and verify that the folder being moved isn't being given itself as an ancestor - while let currentParent = currentParentFolder { - if currentParent.uuid == uuid { - canMoveObject = false - return - } + return canMoveObject + } + + static func validateObjectMove(withUUID uuid: String, to parent: BookmarkEntityProtocol) -> Bool { + guard uuid != parent.uuid else { return false } + var currentParentFolder = parent.bookmarkEntityParent - currentParentFolder = currentParent.parent + // Check each parent and verify that the folder being moved isn't being given itself as an ancestor + while let currentParent = currentParentFolder { + if currentParent.uuid == uuid { + return false } - } - return canMoveObject + currentParentFolder = currentParent.bookmarkEntityParent + } + return true } func move(objectUUIDs: [String], toIndex index: Int?, withinParentFolder type: ParentFolderType, completion: @escaping (Error?) -> Void) { diff --git a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift index 9f54209b45..a54d8a10e5 100644 --- a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift +++ b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift @@ -19,7 +19,7 @@ import AppKit @objc protocol BookmarksMenuItemSelectors { - func newFolder(_ sender: NSMenuItem) + func newFolder(_ sender: Any?) func moveToEnd(_ sender: NSMenuItem) @objc optional func manageBookmarks(_ sender: NSMenuItem) } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift b/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift new file mode 100644 index 0000000000..e499fa7030 --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift @@ -0,0 +1,63 @@ +// +// BookmarkListPopover.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 Foundation + +protocol BookmarkListPopoverDelegate: NSPopoverDelegate { + func openNextBookmarksMenu(_ sender: BookmarkListPopover) + func openPreviousBookmarksMenu(_ sender: BookmarkListPopover) +} + +final class BookmarkListPopover: NSPopover { + + override init() { + super.init() + + self.animates = false + self.behavior = .transient + + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("BookmarkListPopover: Bad initializer") + } + + // swiftlint:disable:next force_cast + var viewController: BookmarkListViewController { contentViewController as! BookmarkListViewController } + + private func setupContentController() { + let controller = BookmarkListViewController() + controller.delegate = self + contentViewController = controller + } + +} + +extension BookmarkListPopover: BookmarkListViewControllerDelegate { + + func closeBookmarksPopover(_ sender: BookmarkListViewController) { + close() + } + + func popover(shouldPreventClosure: Bool) { + behavior = shouldPreventClosure ? .applicationDefined : .transient + } + +} diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift index 43a1450afb..208e47b3ec 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -17,17 +17,21 @@ // import AppKit +import Carbon import Combine protocol BookmarkListViewControllerDelegate: AnyObject { - func popoverShouldClose(_ bookmarkListViewController: BookmarkListViewController) + func closeBookmarksPopover(_ sender: BookmarkListViewController) func popover(shouldPreventClosure: Bool) } final class BookmarkListViewController: NSViewController { - static let preferredContentSize = CGSize(width: 420, height: 500) + + fileprivate enum Constants { + static let preferredContentSize = CGSize(width: 420, height: 500) + } weak var delegate: BookmarkListViewControllerDelegate? var currentTabWebsite: WebsiteInfo? @@ -36,10 +40,9 @@ final class BookmarkListViewController: NSViewController { private lazy var stackView = NSStackView() private lazy var newBookmarkButton = MouseOverButton(image: .addBookmark, target: self, action: #selector(newBookmarkButtonClicked)) - private lazy var newFolderButton = MouseOverButton(image: .addFolder, target: self, action: #selector(newFolderButtonClicked)) + private lazy var newFolderButton = MouseOverButton(image: .addFolder, target: outlineView.menu, action: #selector(FolderMenuItemSelectors.newFolder)) private lazy var searchBookmarksButton = MouseOverButton(image: .searchBookmarks, target: self, action: #selector(searchBookmarkButtonClicked)) private lazy var sortBookmarksButton = MouseOverButton(image: .bookmarkSortAsc, target: self, action: #selector(sortBookmarksButtonClicked)) - private var isSearchVisible = false private lazy var buttonsDivider = NSBox() private lazy var manageBookmarksButton = MouseOverButton(title: UserText.bookmarksManage, target: self, action: #selector(openManagementInterface)) @@ -56,26 +59,37 @@ final class BookmarkListViewController: NSViewController { private lazy var searchBar = NSSearchField() private var boxDividerTopConstraint = NSLayoutConstraint() - private var cancellables = Set() private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager private let treeControllerDataSource: BookmarkListTreeControllerDataSource private let treeControllerSearchDataSource: BookmarkListTreeControllerSearchDataSource private let sortBookmarksViewModel: SortBookmarksViewModel private let bookmarkMetrics: BookmarksSearchAndSortMetrics - private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource, - sortMode: sortBookmarksViewModel.selectedSortMode, - searchDataSource: treeControllerSearchDataSource) + private let treeController: BookmarkTreeController + + private var isSearchVisible = false { + didSet { + switch (oldValue, isSearchVisible) { + case (false, true): + showSearchBar() + case (true, false): + hideSearchBar() + showTreeView() + default: break + } + } + } + + private var cancellables = Set() private lazy var dataSource: BookmarkOutlineViewDataSource = { BookmarkOutlineViewDataSource( contentMode: .bookmarksAndFolders, bookmarkManager: bookmarkManager, treeController: treeController, + dragDropManager: dragDropManager, sortMode: sortBookmarksViewModel.selectedSortMode, - onMenuRequestedAction: { [weak self] cell in - self?.showContextMenu(for: cell) - }, presentFaviconsFetcherOnboarding: { [weak self] in guard let self, let window = self.view.window else { return @@ -101,12 +115,18 @@ final class BookmarkListViewController: NSViewController { }() init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared, metrics: BookmarksSearchAndSortMetrics = BookmarksSearchAndSortMetrics()) { self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager self.treeControllerDataSource = BookmarkListTreeControllerDataSource(bookmarkManager: bookmarkManager) self.treeControllerSearchDataSource = BookmarkListTreeControllerSearchDataSource(bookmarkManager: bookmarkManager) self.bookmarkMetrics = metrics self.sortBookmarksViewModel = SortBookmarksViewModel(manager: bookmarkManager, metrics: metrics, origin: .panel) + self.treeController = BookmarkTreeController(dataSource: treeControllerDataSource, + sortMode: sortBookmarksViewModel.selectedSortMode, + searchDataSource: treeControllerSearchDataSource, + isBookmarksBarMenu: false) super.init(nibName: nil, bundle: nil) } @@ -114,6 +134,8 @@ final class BookmarkListViewController: NSViewController { fatalError("\(type(of: self)): Bad initializer") } + // MARK: View Lifecycle + override func loadView() { view = ColorView(frame: .zero, backgroundColor: .popoverBackground) @@ -148,6 +170,9 @@ final class BookmarkListViewController: NSViewController { stackView.addArrangedSubview(buttonsDivider) stackView.addArrangedSubview(manageBookmarksButton) + // keep OutlineView menu declaration before the buttons as it‘s their target + outlineView.menu = BookmarksContextMenu(bookmarkManager: bookmarkManager, delegate: self) + newBookmarkButton.bezelStyle = .shadowlessSquare newBookmarkButton.cornerRadius = 4 newBookmarkButton.normalTintColor = .button @@ -229,8 +254,7 @@ final class BookmarkListViewController: NSViewController { outlineView.usesAutomaticRowHeights = true outlineView.target = self outlineView.action = #selector(handleClick) - outlineView.menu = NSMenu() - outlineView.menu!.delegate = self + outlineView.menu = BookmarksContextMenu(bookmarkManager: bookmarkManager, delegate: self) outlineView.dataSource = dataSource outlineView.delegate = dataSource @@ -241,6 +265,7 @@ final class BookmarkListViewController: NSViewController { clipView.drawsBackground = false scrollView.contentView = clipView + emptyStateImageView.translatesAutoresizingMaskIntoConstraints = false emptyState.addSubview(emptyStateImageView) emptyState.addSubview(emptyStateTitle) emptyState.addSubview(emptyStateMessage) @@ -280,137 +305,116 @@ final class BookmarkListViewController: NSViewController { private func setupLayout() { titleTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) titleTextField.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: 12).isActive = true - titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true - newBookmarkButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - newBookmarkButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - newFolderButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - newFolderButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - searchBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - searchBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - sortBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - sortBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true + emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) + emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) - buttonsDivider.widthAnchor.constraint(equalToConstant: 13).isActive = true - buttonsDivider.heightAnchor.constraint(equalToConstant: 18).isActive = true + emptyStateTitle.setContentHuggingPriority(.defaultHigh, for: .vertical) + emptyStateTitle.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - manageBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - let titleWidth = (manageBookmarksButton.title as NSString) - .size(withAttributes: [.font: manageBookmarksButton.font as Any]).width - let buttonWidth = manageBookmarksButton.image!.size.height + titleWidth + 18 - manageBookmarksButton.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true + emptyStateMessage.setContentHuggingPriority(.defaultHigh, for: .vertical) + emptyStateMessage.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - stackView.centerYAnchor.constraint(equalTo: titleTextField.centerYAnchor).isActive = true - view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 20).isActive = true + NSLayoutConstraint.activate([ + titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: 12), + titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - boxDividerTopConstraint = boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12) - boxDividerTopConstraint.isActive = true - boxDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - view.trailingAnchor.constraint(equalTo: boxDivider.trailingAnchor).isActive = true + newBookmarkButton.heightAnchor.constraint(equalToConstant: 28), + newBookmarkButton.widthAnchor.constraint(equalToConstant: 28), - scrollView.topAnchor.constraint(equalTo: boxDivider.bottomAnchor).isActive = true - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + newFolderButton.heightAnchor.constraint(equalToConstant: 28), + newFolderButton.widthAnchor.constraint(equalToConstant: 28), - emptyState.topAnchor.constraint(equalTo: boxDivider.bottomAnchor).isActive = true - emptyState.centerXAnchor.constraint(equalTo: boxDivider.centerXAnchor).isActive = true - emptyState.widthAnchor.constraint(equalToConstant: 342).isActive = true - emptyState.heightAnchor.constraint(equalToConstant: 383).isActive = true + searchBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + searchBookmarksButton.widthAnchor.constraint(equalToConstant: 28), - emptyStateImageView.translatesAutoresizingMaskIntoConstraints = false - emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) - emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor, constant: 94.5).isActive = true - emptyStateImageView.widthAnchor.constraint(equalToConstant: 128).isActive = true - emptyStateImageView.heightAnchor.constraint(equalToConstant: 96).isActive = true - emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true + sortBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + sortBookmarksButton.widthAnchor.constraint(equalToConstant: 28), - emptyStateTitle.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateTitle.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8).isActive = true - emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - emptyStateTitle.widthAnchor.constraint(equalToConstant: 192).isActive = true + buttonsDivider.widthAnchor.constraint(equalToConstant: 13), + buttonsDivider.heightAnchor.constraint(equalToConstant: 18), - emptyStateMessage.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateMessage.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8).isActive = true - emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true + manageBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + manageBookmarksButton.widthAnchor.constraint(equalToConstant: { + let titleWidth = (manageBookmarksButton.title as NSString) + .size(withAttributes: [.font: manageBookmarksButton.font as Any]).width + let buttonWidth = manageBookmarksButton.image!.size.height + titleWidth + 18 + return buttonWidth + }()), - emptyStateMessage.widthAnchor.constraint(equalToConstant: 192).isActive = true + stackView.centerYAnchor.constraint(equalTo: titleTextField.centerYAnchor), + view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 20), - importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8).isActive = true - importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - } + { + boxDividerTopConstraint = boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12) + return boxDividerTopConstraint + }(), + boxDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: boxDivider.trailingAnchor), - override func viewDidLoad() { - super.viewDidLoad() + scrollView.topAnchor.constraint(equalTo: boxDivider.bottomAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - preferredContentSize = Self.preferredContentSize + emptyState.topAnchor.constraint(equalTo: boxDivider.bottomAnchor), + emptyState.centerXAnchor.constraint(equalTo: boxDivider.centerXAnchor), + emptyState.widthAnchor.constraint(equalToConstant: 342), + emptyState.heightAnchor.constraint(equalToConstant: 383), - outlineView.setDraggingSourceOperationMask([.move], forLocal: true) - outlineView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, - FolderPasteboardWriter.folderUTIInternalType]) + emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor, constant: 94.5), + emptyStateImageView.widthAnchor.constraint(equalToConstant: 128), + emptyStateImageView.heightAnchor.constraint(equalToConstant: 96), + emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), - bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] list in - guard let self else { return } + emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8), + emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + emptyStateTitle.widthAnchor.constraint(equalToConstant: 192), - self.reloadData() - let isEmpty = list?.topLevelEntities.isEmpty ?? true + emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8), + emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), - if isEmpty { - self.searchBookmarksButton.isHidden = true - self.showEmptyStateView(for: .noBookmarks) - } else { - self.searchBookmarksButton.isHidden = false - self.outlineView.isHidden = false - } - }.store(in: &cancellables) + emptyStateMessage.widthAnchor.constraint(equalToConstant: 192), - sortBookmarksViewModel.$selectedSortMode.sink { [weak self] newSortMode in - guard let self else { return } + importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8), + importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + ]) + } - switch newSortMode { - case .nameDescending: - self.sortBookmarksButton.image = .bookmarkSortDesc - default: - self.sortBookmarksButton.image = .bookmarkSortAsc - } + override func viewDidLoad() { + preferredContentSize = Constants.preferredContentSize - self.setupSort(mode: newSortMode) - }.store(in: &cancellables) + outlineView.setDraggingSourceOperationMask([.move], forLocal: true) + outlineView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) } override func viewWillAppear() { - super.viewWillAppear() - + subscribeToModelEvents() reloadData() } - override func keyDown(with event: NSEvent) { - let commandKeyDown = event.modifierFlags.contains(.command) - if commandKeyDown && event.keyCode == 3 { // CMD + F - if isSearchVisible { - searchBar.makeMeFirstResponder() - } else { - showSearchBar() - } - } else { - super.keyDown(with: event) - } + override func viewWillDisappear() { + cancellables = [] + } + + private func subscribeToModelEvents() { + bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] _ in + self?.reloadData() + }.store(in: &cancellables) + + sortBookmarksViewModel.$selectedSortMode.sink { [weak self] newSortMode in + self?.setupSort(mode: newSortMode) + }.store(in: &cancellables) } private func reloadData() { if dataSource.isSearching { - if let destinationFolder = dataSource.dragDestinationFolderInSearchMode { + if let destinationFolder = dataSource.dragDestinationFolder { hideSearchBar() updateSearchAndExpand(destinationFolder) } else { - dataSource.reloadData(for: searchBar.stringValue, and: sortBookmarksViewModel.selectedSortMode) + dataSource.reloadData(forSearchQuery: searchBar.stringValue, + sortMode: sortBookmarksViewModel.selectedSortMode) outlineView.reloadData() } } else { @@ -421,76 +425,150 @@ final class BookmarkListViewController: NSViewController { expandAndRestore(selectedNodes: selectedNodes) } - } - private func updateSearchAndExpand(_ folder: BookmarkFolder) { - showTreeView() - expandFoldersAndScrollUntil(folder) - outlineView.scrollToAdjustedPositionInOutlineView(folder) + let isEmpty = (outlineView.numberOfRows == 0) + self.emptyState.isHidden = !isEmpty + self.searchBookmarksButton.isHidden = isEmpty + self.outlineView.isHidden = isEmpty - guard let node = dataSource.treeController.node(representing: folder) else { - return + if isEmpty { + self.showEmptyStateView(for: .noBookmarks) } - - outlineView.highlight(node) } - @objc func newBookmarkButtonClicked(_ sender: AnyObject) { - let view = BookmarksDialogViewFactory.makeAddBookmarkView(currentTab: currentTabWebsite) - showDialog(view: view) + private func setupSort(mode: BookmarksSortMode) { + hideSearchBar() + dataSource.reloadData(with: mode) + outlineView.reloadData() + sortBookmarksButton.image = (mode == .nameDescending) ? .bookmarkSortDesc : .bookmarkSortAsc + sortBookmarksButton.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear + sortBookmarksButton.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver } - @objc func newFolderButtonClicked(_ sender: AnyObject) { - let parentFolder = sender.representedObject as? BookmarkFolder - let view = BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parentFolder) - showDialog(view: view) - } + // MARK: Layout - @objc func searchBookmarkButtonClicked(_ sender: NSButton) { - isSearchVisible.toggle() + private func updateSearchAndExpand(_ folder: BookmarkFolder) { + showTreeView() + expandFoldersAndScrollUntil(folder) + outlineView.scrollToAdjustedPositionInOutlineView(folder) - if isSearchVisible { - showSearchBar() - } else { - hideSearchBar() - showTreeView() - } - } + guard let node = treeController.node(representing: folder) else { return } - @objc func sortBookmarksButtonClicked(_ sender: NSButton) { - let menu = sortBookmarksViewModel.menu - bookmarkMetrics.fireSortButtonClicked(origin: .panel) - menu.popUpAtMouseLocation(in: sortBookmarksButton) + outlineView.highlight(node) } private func showSearchBar() { + isSearchVisible = true view.addSubview(searchBar) + outlineView.highlightedRow = nil boxDividerTopConstraint.isActive = false - searchBar.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 8).isActive = true - searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true - view.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: 16).isActive = true - boxDivider.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 10).isActive = true + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 8), + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + view.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: 16), + boxDivider.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 10), + ]) searchBar.makeMeFirstResponder() searchBookmarksButton.backgroundColor = .buttonMouseDown searchBookmarksButton.mouseOverColor = .buttonMouseDown } private func hideSearchBar() { + isSearchVisible = false + outlineView.highlightedRow = nil + outlineView.makeMeFirstResponder() searchBar.stringValue = "" searchBar.removeFromSuperview() boxDividerTopConstraint.isActive = true - isSearchVisible = false searchBookmarksButton.backgroundColor = .clear searchBookmarksButton.mouseOverColor = .buttonMouseOver } - private func setupSort(mode: BookmarksSortMode) { - hideSearchBar() - dataSource.reloadData(with: mode) + private func showTreeView() { + emptyState.isHidden = true + outlineView.isHidden = false + dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) outlineView.reloadData() - sortBookmarksButton.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear - sortBookmarksButton.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver + if !isSearchVisible { + outlineView.makeMeFirstResponder() + } + } + + private func expandFoldersAndScrollUntil(_ folder: BookmarkFolder) { + guard let folderNode = treeController.findNodeWithId(representing: folder) else { return } + + expandFoldersUntil(node: folderNode) + outlineView.scrollToAdjustedPositionInOutlineView(folderNode) + } + + private func expandFoldersUntil(node: BookmarkNode?) { + guard let folderParent = node?.parent else { return } + for parent in sequence(first: folderParent, next: \.parent).reversed() { + outlineView.animator().expandItem(parent) + } + } + + private func showEmptyStateView(for mode: BookmarksEmptyStateContent) { + emptyState.isHidden = false + outlineView.isHidden = true + emptyStateTitle.stringValue = mode.title + emptyStateMessage.stringValue = mode.description + emptyStateImageView.image = mode.image + importButton.isHidden = mode.shouldHideImportButton + } + + // MARK: Actions + + override func keyDown(with event: NSEvent) { + switch Int(event.keyCode) { + case kVK_ANSI_F where event.deviceIndependentFlags == .command: + if isSearchVisible { + searchBar.makeMeFirstResponder() + } else { + showSearchBar() + } + + case kVK_Return, kVK_ANSI_KeypadEnter, kVK_Space: + if outlineView.highlightedRow != nil { + // submit action when there‘s a highlighted row + handleClick(outlineView) + + } else if outlineView.numberOfRows > 0 { + // when in child menu popover without selection: highlight first row + outlineView.highlightedRow = 0 + } + + case kVK_Escape: + delegate?.closeBookmarksPopover(self) + + default: + // start search when letters are typed + if let characters = event.characters, + !characters.isEmpty { + + showSearchBar() + searchBar.currentEditor()?.keyDown(with: event) + return + } + + super.keyDown(with: event) + } + } + + @objc func newBookmarkButtonClicked(_ sender: AnyObject) { + let view = BookmarksDialogViewFactory.makeAddBookmarkView(currentTab: currentTabWebsite) + showDialog(view) + } + + @objc func searchBookmarkButtonClicked(_ sender: NSButton) { + isSearchVisible.toggle() + } + + @objc func sortBookmarksButtonClicked(_ sender: NSButton) { + let menu = sortBookmarksViewModel.menu + bookmarkMetrics.fireSortButtonClicked(origin: .panel) + menu.popUpAtMouseLocation(in: sender) } @objc func openManagementInterface(_ sender: NSButton) { @@ -498,17 +576,21 @@ final class BookmarkListViewController: NSViewController { } @objc func handleClick(_ sender: NSOutlineView) { - guard sender.clickedRow != -1 else { return } + let row = NSApp.currentEvent?.type == .keyDown ? outlineView.highlightedRow : sender.clickedRow + guard let row, row != -1 else { return } + let item = sender.item(atRow: row) + guard let node = item as? BookmarkNode else { return } - let item = sender.item(atRow: sender.clickedRow) - if let node = item as? BookmarkNode, - let bookmark = node.representedObject as? Bookmark { + switch node.representedObject { + case let bookmark as Bookmark: onBookmarkClick(bookmark) - } else if let node = item as? BookmarkNode, let folder = node.representedObject as? BookmarkFolder, dataSource.isSearching { + + case let folder as BookmarkFolder where dataSource.isSearching: bookmarkMetrics.fireSearchResultClicked(origin: .panel) hideSearchBar() updateSearchAndExpand(folder) - } else { + + default: handleItemClickWhenNotInSearchMode(item: item) } } @@ -519,7 +601,7 @@ final class BookmarkListViewController: NSViewController { } WindowControllersManager.shared.open(bookmark: bookmark) - delegate?.popoverShouldClose(self) + delegate?.closeBookmarksPopover(self) } private func handleItemClickWhenNotInSearchMode(item: Any?) { @@ -530,52 +612,15 @@ final class BookmarkListViewController: NSViewController { } } - private func expandFoldersAndScrollUntil(_ folder: BookmarkFolder) { - guard let folderNode = treeController.findNodeWithId(representing: folder) else { - return - } - - expandFoldersUntil(node: folderNode) - outlineView.scrollToAdjustedPositionInOutlineView(folderNode) - } - - private func expandFoldersUntil(node: BookmarkNode?) { - var nodes: [BookmarkNode?] = [] - var parent = node?.parent - nodes.append(node) - - while parent != nil { - nodes.append(parent) - parent = parent?.parent - } - - while !nodes.isEmpty { - if let current = nodes.removeLast() { - outlineView.animator().expandItem(current) - } - } - } - - private func showTreeView() { - emptyState.isHidden = true - outlineView.isHidden = false - dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) - outlineView.reloadData() - } - - private func showEmptyStateView(for mode: BookmarksEmptyStateContent) { - emptyState.isHidden = false - outlineView.isHidden = true - emptyStateTitle.stringValue = mode.title - emptyStateMessage.stringValue = mode.description - emptyStateImageView.image = mode.image - importButton.isHidden = mode.shouldHideImportButton - } - @objc func onImportClicked(_ sender: NSButton) { DataImportView().show() } + private func showManageBookmarks() { + WindowControllersManager.shared.showBookmarksTab() + delegate?.closeBookmarksPopover(self) + } + // MARK: NSOutlineView Configuration private func expandAndRestore(selectedNodes: [BookmarkNode]) { @@ -627,214 +672,37 @@ 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, forSearch: dataSource.isSearching) - else { - return - } - - contextMenu.popUpAtMouseLocation(in: view) - } - } +// MARK: - BookmarksContextMenuDelegate +extension BookmarkListViewController: BookmarksContextMenuDelegate { -private extension BookmarkListViewController { + var isSearching: Bool { dataSource.isSearching } + var parentFolder: BookmarkFolder? { nil } + var shouldIncludeManageBookmarksItem: Bool { true } - 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 - -extension BookmarkListViewController: NSMenuDelegate { - - func contextualMenuForClickedRows() -> NSMenu? { - let row = outlineView.clickedRow - - guard row != -1 else { - return ContextualMenu.menu(for: nil) - } + func selectedItems() -> [Any] { + guard let row = outlineView.clickedRowIfValid else { return [] } if outlineView.selectedRowIndexes.contains(row) { - return ContextualMenu.menu(for: outlineView.selectedItems) - } - - if let item = outlineView.item(atRow: row) { - return ContextualMenu.menu(for: [item], forSearch: dataSource.isSearching) - } else { - return nil - } - } - - public func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - - guard let contextualMenu = contextualMenuForClickedRows() else { - return - } - - let items = contextualMenu.items - contextualMenu.removeAllItems() - for menuItem in items { - menu.addItem(menuItem) - } - } - -} - -extension BookmarkListViewController: BookmarkMenuItemSelectors { - - func openBookmarkInNewTab(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - - WindowControllersManager.shared.show(url: bookmark.urlObject, source: .bookmark, newTab: true) - } - - func openBookmarkInNewWindow(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - guard let urlObject = bookmark.urlObject else { - return + return outlineView.selectedItems } - WindowsManager.openNewWindow(with: urlObject, source: .bookmark, isBurner: false) - } - - func toggleBookmarkAsFavorite(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - - bookmark.isFavorite.toggle() - bookmarkManager.update(bookmark: bookmark) - } - - func editBookmark(_ sender: NSMenuItem) { - 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) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - bookmark.copyUrlToPasteboard() - } - - func deleteBookmark(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - - bookmarkManager.remove(bookmark: bookmark) - } - func deleteEntities(_ sender: NSMenuItem) { - guard let uuids = sender.representedObject as? [String] else { - assertionFailure("Failed to cast menu item's represented object to UUID array") - return - } - - 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 { - - func newFolder(_ sender: NSMenuItem) { - newFolderButtonClicked(sender) + return outlineView.item(atRow: row).map { [$0] } ?? [] } - 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 - } - - let view = BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) - showDialog(view: view) - } - - func deleteFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { - assertionFailure("Failed to retrieve Bookmark from Delete Folder context menu item") - return - } - - bookmarkManager.remove(folder: folder) - } - - func openInNewTabs(_ sender: NSMenuItem) { - guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, - let folder = sender.representedObject as? BookmarkFolder - else { - assertionFailure("Cannot open all in new tabs") - return + func showDialog(_ dialog: any ModalView) { + delegate?.popover(shouldPreventClosure: true) + dialog.show(in: parent?.view.window) { [weak delegate] in + delegate?.popover(shouldPreventClosure: false) } - - let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) - tabCollection.append(tabs: tabs) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() } - 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) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + func closePopoverIfNeeded() { + delegate?.closeBookmarksPopover(self) } } - +// MARK: - BookmarkSearchMenuItemSelectors extension BookmarkListViewController: BookmarkSearchMenuItemSelectors { func showInFolder(_ sender: NSMenuItem) { guard let baseBookmark = sender.representedObject as? BaseBookmarkEntity else { @@ -845,18 +713,14 @@ extension BookmarkListViewController: BookmarkSearchMenuItemSelectors { hideSearchBar() showTreeView() - guard let node = dataSource.treeController.node(representing: baseBookmark) else { - return - } + guard let node = treeController.node(representing: baseBookmark) else { return } expandFoldersUntil(node: node) outlineView.scrollToAdjustedPositionInOutlineView(node) outlineView.highlight(node) } } - // MARK: - Search field delegate - extension BookmarkListViewController: NSSearchFieldDelegate { func controlTextDidChange(_ obj: Notification) { @@ -866,90 +730,90 @@ extension BookmarkListViewController: NSSearchFieldDelegate { if searchQuery.isBlank { showTreeView() } else { - showSearch(for: searchQuery) + showSearch(forSearchQuery: searchQuery) } bookmarkMetrics.fireSearchExecuted(origin: .panel) } } - private func showSearch(for searchQuery: String) { - dataSource.reloadData(for: searchQuery, and: sortBookmarksViewModel.selectedSortMode) + private func showSearch(forSearchQuery searchQuery: String) { + outlineView.highlightedRow = nil + dataSource.reloadData(forSearchQuery: searchQuery, sortMode: sortBookmarksViewModel.selectedSortMode) - if dataSource.treeController.rootNode.childNodes.isEmpty { + if treeController.rootNode.childNodes.isEmpty { showEmptyStateView(for: .noSearchResults) } else { emptyState.isHidden = true outlineView.isHidden = false outlineView.reloadData() - if let firstNode = dataSource.treeController.rootNode.childNodes.first { + if let firstNode = treeController.rootNode.childNodes.first { outlineView.scrollTo(firstNode) } } } -} - -// MARK: - BookmarkListPopover -final class BookmarkListPopover: NSPopover { - - override init() { - super.init() - - self.animates = false - self.behavior = .transient - - setupContentController() - } - - required init?(coder: NSCoder) { - fatalError("BookmarkListPopover: Bad initializer") - } - - // swiftlint:disable:next force_cast - var viewController: BookmarkListViewController { contentViewController as! BookmarkListViewController } - - private func setupContentController() { - let controller = BookmarkListViewController() - controller.delegate = self - contentViewController = controller - } - -} + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard control === searchBar else { + assertionFailure("Unexpected delegating control") + return false + } + switch selector { + case #selector(cancelOperation): + // handle Esc key press while in search mode + isSearchVisible = false -extension BookmarkListPopover: BookmarkListViewControllerDelegate { + case #selector(moveUp): + // handle Up Arrow in search mode + if !outlineView.highlightPreviousItem() { + // unhighlight for the first row + outlineView.highlightedRow = nil + } + case #selector(moveDown): + // handle Down Arrow in search mode + outlineView.highlightNextItem() - func popoverShouldClose(_ bookmarkListViewController: BookmarkListViewController) { - close() - } + case #selector(insertNewline) where outlineView.highlightedRow != nil: + // handle Enter key in search mode when there‘s a highlighted row + handleClick(outlineView) - func popover(shouldPreventClosure: Bool) { - behavior = shouldPreventClosure ? .applicationDefined : .transient + default: + return false + } + return true } } +// MARK: - Preview #if DEBUG // swiftlint:disable:next identifier_name func _mockPreviewBookmarkManager(previewEmptyState: Bool) -> BookmarkManager { - let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: previewEmptyState ? [] : [ - BookmarkFolder(id: "1", title: "Folder 1", children: [ - BookmarkFolder(id: "2", title: "Nested Folder", children: [ - Bookmark(id: "b1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "2") - ]) - ]), - BookmarkFolder(id: "3", title: "Another Folder", children: [ - BookmarkFolder(id: "4", title: "Nested Folder", children: [ - BookmarkFolder(id: "5", title: "Another Nested Folder", children: [ - Bookmark(id: "b2", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "5") + let bookmarks: [BaseBookmarkEntity] + if previewEmptyState { + bookmarks = [] + } else { + bookmarks = (1..<100).map { _ in [ + BookmarkFolder(id: "1", title: "Folder 1", children: [ + BookmarkFolder(id: "2", title: "Nested Folder", children: [ + Bookmark(id: "b1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "2") + ]) + ]), + BookmarkFolder(id: "3", title: "Another Folder", children: [ + BookmarkFolder(id: "4", title: "Nested Folder", children: [ + BookmarkFolder(id: "5", title: "Another Nested Folder", children: [ + Bookmark(id: "b2", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "5") + ]) ]) - ]) - ]), - Bookmark(id: "b3", url: URL.duckDuckGo.absoluteString, title: "Bookmark 1", isFavorite: false, parentFolderUUID: ""), - Bookmark(id: "b4", url: URL.duckDuckGo.absoluteString, title: "Bookmark 2", isFavorite: false, parentFolderUUID: ""), - Bookmark(id: "b5", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "") - ])) + ]), + Bookmark(id: "b3", url: URL.duckDuckGo.absoluteString, title: "Bookmark 1", isFavorite: false, parentFolderUUID: ""), + Bookmark(id: "b4", url: URL.duckDuckGo.absoluteString, title: "Bookmark 2", isFavorite: false, parentFolderUUID: ""), + Bookmark(id: "b5", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "") + ] }.flatMap { $0 } + } + let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: bookmarks)) + bkman.loadBookmarks() customAssertionFailure = { _, _, _ in } @@ -958,13 +822,13 @@ func _mockPreviewBookmarkManager(previewEmptyState: Bool) -> BookmarkManager { @available(macOS 14.0, *) #Preview("Test Bookmark data", - traits: BookmarkListViewController.preferredContentSize.fixedLayout) { + traits: BookmarkListViewController.Constants.preferredContentSize.fixedLayout) { BookmarkListViewController(bookmarkManager: _mockPreviewBookmarkManager(previewEmptyState: false)) ._preview_hidingWindowControlsOnAppear() } @available(macOS 14.0, *) -#Preview("Empty Scope", traits: BookmarkListViewController.preferredContentSize.fixedLayout) { +#Preview("Empty Scope", traits: BookmarkListViewController.Constants.preferredContentSize.fixedLayout) { BookmarkListViewController(bookmarkManager: _mockPreviewBookmarkManager(previewEmptyState: true)) ._preview_hidingWindowControlsOnAppear() } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift index 71243f37d6..f857da6728 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift @@ -37,7 +37,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem private let toolbarButtonsStackView = NSStackView() private lazy var newBookmarkButton = MouseOverButton(title: " " + UserText.newBookmark, target: self, action: #selector(presentAddBookmarkModal)) .withAccessibilityIdentifier("BookmarkManagementDetailViewController.newBookmarkButton") - private lazy var newFolderButton = MouseOverButton(title: " " + UserText.newFolder, target: self, action: #selector(presentAddFolderModal)) + private lazy var newFolderButton = MouseOverButton(title: " " + UserText.newFolder, target: tableView.menu, action: #selector(FolderMenuItemSelectors.newFolder)) .withAccessibilityIdentifier("BookmarkManagementDetailViewController.newFolderButton") private lazy var deleteItemsButton = MouseOverButton(title: " " + UserText.bookmarksBarContextMenuDelete, target: self, action: #selector(delete)) .withAccessibilityIdentifier("BookmarkManagementDetailViewController.deleteItemsButton") @@ -57,8 +57,9 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem weak var delegate: BookmarkManagementDetailViewControllerDelegate? - private let managementDetailViewModel: BookmarkManagementDetailViewModel + let managementDetailViewModel: BookmarkManagementDetailViewModel private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager private let sortBookmarksViewModel: SortBookmarksViewModel private var selectionState: BookmarkManagementSidebarViewController.SelectionState = .empty { didSet { @@ -78,8 +79,10 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem self.selectionState = selectionState } - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared) { self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager let metrics = BookmarksSearchAndSortMetrics() let sortViewModel = SortBookmarksViewModel(manager: bookmarkManager, metrics: metrics, origin: .manager) self.sortBookmarksViewModel = sortViewModel @@ -97,6 +100,9 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem view = ColorView(frame: .zero, backgroundColor: .bookmarkPageBackground) view.translatesAutoresizingMaskIntoConstraints = false + // set menu before `newFolderButton` initialization as it uses the menu as its target + tableView.menu = BookmarksContextMenu(bookmarkManager: bookmarkManager, delegate: self) + view.addSubview(separator) view.addSubview(scrollView) view.addSubview(emptyState) @@ -149,13 +155,13 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem emptyStateImageView.imageScaling = .scaleProportionallyDown scrollView.autohidesScrollers = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false scrollView.borderType = .noBorder scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.usesPredominantAxisScrolling = false scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = NSEdgeInsets(top: 22, left: 0, bottom: 22, right: 0) - scrollView.menu = NSMenu() - scrollView.menu!.delegate = self let clipView = NSClipView() clipView.documentView = tableView @@ -166,7 +172,6 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem clipView.frame = CGRect(x: 0, y: 0, width: 640, height: 601) tableView.addTableColumn(NSTableColumn()) - tableView.headerView = nil tableView.backgroundColor = .clear tableView.setContentHuggingPriority(.defaultHigh, for: .vertical) @@ -245,8 +250,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem super.viewDidLoad() tableView.setDraggingSourceOperationMask([.move], forLocal: true) - tableView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, - FolderPasteboardWriter.folderUTIInternalType]) + tableView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) reloadData() @@ -351,11 +355,6 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem .show(in: view.window) } - @objc func presentAddFolderModal(_ sender: Any) { - BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: selectionState.folder) - .show(in: view.window) - } - @objc func delete(_ sender: AnyObject) { deleteSelectedItems() } @@ -454,59 +453,41 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi return entity.pasteboardWriter } + private func destination(for dropOperation: NSTableView.DropOperation, at row: Int) -> Any { + switch dropOperation { + case .on: + if let entity = fetchEntity(at: row) { + return entity + } + case .above: + if let folder = selectionState.folder { + return folder + } + @unknown default: preconditionFailure() + } + return selectionState == .favorites ? PseudoFolder.favorites : PseudoFolder.bookmarks + } + func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { - return managementDetailViewModel.validateDrop(pasteboardItems: info.draggingPasteboard.pasteboardItems, - proposedRow: row, - proposedDropOperation: dropOperation) + let destination = destination(for: dropOperation, at: row) + return dragDropManager.validateDrop(info, to: destination) } func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { - guard let draggedItemIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), - !draggedItemIdentifiers.isEmpty else { - return false - } - if let parent = fetchEntity(at: row) as? BookmarkFolder, dropOperation == .on { - bookmarkManager.add(objectsWithUUIDs: draggedItemIdentifiers, to: parent) { _ in } - return true - } else if let currentFolderUUID = selectionState.selectedFolderUUID { - bookmarkManager.move(objectUUIDs: draggedItemIdentifiers, - toIndex: row, - withinParentFolder: .parent(uuid: currentFolderUUID)) { _ in } - return true - } else { - if selectionState == .favorites { - bookmarkManager.moveFavorites(with: draggedItemIdentifiers, toIndex: row) { _ in } - } else { - bookmarkManager.move(objectUUIDs: draggedItemIdentifiers, - toIndex: row, - withinParentFolder: .root) { _ in } - } - return true - } + let destination = destination(for: dropOperation, at: row) + let index = dropOperation == .above ? row : -1 + + return dragDropManager.acceptDrop(info, to: destination, at: index) } private func fetchEntity(at row: Int) -> BaseBookmarkEntity? { return managementDetailViewModel.fetchEntity(at: row) } - private func fetchEntityAndParent(at row: Int) -> (entity: BaseBookmarkEntity?, parentFolder: BookmarkFolder?) { - return managementDetailViewModel.fetchEntityAndParent(at: row) - } - - private func index(for entity: Bookmark) -> Int? { - return managementDetailViewModel.index(for: entity) - } - - fileprivate func selectedItems() -> [AnyObject] { - return tableView.selectedRowIndexes.compactMap { (index) -> AnyObject? in - return fetchEntity(at: index) as AnyObject - } - } - /// Updates the next/previous selection state of each row, and clears the selection flag. fileprivate func resetSelections() { guard totalRows() > 0 else { return } @@ -540,6 +521,8 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi } private func updateToolbarButtons() { + newFolderButton.cell?.representedObject = selectionState.folder + let shouldShowDeleteButton = tableView.selectedRowIndexes.count > 1 NSAnimationContext.runAnimationGroup { context in context.duration = 0.25 @@ -604,211 +587,44 @@ private extension BookmarkManagementDetailViewController { extension BookmarkManagementDetailViewController: BookmarkTableCellViewDelegate { func bookmarkTableCellViewRequestedMenu(_ sender: NSButton, cell: BookmarkTableCellView) { - let row = tableView.row(for: cell) - - guard let bookmark = fetchEntity(at: row) as? Bookmark else { - assertionFailure("BookmarkManagementDetailViewController: Tried to present bookmark menu for nil bookmark or folder") - return - } - - guard let contextMenu = ContextualMenu.menu(for: [bookmark], target: self, forSearch: managementDetailViewModel.isSearching) else { return } - contextMenu.popUpAtMouseLocation(in: view) - } - -} - -// MARK: - NSMenuDelegate - -extension BookmarkManagementDetailViewController: NSMenuDelegate { - - func contextualMenuForClickedRows() -> NSMenu? { - let row = tableView.clickedRow - - guard row != -1 else { - return ContextualMenu.menu(for: nil) - } - - // 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()) - } - - let (item, parent) = fetchEntityAndParent(at: row) - - if let item { - return ContextualMenu.menu(for: item, parentFolder: parent, forSearch: managementDetailViewModel.isSearching) - } else { - return nil - } - } - - public func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - - guard let contextualMenu = contextualMenuForClickedRows() else { - return - } - - let items = contextualMenu.items - contextualMenu.removeAllItems() - for menuItem in items { - menu.addItem(menuItem) - } - } - -} - -// MARK: - Menu Item Selectors - -extension BookmarkManagementDetailViewController: FolderMenuItemSelectors { - - func newFolder(_ sender: NSMenuItem) { - presentAddFolderModal(sender) - } - - 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 - } - - BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) - .show(in: view.window) - } - - func deleteFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { - assertionFailure("Failed to retrieve Bookmark from Delete Folder context menu item") - return - } - - 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 } - openBookmarksInNewTabs(bookmarks) - } else if let bookmarks = sender.representedObject as? [Bookmark] { - openBookmarksInNewTabs(bookmarks) - } else { - assertionFailure("Failed to open entity in new tabs") - } - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() - } - - 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) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + // will update the menu using `BookmarksContextMenuDelegate.selectedItems` + tableView.menu?.popUpAtMouseLocation(in: cell) } } -extension BookmarkManagementDetailViewController: BookmarkMenuItemSelectors { +// MARK: - BookmarksContextMenuDelegate - func openBookmarkInNewTab(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark, - let url = bookmark.urlObject else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } +extension BookmarkManagementDetailViewController: BookmarksContextMenuDelegate { - managementDetailViewModel.onBookmarkTapped() + var isSearching: Bool { managementDetailViewModel.isSearching } - WindowControllersManager.shared.show(url: url, source: .bookmark, newTab: true) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + var parentFolder: BookmarkFolder? { + return managementDetailViewModel.fetchParent() } - func openBookmarkInNewWindow(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark, - let url = bookmark.urlObject else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - - managementDetailViewModel.onBookmarkTapped() + var shouldIncludeManageBookmarksItem: Bool { false } - WindowsManager.openNewWindow(with: url, source: .bookmark, isBurner: false) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() - } + func selectedItems() -> [Any] { + guard let row = tableView.clickedRowIfValid ?? tableView.withMouseLocationInViewCoordinates(convert: { point in + tableView.row(at: point) + }), row != -1 else { return [] } - func toggleBookmarkAsFavorite(_ sender: NSMenuItem) { - if let bookmark = sender.representedObject as? Bookmark { - bookmark.isFavorite.toggle() - bookmarkManager.update(bookmark: bookmark) - } else if let bookmarks = sender.representedObject as? [Bookmark] { - let bookmarkIdentifiers = bookmarks.map(\.id) - bookmarkManager.update(objectsWithUUIDs: bookmarkIdentifiers, update: { entity in - (entity as? Bookmark)?.isFavorite.toggle() - }, completion: { error in - if error != nil { - assertionFailure("Failed to update bookmarks: ") - } - }) - } else { - assertionFailure("Failed to cast menu represented object to Bookmark") - } - } - - func editBookmark(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { return } - - BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark) - .show(in: view.window) - } - - func copyBookmark(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return + // 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 tableView.selectedRowIndexes.compactMap { index in + return fetchEntity(at: index) + } } - bookmark.copyUrlToPasteboard() + return fetchEntity(at: row).map { [$0] } ?? [] } - func deleteBookmark(_ sender: NSMenuItem) { - guard let bookmark = sender.representedObject as? Bookmark else { - assertionFailure("Failed to cast menu represented object to Bookmark") - return - } - - bookmarkManager.remove(bookmark: bookmark) + func showDialog(_ dialog: any ModalView) { + dialog.show(in: view.window) } - func deleteEntities(_ sender: NSMenuItem) { - let uuids: [String] - - if let array = sender.representedObject as? [String] { - uuids = array - } else if let objects = sender.representedObject as? [BaseBookmarkEntity] { - uuids = objects.map(\.id) - } else { - assertionFailure("Failed to cast menu item's represented object to UUID array") - return - } - - bookmarkManager.remove(objectsWithUUIDs: uuids) - } + func closePopoverIfNeeded() {} } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift index fce1c9d2d1..1b34c36646 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift @@ -44,6 +44,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { } private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager private let treeControllerDataSource: BookmarkSidebarTreeController private lazy var tabSwitcherButton = NSPopUpButton() @@ -54,8 +55,8 @@ final class BookmarkManagementSidebarViewController: NSViewController { private lazy var dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController, - sortMode: selectedSortMode, - showMenuButtonOnHover: false) + dragDropManager: dragDropManager, + sortMode: selectedSortMode) private var cancellables = Set() private var selectedSortMode: BookmarksSortMode @@ -69,8 +70,10 @@ final class BookmarkManagementSidebarViewController: NSViewController { return [BookmarkNode]() } - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared) { self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager self.selectedSortMode = bookmarkManager.sortMode treeControllerDataSource = .init(bookmarkManager: bookmarkManager) super.init(nibName: nil, bundle: nil) @@ -102,6 +105,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { scrollView.borderType = .noBorder scrollView.drawsBackground = false scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.usesPredominantAxisScrolling = false @@ -120,8 +124,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { outlineView.rowHeight = 28 outlineView.target = self outlineView.doubleAction = #selector(onDoubleClick) - outlineView.menu = NSMenu() - outlineView.menu!.delegate = self + outlineView.menu = BookmarksContextMenu(bookmarkManager: bookmarkManager, delegate: self) outlineView.dataSource = dataSource outlineView.delegate = dataSource @@ -150,8 +153,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() outlineView.setDraggingSourceOperationMask([.move], forLocal: true) - outlineView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, - FolderPasteboardWriter.folderUTIInternalType]) + outlineView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) dataSource.$selectedFolders.sink { [weak self] selectedFolders in guard let self else { return } @@ -288,106 +290,29 @@ final class BookmarkManagementSidebarViewController: NSViewController { } } +// MARK: - BookmarksContextMenu +extension BookmarkManagementSidebarViewController: BookmarksContextMenuDelegate { -extension BookmarkManagementSidebarViewController: NSMenuDelegate { + var isSearching: Bool { false } + var parentFolder: BookmarkFolder? { nil } + var shouldIncludeManageBookmarksItem: Bool { false } - func contextualMenuForClickedRows() -> NSMenu? { - let row = outlineView.clickedRow - - guard row != -1 else { - return ContextualMenu.menu(for: nil) - } + func selectedItems() -> [Any] { + guard let row = outlineView.clickedRowIfValid else { return [] } if outlineView.selectedRowIndexes.contains(row) { - return ContextualMenu.menu(for: outlineView.selectedItems) - } - - if let item = outlineView.item(atRow: row) { - return ContextualMenu.menu(for: [item]) - } else { - return nil - } - } - - func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - - guard let contextualMenu = contextualMenuForClickedRows() else { - return - } - - let items = contextualMenu.items - contextualMenu.removeAllItems() - for menuItem in items { - menu.addItem(menuItem) - } - } - -} - -extension BookmarkManagementSidebarViewController: FolderMenuItemSelectors { - - func newFolder(_ sender: NSMenuItem) { - let parent = sender.representedObject as? BookmarkFolder - BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parent) - .show(in: view.window) - } - - 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 + return outlineView.selectedItems } - - BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) - .show(in: view.window) + return outlineView.item(atRow: row).map { [$0] } ?? [] } - func deleteFolder(_ sender: NSMenuItem) { - guard let folder = sender.representedObject as? BookmarkFolder else { - assertionFailure("Failed to retrieve Folder from Delete Folder context menu item") - return - } - - bookmarkManager.remove(folder: folder) + func showDialog(_ dialog: any ModalView) { + dialog.show(in: view.window) } - 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 folder = sender.representedObject as? BookmarkFolder - else { - assertionFailure("Cannot open all in new tabs") - return - } - - let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) - tabCollection.append(tabs: tabs) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() - } - - 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) - PixelExperiment.fireOnboardingBookmarkUsed5to7Pixel() + func closePopoverIfNeeded() {} + func showInFolder(_ sender: NSMenuItem) { + assertionFailure("BookmarkManagementSidebarViewController does not support search") } } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift index fb79524c09..facb508dc7 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift @@ -25,24 +25,62 @@ protocol BookmarkOutlineCellViewDelegate: AnyObject { final class BookmarkOutlineCellView: NSTableCellView { + private static let sizingCellIdentifier = NSUserInterfaceItemIdentifier("sizing") + static func identifier(for mode: BookmarkOutlineViewDataSource.ContentMode) -> NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier("\(mode)_\(self.className())") + } + + static let sizingCell: BookmarkOutlineCellView = { + let cell = BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.sizingCellIdentifier) + cell.highlight = true // include menu button width + return cell + }() + + static let rowHeight: CGFloat = 28 + private enum Constants { + static let minUrlLabelWidth: CGFloat = 42 + static let minWidth: CGFloat = 75 + static let extraWidth: CGFloat = 6 + } + private lazy var faviconImageView = NSImageView() private lazy var titleLabel = NSTextField(string: "Bookmark/Folder") private lazy var countLabel = NSTextField(string: "42") + private lazy var urlLabel = NSTextField(string: "URL") 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) - }() + private var leadingConstraint = NSLayoutConstraint() - var shouldShowMenuButton = false + var highlight = false { + didSet { + updateUI() + } + } + var isInKeyWindow = true { + didSet { + updateUI() + } + } + + var contentMode: BookmarkOutlineViewDataSource.ContentMode? { + BookmarkOutlineViewDataSource.ContentMode.allCases.first { mode in + Self.identifier(for: mode) == self.identifier + } + } + + var shouldShowMenuButton: Bool { + contentMode != .foldersOnly + } + var shouldShowChevron: Bool { + contentMode == .bookmarksMenu + } weak var delegate: BookmarkOutlineCellViewDelegate? init(identifier: NSUserInterfaceItemIdentifier) { super.init(frame: .zero) self.identifier = identifier - setupUI() } @@ -50,33 +88,15 @@ 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() { + translatesAutoresizingMaskIntoConstraints = false + addSubview(faviconImageView) addSubview(titleLabel) addSubview(countLabel) + addSubview(urlLabel) addSubview(menuButton) addSubview(favoriteImageView) @@ -91,6 +111,7 @@ final class BookmarkOutlineCellView: NSTableCellView { titleLabel.isEditable = false titleLabel.isBordered = false titleLabel.isSelectable = false + titleLabel.refusesFirstResponder = true titleLabel.drawsBackground = false titleLabel.font = .systemFont(ofSize: 13) titleLabel.textColor = .controlTextColor @@ -100,17 +121,28 @@ final class BookmarkOutlineCellView: NSTableCellView { countLabel.isEditable = false countLabel.isBordered = false countLabel.isSelectable = false + countLabel.refusesFirstResponder = true countLabel.drawsBackground = false countLabel.font = .preferredFont(forTextStyle: .body) countLabel.alignment = .right countLabel.textColor = .blackWhite60 countLabel.lineBreakMode = .byClipping + urlLabel.translatesAutoresizingMaskIntoConstraints = false + urlLabel.isEditable = false + urlLabel.isBordered = false + urlLabel.isSelectable = false + urlLabel.drawsBackground = false + urlLabel.font = .systemFont(ofSize: 13) + urlLabel.textColor = .secondaryLabelColor + urlLabel.lineBreakMode = .byTruncatingTail + menuButton.translatesAutoresizingMaskIntoConstraints = false menuButton.contentTintColor = .button menuButton.imagePosition = .imageTrailing menuButton.isBordered = false menuButton.isHidden = true + menuButton.sendAction(on: .leftMouseDown) favoriteImageView.translatesAutoresizingMaskIntoConstraints = false favoriteImageView.imageScaling = .scaleProportionallyDown @@ -126,37 +158,112 @@ final class BookmarkOutlineCellView: NSTableCellView { leadingConstraint, faviconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), - titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 10), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor) + .priority(700), + bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 6), + trailingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 0) + .priority(800), + + urlLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: urlLabel.trailingAnchor), + countLabel.leadingAnchor.constraint(greaterThanOrEqualTo: urlLabel.trailingAnchor), 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) + .priority(900) + .autoDeactivatedWhenViewIsHidden(faviconImageView) + menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(menuButton) + countLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(countLabel) + favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(favoriteImageView) + urlLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, + constant: 6) + .priority(900) + .autoDeactivatedWhenViewIsHidden(urlLabel) + + faviconImageView.setContentHuggingPriority(.init(251), for: .vertical) + urlLabel.setContentHuggingPriority(.init(300), for: .horizontal) + countLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + if identifier != Self.sizingCellIdentifier { + faviconImageView.setContentHuggingPriority(.init(251), for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.init(300), for: .horizontal) + titleLabel.setContentHuggingPriority(.init(301), for: .vertical) + urlLabel.setContentCompressionResistancePriority(.init(200), for: .horizontal) + countLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) - titleLabel.setContentHuggingPriority(.init(rawValue: 200), for: .horizontal) + } else { + faviconImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + urlLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + trailingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 8) + .priority(900) + .isActive = true + } + } - countLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) - countLabel.setContentHuggingPriority(.required, for: .horizontal) + private func updateUI() { + if shouldShowMenuButton && titleLabel.isEnabled { + let isHighlighted = self.highlight && self.isInKeyWindow + countLabel.isHidden = isHighlighted || countLabel.stringValue.isEmpty + favoriteImageView.isHidden = isHighlighted || favoriteImageView.image == nil + menuButton.isShown = isHighlighted && faviconImageView.image != nil // don‘t show for custom menu item + menuButton.contentTintColor = isHighlighted ? .selectedMenuItemTextColor : .button + urlLabel.isShown = isHighlighted && !urlLabel.stringValue.isEmpty + } else { + menuButton.isHidden = true + urlLabel.isHidden = true + } + if !titleLabel.isEnabled { + titleLabel.textColor = .disabledControlTextColor + } else if highlight, + isInKeyWindow, + contentMode != .foldersOnly { + titleLabel.textColor = .selectedMenuItemTextColor + urlLabel.textColor = .selectedMenuItemTextColor + } else { + titleLabel.textColor = .controlTextColor + urlLabel.textColor = .secondaryLabelColor + } + } + + override func layout() { + super.layout() + + // hide URL label if it can‘t fit meaningful text length + if urlLabel.isShown, urlLabel.frame.width < Constants.minUrlLabelWidth { + urlLabel.stringValue = "" + urlLabel.isHidden = true + } } @objc private func cellMenuButtonClicked() { @@ -165,26 +272,84 @@ final class BookmarkOutlineCellView: NSTableCellView { // MARK: - Public - func update(from bookmark: Bookmark, isSearch: Bool = false) { + static func preferredContentWidth(for object: Any?) -> CGFloat { + guard let representedObject = (object as? BookmarkNode)?.representedObject ?? object else { return 0 } + let extraWidth: CGFloat + let minWidth: CGFloat + switch representedObject { + case is Bookmark, is BookmarkFolder, is PseudoFolder: + minWidth = Constants.minWidth + extraWidth = Constants.extraWidth + case is MenuItemNode: + minWidth = 0 + extraWidth = 0 + default: + return 0 + } + sizingCell.frame = .zero + sizingCell.update(from: representedObject) + sizingCell.layoutSubtreeIfNeeded() + + return max(minWidth, sizingCell.frame.width + extraWidth) + } + + func update(from object: Any, isSearch: Bool = false) { + let representedObject = (object as? BookmarkNode)?.representedObject ?? object + switch representedObject { + case let bookmark as Bookmark: + update(from: bookmark, isSearch: isSearch, showURL: identifier != Self.sizingCellIdentifier) + case let folder as BookmarkFolder: + update(from: folder, isSearch: isSearch) + case let folder as PseudoFolder: + update(from: folder) + case let menuItem as MenuItemNode: + update(from: menuItem) + default: + assertionFailure("Unexpected object \(object).\(String(describing: (object as? BookmarkNode)?.representedObject))") + } + } + + func update(from bookmark: Bookmark, isSearch: Bool = false, showURL: Bool) { faviconImageView.image = bookmark.favicon(.small) ?? .bookmarkDefaultFavicon + faviconImageView.isHidden = false titleLabel.stringValue = bookmark.title + titleLabel.isEnabled = true countLabel.stringValue = "" + countLabel.isHidden = true + urlLabel.stringValue = showURL ? "– " + bookmark.url.dropping(prefix: { + if let scheme = URL(string: bookmark.url)?.navigationalScheme, + scheme.isHypertextScheme { + return scheme.separated() + } else { + return "" + } + }()) : "" + urlLabel.isHidden = urlLabel.stringValue.isEmpty + self.toolTip = bookmark.url favoriteImageView.image = bookmark.isFavorite ? .favoriteFilledBorder : nil + favoriteImageView.isHidden = favoriteImageView.image == nil + updateConstraints(isSearch: isSearch) } func update(from folder: BookmarkFolder, isSearch: Bool = false) { faviconImageView.image = .folder + faviconImageView.isHidden = false titleLabel.stringValue = folder.title - favoriteImageView.image = nil + titleLabel.isEnabled = true + favoriteImageView.image = shouldShowChevron ? .chevronMediumRight16 : nil + favoriteImageView.isHidden = favoriteImageView.image == nil + urlLabel.stringValue = "" + self.toolTip = nil let totalChildBookmarks = folder.totalChildBookmarks - if totalChildBookmarks > 0 { + if totalChildBookmarks > 0 && !shouldShowChevron { countLabel.stringValue = String(totalChildBookmarks) + countLabel.isHidden = false } else { countLabel.stringValue = "" + countLabel.isHidden = true } - updateConstraints(isSearch: isSearch) } @@ -194,9 +359,28 @@ final class BookmarkOutlineCellView: NSTableCellView { func update(from pseudoFolder: PseudoFolder) { faviconImageView.image = pseudoFolder.icon + faviconImageView.isHidden = false titleLabel.stringValue = pseudoFolder.name + titleLabel.isEnabled = true countLabel.stringValue = pseudoFolder.count > 0 ? String(pseudoFolder.count) : "" + countLabel.isHidden = countLabel.stringValue.isEmpty + favoriteImageView.image = nil + favoriteImageView.isHidden = true + urlLabel.stringValue = "" + self.toolTip = nil + } + + func update(from menuItem: MenuItemNode) { + faviconImageView.image = nil + faviconImageView.isHidden = true + titleLabel.stringValue = menuItem.title + titleLabel.isEnabled = menuItem.isEnabled + countLabel.stringValue = "" favoriteImageView.image = nil + favoriteImageView.isHidden = true + urlLabel.stringValue = "" + urlLabel.isHidden = true + self.toolTip = nil } } @@ -220,11 +404,17 @@ extension BookmarkOutlineCellView { translatesAutoresizingMaskIntoConstraints = true let cells = [ - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.identifier(for: .bookmarksMenu)), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.identifier(for: .bookmarksMenu)), + BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.identifier(for: .bookmarksMenu)), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), ] let stackView = NSStackView(views: cells as [NSView]) @@ -232,13 +422,31 @@ extension BookmarkOutlineCellView { stackView.spacing = 1 addAndLayout(stackView) - cells[0].update(from: Bookmark(id: "1", url: "http://a.b", title: "DuckDuckGo", isFavorite: true)) - cells[1].update(from: BookmarkFolder(id: "2", title: "Bookmark Folder with a reasonably long name")) - cells[2].update(from: BookmarkFolder(id: "2", title: "Bookmark Folder with 42 bookmark children", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42))) + cells[0].update(from: Bookmark(id: "1", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), showURL: false) + cells[1].update(from: Bookmark(id: "2", url: "http://aurl.bu/asdfg/ss=1", title: "Some Page bookmarked", isFavorite: true), showURL: true) + cells[1].highlight = true + cells[1].wantsLayer = true + cells[1].layer!.backgroundColor = NSColor.controlAccentColor.cgColor + + let bkm2 = Bookmark(id: "3", url: "http://a.b", title: "Bookmark with longer title to test width", isFavorite: false) + cells[2].update(from: bkm2, showURL: false) + + cells[3].update(from: BookmarkFolder(id: "4", title: "Bookmark Folder with a reasonably long name")) + cells[4].update(from: BookmarkFolder(id: "5", title: "Bookmark Folder with 42 bookmark children", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42))) PseudoFolder.favorites.count = 64 - cells[3].update(from: PseudoFolder.favorites) + cells[5].update(from: PseudoFolder.favorites) PseudoFolder.bookmarks.count = 256 - cells[4].update(from: PseudoFolder.bookmarks) + cells[6].update(from: PseudoFolder.bookmarks) + + let node = BookmarkNode(representedObject: MenuItemNode(identifier: "", title: UserText.bookmarksOpenInNewTabs, isEnabled: true), parent: BookmarkNode.genericRootNode()) + cells[7].update(from: node) + + let emptyNode = BookmarkNode(representedObject: MenuItemNode(identifier: "", title: UserText.bookmarksBarFolderEmpty, isEnabled: false), parent: BookmarkNode.genericRootNode()) + cells[8].update(from: emptyNode) + + let sbkm = Bookmark(id: "3", url: "http://a.b", title: "Bookmark in Search mode", isFavorite: false) + cells[9].update(from: sbkm, isSearch: true, showURL: false) + cells[10].update(from: BookmarkFolder(id: "5", title: "Folder in Search mode", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42)), isSearch: true) widthAnchor.constraint(equalToConstant: 258).isActive = true heightAnchor.constraint(equalToConstant: CGFloat((28 + 1) * cells.count)).isActive = true diff --git a/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift index 3a3d3ea0d9..83da5ac44b 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkTableCellView.swift @@ -129,6 +129,7 @@ final class BookmarkTableCellView: NSTableCellView { menuButton.translatesAutoresizingMaskIntoConstraints = false menuButton.isBordered = false menuButton.isHidden = true + menuButton.sendAction(on: .leftMouseDown) menuButton.setAccessibilityIdentifier("BookmarkTableCellView.menuButton") } diff --git a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift index 274582f719..46b2fde825 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift @@ -17,16 +17,131 @@ // import AppKit +import Carbon -extension NSDragOperation { - - static let none = NSDragOperation([]) - +protocol BookmarksOutlineViewDataSource: NSOutlineViewDataSource { + func firstHighlightableRow(for _: BookmarksOutlineView) -> Int? + func nextHighlightableRow(inNextSection: Bool, for _: BookmarksOutlineView, after row: Int) -> Int? + func previousHighlightableRow(inPreviousSection: Bool, for _: BookmarksOutlineView, before row: Int) -> Int? + func lastHighlightableRow(for _: BookmarksOutlineView) -> Int? +} +extension BookmarksOutlineViewDataSource { + func nextHighlightableRow(for outlineView: BookmarksOutlineView, after row: Int) -> Int? { + nextHighlightableRow(inNextSection: false, for: outlineView, after: row) + } + func previousHighlightableRow(for outlineView: BookmarksOutlineView, before row: Int) -> Int? { + previousHighlightableRow(inPreviousSection: false, for: outlineView, before: row) + } } final class BookmarksOutlineView: NSOutlineView { - var lastRow: RoundedSelectionRowView? + private var highlightedRowView: RoundedSelectionRowView? + private var highlightedCellView: BookmarkOutlineCellView? + + private var bookmarksDataSource: BookmarksOutlineViewDataSource? { + dataSource as? BookmarksOutlineViewDataSource + } + + @PublishedAfter var highlightedRow: Int? { + didSet { + defer { + updateIsInKeyPopoverState() + } + highlightedRowView?.highlight = false + highlightedCellView?.highlight = false + guard let row = highlightedRow, row < numberOfRows else { return } + if case .keyDown = NSApp.currentEvent?.type { + scrollRowToVisible(row) + } + + let item = item(atRow: row) as? BookmarkNode + let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView + rowView?.highlight = item?.canBeHighlighted ?? false + highlightedRowView = rowView + + let cellView = self.view(atColumn: 0, row: row, makeIfNecessary: false) as? BookmarkOutlineCellView + cellView?.highlight = item?.canBeHighlighted ?? false + highlightedCellView = cellView + } + } + + /// popover displaying this Bookmarks Menu + private var popover: NSPopover? { + window?.contentViewController?.nextResponder as? NSPopover + } + + /// return parent level Bookmarks Menu Outline View if this Bookmarks Menu is displayed as its submenu + private var parentMenuOutlineView: Self? { + if let window, // popover window + let windowParent = window.parent, // parent popover window + type(of: windowParent) == type(of: window) /* does window type match _NSPopoverWindow? */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self { + return outlineView + } + return nil + } + + private var isInPopover: Bool { + popover != nil + } + private var isInKeyPopover: Bool { + guard highlightedRow != nil else { return false } + // is there a child menu popover window owned by our window? + if window?.childWindows?.first(where: { child in + if type(of: child) == type(of: window!), + let scrollView = child.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self, + outlineView.highlightedRow != nil { + true + } else { + false + } + }) != nil { + return false + } + return true + } + + // mark highlight with inactive color for non-key popover menu and with active color for key popover menu + private func updateIsInKeyPopoverState() { + guard isInPopover else { + highlightedRowView?.isInKeyWindow = false + highlightedCellView?.isInKeyWindow = false + return + } + // when no highlighted row - our parent is the key popover + guard highlightedRow != nil else { + parentMenuOutlineView?.updateIsInKeyPopoverState() + return + } + + var isInKeyPopover = self.isInKeyPopover + var outlineView: BookmarksOutlineView! = self + while outlineView != nil { + outlineView.highlightedRowView?.isInKeyWindow = isInKeyPopover + outlineView.highlightedCellView?.isInKeyWindow = isInKeyPopover + + // if we‘re in the key popover all our parent popovers should not be key + isInKeyPopover = false + outlineView = outlineView.parentMenuOutlineView + } + } + + @objc private func popoverDidClose(_: Notification) { + updateIsInKeyPopoverState() + } + + override var clickedRow: Int { + let clickedRow = super.clickedRow + if clickedRow != -1 { + return clickedRow + } + return self.withMouseLocationInViewCoordinates { point in + self.row(at: point) + } ?? -1 + } override func frameOfOutlineCell(atRow row: Int) -> NSRect { let frame = super.frameOfOutlineCell(atRow: row) @@ -51,22 +166,154 @@ final class BookmarksOutlineView: NSOutlineView { } override func viewDidMoveToWindow() { + highlightedRow = nil + super.viewDidMoveToWindow() guard let scrollView = enclosingScrollView else { return } - let trackingArea = NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil) + let trackingArea = NSTrackingArea(rect: .zero, options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil) scrollView.addTrackingArea(trackingArea) + + NotificationCenter.default.addObserver(self, selector: #selector(popoverDidClose), name: NSPopover.didCloseNotification, object: window?.contentViewController?.nextResponder) + } + + override func didAdd(_ rowView: NSTableRowView, forRow row: Int) { + super.didAdd(rowView, forRow: row) + + guard let rowView = rowView as? RoundedSelectionRowView, + let cell = rowView.subviews.first as? BookmarkOutlineCellView else { return } + + let highlight = (row == highlightedRow) + + rowView.highlight = highlight + cell.highlight = highlight + + if highlight { + highlightedRowView = rowView + highlightedCellView = cell + + updateIsInKeyPopoverState() + } } override func mouseMoved(with event: NSEvent) { - lastRow?.highlight = false - let point = convert(event.locationInWindow, to: nil) - let row = row(at: point) - guard row >= 0, let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView else { return } - let item = item(atRow: row) as? BookmarkNode - rowView.highlight = !(item?.representedObject is SpacerNode) - lastRow = rowView + updateHighlightedRowUnderCursor() + } + + override func mouseExited(with event: NSEvent) { + let windowNumber = NSWindow.windowNumber(at: NSEvent.mouseLocation, belowWindowWithWindowNumber: 0) + // don‘t reset highlight when mouse is exiting to a child popover + guard let window, !(window.childWindows?.isEmpty ?? true), + let mouseWindow = NSApp.window(withWindowNumber: windowNumber), + type(of: mouseWindow) == type(of: window) /* _NSPopoverWindow */ else { + highlightedRow = nil + return + } + } + + override func keyDown(with event: NSEvent) { + switch Int(event.keyCode) { + case kVK_DownArrow, kVK_PageDown: + onDownArrowPress(event) + case kVK_UpArrow, kVK_PageUp: + onUpArrowPress(event) + case kVK_RightArrow: + onRightArrowPress(event) + case kVK_LeftArrow: + onLeftArrowPress(event) + default: + super.keyDown(with: event) + } + } + + private func onDownArrowPress(_ event: NSEvent) { + if let highlightedRow { + // modify existing highlight + if event.modifierFlags.contains(.option) || event.keyCode == kVK_PageDown, + let lastRow = bookmarksDataSource?.lastHighlightableRow(for: self) { + self.highlightedRow = lastRow + } else if let nextRow = bookmarksDataSource?.nextHighlightableRow(inNextSection: event.modifierFlags.contains(.command), for: self, after: highlightedRow) { + self.highlightedRow = nextRow + } + + } else if let parentMenuOutlineView /* && highlightedRow == nil */ { + // when no highlighted row in child menu popover: send event to parent menu to close the submenu and highlight next row + parentMenuOutlineView.keyDown(with: event) + return + + } else if event.modifierFlags.contains(.option) || event.keyCode == kVK_PageDown, + let lastRow = bookmarksDataSource?.lastHighlightableRow(for: self) { + // highlight last row on Opt+Down + self.highlightedRow = lastRow + + } else if let firstRow = bookmarksDataSource?.firstHighlightableRow(for: self) { + // highlight first row on Down without existing highlight + self.highlightedRow = firstRow + } + } + + private func onUpArrowPress(_ event: NSEvent) { + if let highlightedRow { + // modify existing highlight + if event.modifierFlags.contains(.option) || event.keyCode == kVK_PageUp, + let firstRow = bookmarksDataSource?.firstHighlightableRow(for: self) { + self.highlightedRow = firstRow + } else if let prevRow = bookmarksDataSource?.previousHighlightableRow(inPreviousSection: event.modifierFlags.contains(.command), for: self, before: highlightedRow) { + self.highlightedRow = prevRow + } + + } else if let parentMenuOutlineView /* && highlightedRow == nil */ { + // when no highlighted row in child menu popover: send event to parent menu to close the submenu and highlight prev row + parentMenuOutlineView.keyDown(with: event) + return + + } else if event.modifierFlags.contains(.option) || event.keyCode == kVK_PageUp, + let firstRow = bookmarksDataSource?.firstHighlightableRow(for: self) { + // highlight last row on Opt+Dp + self.highlightedRow = firstRow + + } else if let lastRow = bookmarksDataSource?.lastHighlightableRow(for: self) { + // highlight last row on Up without existing highlight + self.highlightedRow = lastRow + } + } + + private func onRightArrowPress(_ event: NSEvent) { + if parentMenuOutlineView != nil, highlightedRow == nil, + let firstRow = bookmarksDataSource?.firstHighlightableRow(for: self) { + // when we are in a submenu and no row highlighted: highlight first row on Right + highlightedRow = firstRow + + } else if let highlightedRow, let item = self.item(atRow: highlightedRow), + isExpandable(item) { + guard !isItemExpanded(item) else { return } + // regular Outline View item expansion + animator().expandItem(item) + + } else { + // pass the key to the BookmarkListViewController to expand highlighted folder + // or to delegate it to the Bookmarks Bar to open the next Bookmarks Menu + nextResponder?.keyDown(with: event) + } + } + + private func onLeftArrowPress(_ event: NSEvent) { + if parentMenuOutlineView != nil { + // when we are in a submenu close the submenu on Left + popover?.close() + + } else if let highlightedRow, + let item = self.item(atRow: highlightedRow), + isExpandable(item) { + // regular Outline View item collapsing + guard isItemExpanded(item) else { return } + animator().collapseItem(item) + + } else { + // pass the key to the BookmarkListViewController to delegate it to the Bookmarks Bar to open previous Bookmarks Menu + nextResponder?.keyDown(with: event) + } } func scrollTo(_ item: Any, code: ((Int) -> Void)? = nil) { @@ -91,13 +338,45 @@ final class BookmarksOutlineView: NSOutlineView { } func highlight(_ item: Any) { - let row = row(forItem: item) - guard let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView else { return } + guard let row = rowIfValid(forItem: item) else { return } + self.highlightedRow = row + } + + @discardableResult + func highlightFirstItem() -> Bool { + guard let prevRow = bookmarksDataSource?.firstHighlightableRow(for: self) else { return false } + self.highlightedRow = prevRow + return true + } - rowView.highlight = true + @discardableResult + func highlightNextItem() -> Bool { + guard let highlightedRow else { return highlightFirstItem() } + guard let rowToHighlight = bookmarksDataSource?.nextHighlightableRow(for: self, after: highlightedRow) else { return false } + self.highlightedRow = rowToHighlight + return true + } + + @discardableResult + func highlightPreviousItem() -> Bool { + guard let highlightedRow, + let prevRow = bookmarksDataSource?.previousHighlightableRow(for: self, before: highlightedRow) else { return false } + self.highlightedRow = prevRow + return true + } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - rowView.highlight = false + func updateHighlightedRowUnderCursor() { + let point = mouseLocationInsideBounds() + let row = point.map { self.row(at: NSPoint(x: self.bounds.midX, y: $0.y)) } ?? -1 + guard row >= 0, row < NSNotFound else { + highlightedRow = nil + return + } + if highlightedRow != row { + highlightedRow = row + } else if isInPopover { + highlightedRowView?.isInKeyWindow = true + highlightedCellView?.isInKeyWindow = true } } @@ -111,4 +390,13 @@ final class BookmarksOutlineView: NSOutlineView { let visibleRowsRange = self.rows(in: self.visibleRect) return visibleRowsRange.contains(rowIndex) } + + override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool { + if event?.type == .rightMouseDown { + // always allow context menu on a cell + return true + } + return super.validateProposedFirstResponder(responder, for: event) + } + } diff --git a/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift index 3bff7ff4af..3f62495cca 100644 --- a/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift +++ b/DuckDuckGo/Bookmarks/View/Dialog/BookmarksDialogViewFactory.swift @@ -26,7 +26,7 @@ enum BookmarksDialogViewFactory { /// - 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 { + static func makeAddBookmarkFolderView(parentFolder: BookmarkFolder?, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkFolderDialogView { let viewModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: parentFolder), bookmarkManager: bookmarkManager) return AddEditBookmarkFolderDialogView(viewModel: viewModel) } @@ -37,7 +37,7 @@ enum BookmarksDialogViewFactory { /// - 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 { + static func makeEditBookmarkFolderView(folder: BookmarkFolder, parentFolder: BookmarkFolder?, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkFolderDialogView { let viewModel = AddEditBookmarkFolderDialogViewModel(mode: .edit(folder: folder, parentFolder: parentFolder), bookmarkManager: bookmarkManager) return AddEditBookmarkFolderDialogView(viewModel: viewModel) } @@ -47,7 +47,7 @@ enum BookmarksDialogViewFactory { /// - 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 { + static func makeAddBookmarkView(currentTab: WebsiteInfo?, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkDialogView { let viewModel = AddEditBookmarkDialogViewModel(mode: .add(tabWebsite: currentTab), bookmarkManager: bookmarkManager) return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) } @@ -57,7 +57,7 @@ enum BookmarksDialogViewFactory { /// - 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 { + static func makeAddBookmarkView(parent: BookmarkFolder?, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkDialogView { let viewModel = AddEditBookmarkDialogViewModel(mode: .add(parentFolder: parent), bookmarkManager: bookmarkManager) return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) } @@ -65,7 +65,7 @@ enum BookmarksDialogViewFactory { /// 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 { + static func makeAddFavoriteView(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkDialogView { let viewModel = AddEditBookmarkDialogViewModel(mode: .add(shouldPresetFavorite: true), bookmarkManager: bookmarkManager) return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) } @@ -75,7 +75,7 @@ enum BookmarksDialogViewFactory { /// - 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 { + static func makeEditBookmarkView(bookmark: Bookmark, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> AddEditBookmarkDialogView { let viewModel = AddEditBookmarkDialogViewModel(mode: .edit(bookmark: bookmark), bookmarkManager: bookmarkManager) return makeAddEditBookmarkDialogView(viewModel: viewModel, bookmarkManager: bookmarkManager) } @@ -85,7 +85,7 @@ enum BookmarksDialogViewFactory { /// - websitesInfo: A list of websites to add as bookmarks. /// - bookmarkManager: An instance of `BookmarkManager`. This should be used for `#previews` only. /// - Returns: An instance of BookmarkAllTabsDialogView - static func makeBookmarkAllOpenTabsView(websitesInfo: [WebsiteInfo], bookmarkManager: LocalBookmarkManager = .shared) -> BookmarkAllTabsDialogView { + static func makeBookmarkAllOpenTabsView(websitesInfo: [WebsiteInfo], bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) -> BookmarkAllTabsDialogView { let addFolderViewModel = AddEditBookmarkFolderDialogViewModel(mode: .add(parentFolder: nil), bookmarkManager: bookmarkManager) let bookmarkAllTabsViewModel = BookmarkAllTabsDialogViewModel(websites: websitesInfo, foldersStore: UserDefaultsBookmarkFoldersStore(), bookmarkManager: bookmarkManager) let viewModel = BookmarkAllTabsDialogCoordinatorViewModel(bookmarkModel: bookmarkAllTabsViewModel, folderModel: addFolderViewModel) diff --git a/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift b/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift index 9413168874..98f5bf605f 100644 --- a/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift +++ b/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift @@ -20,6 +20,16 @@ import AppKit final class OutlineSeparatorViewCell: NSTableCellView { + static let separatorIdentifier = NSUserInterfaceItemIdentifier(className() + "_separator") + static let blankIdentifier = NSUserInterfaceItemIdentifier(className()) + + static func rowHeight(for mode: BookmarkOutlineViewDataSource.ContentMode) -> CGFloat { + switch mode { + case .bookmarksMenu: 11 + case .bookmarksAndFolders, .foldersOnly: 28 + } + } + private lazy var separatorView: NSBox = { let box = NSBox() box.translatesAutoresizingMaskIntoConstraints = false @@ -28,13 +38,15 @@ final class OutlineSeparatorViewCell: NSTableCellView { return box }() - init(separatorVisible: Bool = false) { + init(identifier: NSUserInterfaceItemIdentifier) { super.init(frame: .zero) + self.identifier = identifier + } - // Previous value of 20 was being ignored anyway and causes lots of console logging - self.heightAnchor.constraint(equalToConstant: 28).isActive = true + convenience init(isSeparatorVisible: Bool = false) { + self.init(identifier: isSeparatorVisible ? Self.separatorIdentifier : Self.blankIdentifier) - if separatorVisible { + if isSeparatorVisible { addSubview(separatorView) separatorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5).isActive = true diff --git a/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift b/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift index c66224aa4f..eea1567823 100644 --- a/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift +++ b/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift @@ -25,6 +25,11 @@ final class RoundedSelectionRowView: NSTableRowView { needsDisplay = true } } + var isInKeyWindow = true { + didSet { + needsDisplay = true + } + } var insets = NSEdgeInsets() @@ -65,7 +70,12 @@ final class RoundedSelectionRowView: NSTableRowView { selectionRect.size.height -= (insets.top + insets.bottom) let path = NSBezierPath(roundedRect: selectionRect, xRadius: 6, yRadius: 6) - NSColor.buttonMouseOver.setFill() + + if isInKeyWindow { + NSColor.controlAccentColor.setFill() + } else { + NSColor.buttonMouseOver.setFill() + } path.fill() } diff --git a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift index 0aa03eade1..896748705f 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/AddEditBookmarkDialogViewModel.swift @@ -78,7 +78,7 @@ final class AddEditBookmarkDialogViewModel: BookmarkDialogEditing { private let mode: Mode private let bookmarkManager: BookmarkManager - init(mode: Mode, bookmarkManager: LocalBookmarkManager = .shared) { + init(mode: Mode, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { let isFavorite = mode.bookmarkURL.flatMap(bookmarkManager.isUrlFavorited) ?? false self.mode = mode self.bookmarkManager = bookmarkManager diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift index a8d4de24b6..cda0bfaa05 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift @@ -79,12 +79,12 @@ final class BookmarkManagementDetailViewModel { return visibleBookmarks[safe: row] } - func fetchEntityAndParent(at row: Int) -> (entity: BaseBookmarkEntity?, parentFolder: BookmarkFolder?) { + func fetchParent() -> BookmarkFolder? { switch currentSelectionState { case .folder(let bookmarkFolder): - return (visibleBookmarks[safe: row], bookmarkFolder) + return bookmarkFolder default: - return (visibleBookmarks[safe: row], nil) + return nil } } @@ -96,59 +96,6 @@ final class BookmarkManagementDetailViewModel { return bookmarkManager.getBookmarkFolder(withId: parentID) } - // MARK: - Drag and drop - - func validateDrop(pasteboardItems: [NSPasteboardItem]?, - proposedRow row: Int, - proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { - if let proposedDestination = fetchEntity(at: row), proposedDestination.isFolder { - if let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: pasteboardItems) { - return validateDrop(for: bookmarks, destination: proposedDestination) - } - - if let folders = PasteboardFolder.pasteboardFolders(with: pasteboardItems) { - return validateDrop(for: folders, destination: proposedDestination) - } - - return .none - } else { - // We only want to allow dropping in the same level when not searching - if dropOperation == .above && searchQuery.isBlank { - return .move - } else { - return .none - } - } - } - - private func validateDrop(for draggedBookmarks: Set, destination: BaseBookmarkEntity) -> NSDragOperation { - guard destination is BookmarkFolder else { - return .none - } - - return .move - } - - private func validateDrop(for draggedFolders: Set, destination: BaseBookmarkEntity) -> NSDragOperation { - guard let destinationFolder = destination as? BookmarkFolder else { - return .none - } - - for folderID in draggedFolders.map(\.id) where !bookmarkManager.canMoveObjectWithUUID(objectUUID: folderID, to: destinationFolder) { - return .none - } - - let tryingToDragOntoSameFolder = draggedFolders.contains { folder in - return folder.id == destination.id - } - - if tryingToDragOntoSameFolder { - return .none - } - - return .move - } - // MARK: - Metrics func onSortButtonTapped() { diff --git a/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift index 5106338764..729408802a 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/SortBookmarksViewModel.swift @@ -129,7 +129,7 @@ final class SortBookmarksUserDefaults: SortBookmarksRepository { final class SortBookmarksViewModel: NSObject { - private let metrics: BookmarksSearchAndSortMetrics + let metrics: BookmarksSearchAndSortMetrics private let origin: BookmarkOperationOrigin private var manager: BookmarkManager private var wasSortOptionSelected = false diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard b/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard index b5d176243a..82c7fae914 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard @@ -1,7 +1,7 @@ - + - + @@ -28,7 +28,7 @@ - + @@ -46,12 +46,27 @@ -