diff --git a/src/Files.App.CsWin32/NativeMethods.txt b/src/Files.App.CsWin32/NativeMethods.txt index efbad8de4f2d..0b3e5ffe436d 100644 --- a/src/Files.App.CsWin32/NativeMethods.txt +++ b/src/Files.App.CsWin32/NativeMethods.txt @@ -164,6 +164,11 @@ IApplicationDestinations ApplicationDestinations IApplicationDocumentLists ApplicationDocumentLists +BHID_EnumItems +BHID_SFUIObject +IContextMenu +CMF_OPTIMIZEFORINVOKE +IPropertyStore IApplicationActivationManager MENU_ITEM_TYPE COMPRESSION_FORMAT diff --git a/src/Files.App/Actions/Sidebar/PinFolderToSidebarAction.cs b/src/Files.App/Actions/Sidebar/PinFolderToSidebarAction.cs index 228436a79256..1f94cd2fa195 100644 --- a/src/Files.App/Actions/Sidebar/PinFolderToSidebarAction.cs +++ b/src/Files.App/Actions/Sidebar/PinFolderToSidebarAction.cs @@ -1,14 +1,14 @@ // Copyright (c) 2024 Files Community // Licensed under the MIT License. See the LICENSE. -using Windows.Storage; +using System.Collections.Specialized; namespace Files.App.Actions { internal sealed class PinFolderToSidebarAction : ObservableObject, IAction { - private readonly IContentPageContext context; - private readonly IQuickAccessService service; + private readonly IContentPageContext context = Ioc.Default.GetRequiredService(); + private readonly IQuickAccessService service = Ioc.Default.GetRequiredService(); public string Label => "PinFolderToSidebar".GetLocalizedResource(); @@ -24,30 +24,25 @@ public bool IsExecutable public PinFolderToSidebarAction() { - context = Ioc.Default.GetRequiredService(); - service = Ioc.Default.GetRequiredService(); - context.PropertyChanged += Context_PropertyChanged; - App.QuickAccessManager.UpdateQuickAccessWidget += QuickAccessManager_DataChanged; + service.PinnedFoldersChanged += QuickAccessService_CollectionChanged; } public async Task ExecuteAsync(object? parameter = null) { - if (context.HasSelection) - { - var items = context.SelectedItems.Select(x => x.ItemPath).ToArray(); + var items = context.HasSelection + ? context.SelectedItems.Select(x => x.ItemPath).ToArray() + : context.Folder is not null + ? [context.Folder.ItemPath] + : null; - await service.PinToSidebarAsync(items); - } - else if (context.Folder is not null) - { - await service.PinToSidebarAsync(context.Folder.ItemPath); - } + if (items is not null) + await service.PinFolderAsync(items); } private bool GetIsExecutable() { - string[] pinnedFolders = [.. App.QuickAccessManager.Model.PinnedFolders]; + string[] pinnedFolders = [.. service.Folders.Select(x => x.Path)]; return context.HasSelection ? context.SelectedItems.All(IsPinnable) @@ -56,7 +51,7 @@ private bool GetIsExecutable() bool IsPinnable(ListedItem item) { return - item.PrimaryItemAttribute is StorageItemTypes.Folder && + item.PrimaryItemAttribute is Windows.Storage.StorageItemTypes.Folder && !pinnedFolders.Contains(item.ItemPath); } } @@ -72,7 +67,7 @@ private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) } } - private void QuickAccessManager_DataChanged(object? sender, ModifyQuickAccessEventArgs e) + private void QuickAccessService_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { OnPropertyChanged(nameof(IsExecutable)); } diff --git a/src/Files.App/Actions/Sidebar/UnpinFolderToSidebarAction.cs b/src/Files.App/Actions/Sidebar/UnpinFolderToSidebarAction.cs index dcd8437bbdff..f61569623c9d 100644 --- a/src/Files.App/Actions/Sidebar/UnpinFolderToSidebarAction.cs +++ b/src/Files.App/Actions/Sidebar/UnpinFolderToSidebarAction.cs @@ -1,12 +1,14 @@ // Copyright (c) 2024 Files Community // Licensed under the MIT License. See the LICENSE. +using System.Collections.Specialized; + namespace Files.App.Actions { internal sealed class UnpinFolderFromSidebarAction : ObservableObject, IAction { - private readonly IContentPageContext context; - private readonly IQuickAccessService service; + private readonly IContentPageContext context = Ioc.Default.GetRequiredService(); + private readonly IQuickAccessService service = Ioc.Default.GetRequiredService(); public string Label => "UnpinFolderFromSidebar".GetLocalizedResource(); @@ -22,29 +24,25 @@ public bool IsExecutable public UnpinFolderFromSidebarAction() { - context = Ioc.Default.GetRequiredService(); - service = Ioc.Default.GetRequiredService(); - context.PropertyChanged += Context_PropertyChanged; - App.QuickAccessManager.UpdateQuickAccessWidget += QuickAccessManager_DataChanged; + service.PinnedFoldersChanged += QuickAccessService_CollectionChanged; } public async Task ExecuteAsync(object? parameter = null) { - if (context.HasSelection) - { - var items = context.SelectedItems.Select(x => x.ItemPath).ToArray(); - await service.UnpinFromSidebarAsync(items); - } - else if (context.Folder is not null) - { - await service.UnpinFromSidebarAsync(context.Folder.ItemPath); - } + var items = context.HasSelection + ? context.SelectedItems.Select(x => x.ItemPath).ToArray() + : context.Folder is not null + ? [context.Folder.ItemPath] + : null; + + if (items is not null) + await service.UnpinFolderAsync(items); } private bool GetIsExecutable() { - string[] pinnedFolders = [.. App.QuickAccessManager.Model.PinnedFolders]; + string[] pinnedFolders = [.. service.Folders.Select(x => x.Path)]; return context.HasSelection ? context.SelectedItems.All(IsPinned) @@ -67,7 +65,7 @@ private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) } } - private void QuickAccessManager_DataChanged(object? sender, ModifyQuickAccessEventArgs e) + private void QuickAccessService_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { OnPropertyChanged(nameof(IsExecutable)); } diff --git a/src/Files.App/App.xaml.cs b/src/Files.App/App.xaml.cs index 8e77a1fd51d4..bba5d699a7a4 100644 --- a/src/Files.App/App.xaml.cs +++ b/src/Files.App/App.xaml.cs @@ -36,7 +36,6 @@ public static CommandBarFlyout? LastOpenedFlyout } // TODO: Replace with DI - public static QuickAccessManager QuickAccessManager { get; private set; } = null!; public static StorageHistoryWrapper HistoryWrapper { get; private set; } = null!; public static FileTagsManager FileTagsManager { get; private set; } = null!; public static LibraryManager LibraryManager { get; private set; } = null!; @@ -109,7 +108,6 @@ async Task ActivateAsync() } // TODO: Replace with DI - QuickAccessManager = Ioc.Default.GetRequiredService(); HistoryWrapper = Ioc.Default.GetRequiredService(); FileTagsManager = Ioc.Default.GetRequiredService(); LibraryManager = Ioc.Default.GetRequiredService(); diff --git a/src/Files.App/Data/Contexts/SideBar/SideBarContext.cs b/src/Files.App/Data/Contexts/SideBar/SideBarContext.cs index b8412d9f8e3a..0de48657d1a2 100644 --- a/src/Files.App/Data/Contexts/SideBar/SideBarContext.cs +++ b/src/Files.App/Data/Contexts/SideBar/SideBarContext.cs @@ -6,12 +6,7 @@ namespace Files.App.Data.Contexts /// internal sealed class SidebarContext : ObservableObject, ISidebarContext { - private readonly PinnedFoldersManager favoriteModel = App.QuickAccessManager.Model; - - private int PinnedFolderItemIndex => - IsItemRightClicked - ? favoriteModel.IndexOfItem(_RightClickedItem!) - : -1; + private readonly IQuickAccessService WindowsQuickAccessService = Ioc.Default.GetRequiredService(); private INavigationControlItem? _RightClickedItem = null; public INavigationControlItem? RightClickedItem => _RightClickedItem; @@ -22,7 +17,7 @@ internal sealed class SidebarContext : ObservableObject, ISidebarContext public bool IsPinnedFolderItem => IsItemRightClicked && _RightClickedItem!.Section is SectionType.Pinned && - PinnedFolderItemIndex is not -1; + WindowsQuickAccessService.IsPinned(_RightClickedItem.Path); public DriveItem? OpenDriveItem => _RightClickedItem as DriveItem; @@ -37,7 +32,6 @@ public void SidebarControl_RightClickedItemChanged(object? sender, INavigationCo if (SetProperty(ref _RightClickedItem, e, nameof(RightClickedItem))) { OnPropertyChanged(nameof(IsItemRightClicked)); - OnPropertyChanged(nameof(PinnedFolderItemIndex)); OnPropertyChanged(nameof(IsPinnedFolderItem)); OnPropertyChanged(nameof(OpenDriveItem)); } diff --git a/src/Files.App/Data/Contracts/IQuickAccessService.cs b/src/Files.App/Data/Contracts/IQuickAccessService.cs index 1608146c6e30..936641764651 100644 --- a/src/Files.App/Data/Contracts/IQuickAccessService.cs +++ b/src/Files.App/Data/Contracts/IQuickAccessService.cs @@ -1,56 +1,24 @@ // Copyright (c) 2024 Files Community // Licensed under the MIT License. See the LICENSE. +using System.Collections.Specialized; + namespace Files.App.Data.Contracts { public interface IQuickAccessService { - /// - /// Gets the list of quick access items - /// - /// - Task> GetPinnedFoldersAsync(); - - /// - /// Pins a folder to the quick access list - /// - /// The folder to pin - /// - Task PinToSidebarAsync(string folderPath); - - /// - /// Pins folders to the quick access list - /// - /// The array of folders to pin - /// - Task PinToSidebarAsync(string[] folderPaths); - - /// - /// Unpins a folder from the quick access list - /// - /// The folder to unpin - /// - Task UnpinFromSidebarAsync(string folderPath); - - /// - /// Unpins folders from the quick access list - /// - /// The array of folders to unpin - /// - Task UnpinFromSidebarAsync(string[] folderPaths); - - /// - /// Checks if a folder is pinned to the quick access list - /// - /// The path of the folder - /// true if the item is pinned - bool IsItemPinned(string folderPath); - - /// - /// Saves a state of pinned folder items in the sidebar - /// - /// The array of items to save - /// - Task SaveAsync(string[] items); + IReadOnlyList Folders { get; } + + event EventHandler? PinnedFoldersChanged; + + Task InitializeAsync(); + + Task UpdatePinnedFoldersAsync(); + + bool IsPinned(string path); + + Task PinFolderAsync(string[] paths); + + Task UnpinFolderAsync(string[] paths); } } diff --git a/src/Files.App/Data/Items/DriveItem.cs b/src/Files.App/Data/Items/DriveItem.cs index 12f1edf930b8..8b8733002b1b 100644 --- a/src/Files.App/Data/Items/DriveItem.cs +++ b/src/Files.App/Data/Items/DriveItem.cs @@ -13,6 +13,8 @@ namespace Files.App.Data.Items { public sealed class DriveItem : ObservableObject, INavigationControlItem, ILocatableFolder { + private readonly IQuickAccessService QuickAccessService = Ioc.Default.GetRequiredService(); + private BitmapImage icon; public BitmapImage Icon { @@ -48,7 +50,7 @@ public bool IsNetwork => Type == DriveType.Network; public bool IsPinned - => App.QuickAccessManager.Model.PinnedFolders.Contains(path); + => QuickAccessService.IsPinned(path); public string MaxSpaceText => MaxSpace.ToSizeString(); diff --git a/src/Files.App/Data/Items/ListedItem.cs b/src/Files.App/Data/Items/ListedItem.cs index 84d3e16fe4d1..300054f38ecf 100644 --- a/src/Files.App/Data/Items/ListedItem.cs +++ b/src/Files.App/Data/Items/ListedItem.cs @@ -25,6 +25,8 @@ public class ListedItem : ObservableObject, IGroupableItem protected static readonly IDateTimeFormatter dateTimeFormatter = Ioc.Default.GetRequiredService(); + protected readonly IQuickAccessService QuickAccessService = Ioc.Default.GetRequiredService(); + public bool IsHiddenItem { get; set; } = false; public StorageItemTypes PrimaryItemAttribute { get; set; } @@ -414,7 +416,7 @@ public override string ToString() public bool IsGitItem => this is GitItem; public virtual bool IsExecutable => !IsFolder && FileExtensionHelpers.IsExecutableFile(ItemPath); public virtual bool IsScriptFile => FileExtensionHelpers.IsScriptFile(ItemPath); - public bool IsPinned => App.QuickAccessManager.Model.PinnedFolders.Contains(itemPath); + public bool IsPinned => QuickAccessService.IsPinned(itemPath); public bool IsDriveRoot => ItemPath == PathNormalization.GetPathRoot(ItemPath); public bool IsElevationRequired { get; set; } diff --git a/src/Files.App/Data/Items/LocationItem.cs b/src/Files.App/Data/Items/LocationItem.cs index 5e0933c15561..0795b5e8112d 100644 --- a/src/Files.App/Data/Items/LocationItem.cs +++ b/src/Files.App/Data/Items/LocationItem.cs @@ -80,7 +80,7 @@ public bool IsExpanded public bool IsInvalid { get; set; } = false; - public bool IsPinned => App.QuickAccessManager.Model.PinnedFolders.Contains(path); + public bool IsPinned { get; set; } public SectionType Section { get; set; } diff --git a/src/Files.App/Data/Models/PinnedFoldersManager.cs b/src/Files.App/Data/Models/PinnedFoldersManager.cs deleted file mode 100644 index 18f30a866299..000000000000 --- a/src/Files.App/Data/Models/PinnedFoldersManager.cs +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) 2024 Files Community -// Licensed under the MIT License. See the LICENSE. - -using System.Collections.Specialized; -using System.IO; -using System.Text.Json.Serialization; - -namespace Files.App.Data.Models -{ - public sealed class PinnedFoldersManager - { - private IUserSettingsService userSettingsService { get; } = Ioc.Default.GetRequiredService(); - private IQuickAccessService QuickAccessService { get; } = Ioc.Default.GetRequiredService(); - - public EventHandler? DataChanged; - - private readonly SemaphoreSlim addSyncSemaphore = new(1, 1); - - public List PinnedFolders { get; set; } = []; - - public readonly List _PinnedFolderItems = []; - - [JsonIgnore] - public IReadOnlyList PinnedFolderItems - { - get - { - lock (_PinnedFolderItems) - return _PinnedFolderItems.ToList().AsReadOnly(); - } - } - - /// - /// Updates items with the pinned items from the explorer sidebar - /// - public async Task UpdateItemsWithExplorerAsync() - { - await addSyncSemaphore.WaitAsync(); - - try - { - var formerPinnedFolders = PinnedFolders.ToList(); - - PinnedFolders = (await QuickAccessService.GetPinnedFoldersAsync()) - .Where(link => (bool?)link.Properties["System.Home.IsPinned"] ?? false) - .Select(link => link.FilePath).ToList(); - - if (formerPinnedFolders.SequenceEqual(PinnedFolders)) - return; - - RemoveStaleSidebarItems(); - await AddAllItemsToSidebarAsync(); - } - finally - { - addSyncSemaphore.Release(); - } - } - - /// - /// Returns the index of the location item in the navigation sidebar - /// - /// The location item - /// Index of the item - public int IndexOfItem(INavigationControlItem locationItem) - { - lock (_PinnedFolderItems) - { - return _PinnedFolderItems.FindIndex(x => x.Path == locationItem.Path); - } - } - - public async Task CreateLocationItemFromPathAsync(string path) - { - var item = await FilesystemTasks.Wrap(() => DriveHelpers.GetRootFromPathAsync(path)); - var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path, item)); - LocationItem locationItem; - - if (string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) - locationItem = LocationItem.Create(); - else - { - locationItem = LocationItem.Create(); - - if (path.Equals(Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase)) - locationItem.Text = "ThisPC".GetLocalizedResource(); - else if (path.Equals(Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase)) - locationItem.Text = "Network".GetLocalizedResource(); - } - - locationItem.Path = path; - locationItem.Section = SectionType.Pinned; - locationItem.MenuOptions = new ContextMenuOptions - { - IsLocationItem = true, - ShowProperties = true, - ShowUnpinItem = true, - ShowShellItems = true, - ShowEmptyRecycleBin = string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) - }; - locationItem.IsDefaultLocation = false; - locationItem.Text = res?.Result?.DisplayName ?? Path.GetFileName(path.TrimEnd('\\')); - - if (res) - { - locationItem.IsInvalid = false; - if (res.Result is not null) - { - var result = await FileThumbnailHelper.GetIconAsync( - res.Result.Path, - Constants.ShellIconSizes.Small, - true, - IconOptions.ReturnIconOnly | IconOptions.UseCurrentScale); - - locationItem.IconData = result; - - var bitmapImage = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => locationItem.IconData.ToBitmapAsync(), Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); - if (bitmapImage is not null) - locationItem.Icon = bitmapImage; - } - } - else - { - locationItem.Icon = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => UIHelpers.GetSidebarIconResource(Constants.ImageRes.Folder)); - locationItem.IsInvalid = true; - Debug.WriteLine($"Pinned item was invalid {res?.ErrorCode}, item: {path}"); - } - - return locationItem; - } - - /// - /// Adds the item (from a path) to the navigation sidebar - /// - /// The path which to save - /// Task - public async Task AddItemToSidebarAsync(string path) - { - var locationItem = await CreateLocationItemFromPathAsync(path); - - AddLocationItemToSidebar(locationItem); - } - - /// - /// Adds the location item to the navigation sidebar - /// - /// The location item which to save - private void AddLocationItemToSidebar(LocationItem locationItem) - { - int insertIndex = -1; - lock (_PinnedFolderItems) - { - if (_PinnedFolderItems.Any(x => x.Path == locationItem.Path)) - return; - - var lastItem = _PinnedFolderItems.LastOrDefault(x => x.ItemType is NavigationControlItemType.Location); - insertIndex = lastItem is not null ? _PinnedFolderItems.IndexOf(lastItem) + 1 : 0; - _PinnedFolderItems.Insert(insertIndex, locationItem); - } - - DataChanged?.Invoke(SectionType.Pinned, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, locationItem, insertIndex)); - } - - /// - /// Adds all items to the navigation sidebar - /// - public async Task AddAllItemsToSidebarAsync() - { - if (userSettingsService.GeneralSettingsService.ShowPinnedSection) - foreach (string path in PinnedFolders) - await AddItemToSidebarAsync(path); - } - - /// - /// Removes stale items in the navigation sidebar - /// - public void RemoveStaleSidebarItems() - { - // Remove unpinned items from PinnedFolderItems - foreach (var childItem in PinnedFolderItems) - { - if (childItem is LocationItem item && !item.IsDefaultLocation && !PinnedFolders.Contains(item.Path)) - { - lock (_PinnedFolderItems) - { - _PinnedFolderItems.Remove(item); - } - DataChanged?.Invoke(SectionType.Pinned, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item)); - } - } - - // Remove unpinned items from sidebar - DataChanged?.Invoke(SectionType.Pinned, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - } - - public async void LoadAsync(object? sender, FileSystemEventArgs e) - { - await LoadAsync(); - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(null, new ModifyQuickAccessEventArgs((await QuickAccessService.GetPinnedFoldersAsync()).ToArray(), true) - { - Reset = true - }); - } - - public async Task LoadAsync() - { - await UpdateItemsWithExplorerAsync(); - } - } -} diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 10cfdaf9fa15..00fc06b46f04 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -61,17 +61,18 @@ public static async Task InitializeAppComponentsAsync() var addItemService = Ioc.Default.GetRequiredService(); var generalSettingsService = userSettingsService.GeneralSettingsService; var jumpListService = Ioc.Default.GetRequiredService(); + var windowsQuickAccessService = Ioc.Default.GetRequiredService(); // Start off a list of tasks we need to run before we can continue startup await Task.WhenAll( OptionalTaskAsync(CloudDrivesManager.UpdateDrivesAsync(), generalSettingsService.ShowCloudDrivesSection), App.LibraryManager.UpdateLibrariesAsync(), OptionalTaskAsync(WSLDistroManager.UpdateDrivesAsync(), generalSettingsService.ShowWslSection), - OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection), - App.QuickAccessManager.InitializeAsync() + OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection) ); await Task.WhenAll( + windowsQuickAccessService.InitializeAsync(), jumpListService.InitializeAsync(), addItemService.InitializeAsync(), ContextMenu.WarmUpQueryContextMenuAsync() @@ -210,7 +211,6 @@ public static IHost ConfigureHost() .AddSingleton() .AddSingleton() // Utilities - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Files.App/Services/Windows/WindowsJumpListService.cs b/src/Files.App/Services/Windows/WindowsJumpListService.cs index b842149578b2..c7915f350ab6 100644 --- a/src/Files.App/Services/Windows/WindowsJumpListService.cs +++ b/src/Files.App/Services/Windows/WindowsJumpListService.cs @@ -2,14 +2,16 @@ // Licensed under the MIT License. See the LICENSE. using Microsoft.Extensions.Logging; +using System.Collections.Specialized; using System.IO; -using Windows.Storage; using Windows.UI.StartScreen; namespace Files.App.Services { public sealed class WindowsJumpListService : IWindowsJumpListService { + private readonly IQuickAccessService WindowsQuickAccessService = Ioc.Default.GetRequiredService(); + private const string JumpListRecentGroupHeader = "ms-resource:///Resources/JumpListRecentGroupHeader"; private const string JumpListPinnedGroupHeader = "ms-resource:///Resources/JumpListPinnedGroupHeader"; @@ -17,8 +19,8 @@ public async Task InitializeAsync() { try { - App.QuickAccessManager.UpdateQuickAccessWidget -= UpdateQuickAccessWidget_Invoked; - App.QuickAccessManager.UpdateQuickAccessWidget += UpdateQuickAccessWidget_Invoked; + WindowsQuickAccessService.PinnedFoldersChanged -= QuickAccessService_CollectionChanged; + WindowsQuickAccessService.PinnedFoldersChanged += QuickAccessService_CollectionChanged; await RefreshPinnedFoldersAsync(); } @@ -35,10 +37,11 @@ public async Task AddFolderAsync(string path) if (JumpList.IsSupported()) { var instance = await JumpList.LoadCurrentAsync(); - // Disable automatic jumplist. It doesn't work. + + // Disable automatic jump-list. It doesn't work. instance.SystemGroupKind = JumpListSystemGroupKind.None; - // Saving to jumplist may fail randomly with error: ERROR_UNABLE_TO_REMOVE_REPLACED + // Saving to jump-list may fail randomly with error: ERROR_UNABLE_TO_REMOVE_REPLACED // In that case app should just catch the error and proceed as usual if (instance is not null) { @@ -60,7 +63,8 @@ public async Task> GetFoldersAsync() try { var instance = await JumpList.LoadCurrentAsync(); - // Disable automatic jumplist. It doesn't work. + + // Disable automatic jump-list. It doesn't work. instance.SystemGroupKind = JumpListSystemGroupKind.None; return instance.Items.Select(item => item.Arguments).ToList(); @@ -80,12 +84,11 @@ public async Task RefreshPinnedFoldersAsync() { try { - App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = false; - if (JumpList.IsSupported()) { var instance = await JumpList.LoadCurrentAsync(); - // Disable automatic jumplist. It doesn't work with Files UWP. + + // Disable automatic jump-list. It doesn't work with Files UWP. instance.SystemGroupKind = JumpListSystemGroupKind.None; if (instance is null) @@ -93,17 +96,12 @@ public async Task RefreshPinnedFoldersAsync() var itemsToRemove = instance.Items.Where(x => string.Equals(x.GroupName, JumpListPinnedGroupHeader, StringComparison.OrdinalIgnoreCase)).ToList(); itemsToRemove.ForEach(x => instance.Items.Remove(x)); - App.QuickAccessManager.Model.PinnedFolders.ForEach(x => AddFolder(x, JumpListPinnedGroupHeader, instance)); + WindowsQuickAccessService.Folders.ForEach(x => AddFolder(x.Path, JumpListPinnedGroupHeader, instance)); + await instance.SaveAsync(); } } - catch - { - } - finally - { - SafetyExtensions.IgnoreExceptions(() => App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = true); - } + catch { } } public async Task RemoveFolderAsync(string path) @@ -113,7 +111,8 @@ public async Task RemoveFolderAsync(string path) try { var instance = await JumpList.LoadCurrentAsync(); - // Disable automatic jumplist. It doesn't work. + + // Disable automatic jump-list. It doesn't work. instance.SystemGroupKind = JumpListSystemGroupKind.None; var itemToRemove = instance.Items.Where(x => x.Arguments == path).Select(x => x).FirstOrDefault(); @@ -137,7 +136,7 @@ private void AddFolder(string path, string group, JumpList instance) { var drivesViewModel = Ioc.Default.GetRequiredService(); - // Jumplist item argument can't end with a slash so append a character that can't exist in a directory name to support listing drives. + // Jump-list item argument can't end with a slash so append a character that can't exist in a directory name to support listing drives. var drive = drivesViewModel.Drives.FirstOrDefault(drive => drive.Path == path); if (drive is null) return; @@ -190,7 +189,7 @@ private void AddFolder(string path, string group, JumpList instance) } } - private async void UpdateQuickAccessWidget_Invoked(object? sender, ModifyQuickAccessEventArgs e) + private async void QuickAccessService_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { await RefreshPinnedFoldersAsync(); } diff --git a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs index cc870bb0058c..8935e2aae28b 100644 --- a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs +++ b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs @@ -1,107 +1,337 @@ // Copyright (c) 2024 Files Community // Licensed under the MIT License. See the LICENSE. -using Files.App.Utils.Shell; -using Files.App.UserControls.Widgets; +using System.Collections.Specialized; +using System.Text; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com.StructuredStorage; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.Shell.PropertiesSystem; +using Windows.Win32.UI.WindowsAndMessaging; namespace Files.App.Services { internal sealed class QuickAccessService : IQuickAccessService { - // Quick access shell folder (::{679f85cb-0220-4080-b29b-5540cc05aab6}) contains recent files - // which are unnecessary for getting pinned folders, so we use frequent places shell folder instead. - private readonly static string guid = "::{3936e9e4-d92c-4eee-a85a-bc16d5ea0819}"; + // Fields - public async Task> GetPinnedFoldersAsync() + private SystemIO.FileSystemWatcher? _watcher; + + // Properties + + private readonly List _QuickAccessFolders = []; + /// + public IReadOnlyList Folders { - var result = (await Win32Helper.GetShellFolderAsync(guid, false, true, 0, int.MaxValue, "System.Home.IsPinned")).Enumerate - .Where(link => link.IsFolder); - return result; + get + { + lock (_QuickAccessFolders) + return _QuickAccessFolders.ToList().AsReadOnly(); + } } - public Task PinToSidebarAsync(string folderPath) => PinToSidebarAsync(new[] { folderPath }); + /// + public event EventHandler? PinnedFoldersChanged; - public Task PinToSidebarAsync(string[] folderPaths) => PinToSidebarAsync(folderPaths, true); + public QuickAccessService() + { + } - private async Task PinToSidebarAsync(string[] folderPaths, bool doUpdateQuickAccessWidget) + public async Task InitializeAsync() { - foreach (string folderPath in folderPaths) - await ContextMenu.InvokeVerb("pintohome", [folderPath]); + _watcher = new() + { + Path = SystemIO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Recent), "AutomaticDestinations"), + Filter = "f01b4d95cf55d32a.automaticDestinations-ms", + NotifyFilter = SystemIO.NotifyFilters.DirectoryName | SystemIO.NotifyFilters.FileName | SystemIO.NotifyFilters.LastWrite, + }; + + _watcher.Changed += Watcher_Changed; + _watcher.Deleted += Watcher_Changed; + _watcher.EnableRaisingEvents = true; - await App.QuickAccessManager.Model.LoadAsync(); - if (doUpdateQuickAccessWidget) - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(folderPaths, true)); + // TODO: Add Recycle Bin to Quick Access } - public Task UnpinFromSidebarAsync(string folderPath) => UnpinFromSidebarAsync(new[] { folderPath }); + public async Task UpdatePinnedFoldersAsync() + { + return await Task.Run(async () => + { + try + { + List items = []; + foreach (var path in GetPinnedFolders()) + items.Add(await CreateItemOf(path)); - public Task UnpinFromSidebarAsync(string[] folderPaths) => UnpinFromSidebarAsync(folderPaths, true); + if (items.Count is 0) + return false; - private async Task UnpinFromSidebarAsync(string[] folderPaths, bool doUpdateQuickAccessWidget) - { - Type? shellAppType = Type.GetTypeFromProgID("Shell.Application"); - object? shell = Activator.CreateInstance(shellAppType); - dynamic? f2 = shellAppType.InvokeMember("NameSpace", System.Reflection.BindingFlags.InvokeMethod, null, shell, [$"shell:{guid}"]); + var snapshot = Folders; - if (folderPaths.Length == 0) - folderPaths = (await GetPinnedFoldersAsync()) - .Where(link => (bool?)link.Properties["System.Home.IsPinned"] ?? false) - .Select(link => link.FilePath).ToArray(); + lock (_QuickAccessFolders) + { + _QuickAccessFolders.Clear(); + _QuickAccessFolders.AddRange(items); + } + + var eventArgs = GetChangedActionEventArgs(snapshot, items); + PinnedFoldersChanged?.Invoke(this, eventArgs); - foreach (dynamic? fi in f2.Items()) + return true; + } + catch + { + return false; + } + }); + + unsafe List GetPinnedFolders() { - if (ShellStorageFolder.IsShellPath((string)fi.Path)) + HRESULT hr = default; + + // Get IShellItem of the shell folder + var IID_IShellItem = typeof(IShellItem).GUID; + using ComPtr pFolderShellItem = default; + fixed (char* pszFolderShellPath = "Shell:::{3936E9E4-D92C-4EEE-A85A-BC16D5EA0819}") + hr = PInvoke.SHCreateItemFromParsingName(pszFolderShellPath, null, &IID_IShellItem, (void**)pFolderShellItem.GetAddressOf()); + + // Get IEnumShellItems of the quick access shell folder + Guid BHID_EnumItems = PInvoke.BHID_EnumItems, IID_IEnumShellItems = typeof(IEnumShellItems).GUID; + using ComPtr pEnumShellItems = default; + hr = pFolderShellItem.Get()->BindToHandler(null, &BHID_EnumItems, &IID_IEnumShellItems, (void**)pEnumShellItems.GetAddressOf()); + + // Enumerate pinned folders + int index = 0; + List paths = []; + using ComPtr pShellItem = default; + while (pEnumShellItems.Get()->Next(1, pShellItem.GetAddressOf()) == HRESULT.S_OK) { - var folder = await ShellStorageFolder.FromPathAsync((string)fi.Path); - var path = folder?.Path; + // Get the full path + pShellItem.Get()->GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var szDisplayName); + var path = szDisplayName.ToString(); + PInvoke.CoTaskMemFree(szDisplayName.Value); - if (path is not null && - (folderPaths.Contains(path) || (path.StartsWith(@"\\SHELL\") && folderPaths.Any(x => x.StartsWith(@"\\SHELL\"))))) // Fix for the Linux header + paths.Add(path); + + index++; + } + + return paths; + } + + async Task CreateItemOf(string path) + { + var item = await FilesystemTasks.Wrap(() => DriveHelpers.GetRootFromPathAsync(path)); + var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path, item)); + LocationItem locationItem; + + if (string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + { + locationItem = LocationItem.Create(); + } + else + { + locationItem = LocationItem.Create(); + + if (path.Equals(Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase)) + locationItem.Text = "ThisPC".GetLocalizedResource(); + else if (path.Equals(Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase)) + locationItem.Text = "Network".GetLocalizedResource(); + } + + locationItem.Path = path; + locationItem.IsPinned = IsPinned(path); + locationItem.Section = SectionType.Pinned; + locationItem.MenuOptions = new() + { + IsLocationItem = true, + ShowProperties = true, + ShowUnpinItem = true, + ShowShellItems = true, + ShowEmptyRecycleBin = string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) + }; + locationItem.IsDefaultLocation = false; + locationItem.Text = res?.Result?.DisplayName ?? SystemIO.Path.GetFileName(path.TrimEnd('\\')); + + if (res) + { + locationItem.IsInvalid = false; + if (res.Result is not null) { - await SafetyExtensions.IgnoreExceptions(async () => - { - await fi.InvokeVerb("unpinfromhome"); - }); - continue; + var result = await FileThumbnailHelper.GetIconAsync( + res.Result.Path, + Constants.ShellIconSizes.Small, + true, + IconOptions.ReturnIconOnly | IconOptions.UseCurrentScale); + + locationItem.IconData = result; + + var bitmapImage = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => locationItem.IconData.ToBitmapAsync(), Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + if (bitmapImage is not null) + locationItem.Icon = bitmapImage; } } + else + { + locationItem.Icon = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => UIHelpers.GetSidebarIconResource(Constants.ImageRes.Folder)); + locationItem.IsInvalid = true; + Debug.WriteLine($"Pinned item was invalid {res?.ErrorCode}, item: {path}"); + } - if (folderPaths.Contains((string)fi.Path)) + return locationItem; + } + + NotifyCollectionChangedEventArgs GetChangedActionEventArgs(IReadOnlyList oldItems, IList newItems) + { + if (newItems.Count - oldItems.Count is 1) + { + var differences = newItems.Except(oldItems); + if (differences.Take(2).Count() is 1) + return new(NotifyCollectionChangedAction.Add, newItems.First()); + } + else if (oldItems.Count - newItems.Count is 1) { - await SafetyExtensions.IgnoreExceptions(async () => + var differences = oldItems.Except(newItems); + if (differences.Take(2).Count() is 1) { - await fi.InvokeVerb("unpinfromhome"); - }); + for (int i = 0; i < oldItems.Count; i++) + { + if (i >= newItems.Count || !newItems[i].Equals(oldItems[i])) + return new(NotifyCollectionChangedAction.Remove, oldItems[i], index: i); + } + } + } + else if (newItems.Count == oldItems.Count) + { + var differences = oldItems.Except(newItems); + if (differences.Any()) + return new(NotifyCollectionChangedAction.Reset); + + // First diff from reversed is the designated item + for (int i = oldItems.Count - 1; i >= 0; i--) + { + if (!oldItems[i].Equals(newItems[i])) + return new(NotifyCollectionChangedAction.Move, oldItems[i], index: 0, oldIndex: i); + } } - } - await App.QuickAccessManager.Model.LoadAsync(); - if (doUpdateQuickAccessWidget) - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(folderPaths, false)); + return new(NotifyCollectionChangedAction.Reset); + } } - public bool IsItemPinned(string folderPath) + public unsafe bool IsPinned(string path) { - return App.QuickAccessManager.Model.PinnedFolders.Contains(folderPath); + HRESULT hr = default; + var IID_IShellItem = typeof(IShellItem).GUID; + using ComPtr pShellItem = default; + fixed (char* pszPath = path) + hr = PInvoke.SHCreateItemFromParsingName(pszPath, null, &IID_IShellItem, (void**)pShellItem.GetAddressOf()); + + using ComPtr pShellItem2 = pShellItem.As(); + var IID_IPropertyStore = typeof(IPropertyStore).GUID; + using ComPtr pPropertyStore = default; + hr = pShellItem2.Get()->GetPropertyStore(GETPROPERTYSTOREFLAGS.GPS_DEFAULT, &IID_IPropertyStore, (void**)pPropertyStore.GetAddressOf()); + hr = PInvoke.PSGetPropertyKeyFromName("System.Home.IsPinned", out var propertyKey); + hr = pPropertyStore.Get()->GetValue(propertyKey, out var propertyValue); + return (bool)propertyValue.Anonymous.Anonymous.Anonymous.boolVal; } - public async Task SaveAsync(string[] items) + public async Task PinFolderAsync(string[] paths) { - if (Equals(items, App.QuickAccessManager.Model.PinnedFolders.ToArray())) - return; + return await Task.Run(() => + { + foreach (var path in paths) + { + if (!PinFolder(path)) + return false; + } + + return true; + }); + + unsafe bool PinFolder(string path) + { + HRESULT hr = default; + + // Get IShellItem of the shell folder + var shellItemIid = typeof(IShellItem).GUID; + using ComPtr pShellItem = default; + fixed (char* pszFolderShellPath = path) + hr = PInvoke.SHCreateItemFromParsingName(pszFolderShellPath, null, &shellItemIid, (void**)pShellItem.GetAddressOf()); - App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = false; + var bhid = PInvoke.BHID_SFUIObject; + var contextMenuIid = typeof(IContextMenu).GUID; + using ComPtr pContextMenu = default; + hr = pShellItem.Get()->BindToHandler(null, &bhid, &contextMenuIid, (void**)pContextMenu.GetAddressOf()); + HMENU hMenu = PInvoke.CreatePopupMenu(); + hr = pContextMenu.Get()->QueryContextMenu(hMenu, 0, 1, 0x7FFF, PInvoke.CMF_OPTIMIZEFORINVOKE); - // Unpin every item that is below this index and then pin them all in order - await UnpinFromSidebarAsync([], false); + CMINVOKECOMMANDINFO cmi = default; + cmi.cbSize = (uint)sizeof(CMINVOKECOMMANDINFO); + cmi.nShow = (int)SHOW_WINDOW_CMD.SW_HIDE; - await PinToSidebarAsync(items, false); - App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = true; + fixed (byte* pVerb = Encoding.ASCII.GetBytes("pintohome")) + { + cmi.lpVerb = new(pVerb); + hr = pContextMenu.Get()->InvokeCommand(cmi); + if (hr != HRESULT.S_OK) + return false; + } + + return true; + } + } - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(items, true) + public async Task UnpinFolderAsync(string[] paths) + { + return await Task.Run(() => { - Reorder = true + foreach (var path in paths) + { + if (!UnpinFolder(path)) + return false; + } + + return true; }); + + unsafe bool UnpinFolder(string path) + { + HRESULT hr = default; + + // Get IShellItem of the shell folder + var shellItemIid = typeof(IShellItem).GUID; + using ComPtr pShellItem = default; + fixed (char* pszFolderShellPath = path) + hr = PInvoke.SHCreateItemFromParsingName(pszFolderShellPath, null, &shellItemIid, (void**)pShellItem.GetAddressOf()); + + var bhid = PInvoke.BHID_SFUIObject; + var contextMenuIid = typeof(IContextMenu).GUID; + using ComPtr pContextMenu = default; + hr = pShellItem.Get()->BindToHandler(null, &bhid, &contextMenuIid, (void**)pContextMenu.GetAddressOf()); + HMENU hMenu = PInvoke.CreatePopupMenu(); + hr = pContextMenu.Get()->QueryContextMenu(hMenu, 0, 1, 0x7FFF, PInvoke.CMF_OPTIMIZEFORINVOKE); + + CMINVOKECOMMANDINFO cmi = default; + cmi.cbSize = (uint)sizeof(CMINVOKECOMMANDINFO); + cmi.nShow = (int)SHOW_WINDOW_CMD.SW_HIDE; + + fixed (byte* pVerb = Encoding.ASCII.GetBytes("unpinfromhome")) + { + cmi.lpVerb = new(pVerb); + hr = pContextMenu.Get()->InvokeCommand(cmi); + if (hr != HRESULT.S_OK) + return false; + } + + return true; + } + } + + private void Watcher_Changed(object sender, SystemIO.FileSystemEventArgs e) + { + _ = UpdatePinnedFoldersAsync(); } } } diff --git a/src/Files.App/Utils/Global/QuickAccessManager.cs b/src/Files.App/Utils/Global/QuickAccessManager.cs deleted file mode 100644 index deeabca3424f..000000000000 --- a/src/Files.App/Utils/Global/QuickAccessManager.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2024 Files Community -// Licensed under the MIT License. See the LICENSE. - -using CommunityToolkit.WinUI.Helpers; -using System.IO; - -namespace Files.App.Utils -{ - public sealed class QuickAccessManager - { - public FileSystemWatcher? PinnedItemsWatcher; - - public event FileSystemEventHandler? PinnedItemsModified; - - public EventHandler? UpdateQuickAccessWidget; - - public IQuickAccessService QuickAccessService; - - public PinnedFoldersManager Model; - public QuickAccessManager() - { - QuickAccessService = Ioc.Default.GetRequiredService(); - Model = new(); - Initialize(); - } - - public void Initialize() - { - PinnedItemsWatcher = new() - { - Path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Recent", "AutomaticDestinations"), - Filter = "f01b4d95cf55d32a.automaticDestinations-ms", - NotifyFilter = NotifyFilters.LastAccess | NotifyFilters.LastWrite | NotifyFilters.FileName - }; - - PinnedItemsWatcher.Changed += PinnedItemsWatcher_Changed; - } - - private void PinnedItemsWatcher_Changed(object sender, FileSystemEventArgs e) - => PinnedItemsModified?.Invoke(this, e); - - public async Task InitializeAsync() - { - PinnedItemsModified += Model.LoadAsync; - - if (!Model.PinnedFolders.Contains(Constants.UserEnvironmentPaths.RecycleBinPath) && SystemInformation.Instance.IsFirstRun) - await QuickAccessService.PinToSidebarAsync(Constants.UserEnvironmentPaths.RecycleBinPath); - - await Model.LoadAsync(); - } - } -} diff --git a/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs index d8df0b682af3..045539a9190f 100644 --- a/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs @@ -7,24 +7,26 @@ namespace Files.App.ViewModels.Dialogs { public sealed class ReorderSidebarItemsDialogViewModel : ObservableObject { - private readonly IQuickAccessService quickAccessService = Ioc.Default.GetRequiredService(); + private readonly IQuickAccessService QuickAccessService = Ioc.Default.GetRequiredService(); public string HeaderText = "ReorderSidebarItemsDialogText".GetLocalizedResource(); - public ICommand PrimaryButtonCommand { get; private set; } + public ObservableCollection SidebarPinnedFolderItems { get; } - public ObservableCollection SidebarPinnedFolderItems = new(App.QuickAccessManager.Model._PinnedFolderItems - .Where(x => x is LocationItem loc && loc.Section is SectionType.Pinned && !loc.IsHeader) - .Cast()); + public ICommand PrimaryButtonCommand { get; private set; } public ReorderSidebarItemsDialogViewModel() { - //App.Logger.LogWarning(string.Join(", ", SidebarPinnedFolderItems.Select(x => x.Path))); PrimaryButtonCommand = new RelayCommand(SaveChanges); + + SidebarPinnedFolderItems = new(QuickAccessService.Folders + .Where(x => x is LocationItem loc && loc.Section is SectionType.Pinned && !loc.IsHeader) + .Cast()); } public void SaveChanges() { - quickAccessService.SaveAsync(SidebarPinnedFolderItems.Select(x => x.Path).ToArray()); + // TODO: Fire the reset event + //QuickAccessService.SaveAsync(SidebarPinnedFolderItems.Select(x => x.Path).ToArray()); } } } diff --git a/src/Files.App/ViewModels/MainPageViewModel.cs b/src/Files.App/ViewModels/MainPageViewModel.cs index 3953ed7ce984..0c9803de9ad9 100644 --- a/src/Files.App/ViewModels/MainPageViewModel.cs +++ b/src/Files.App/ViewModels/MainPageViewModel.cs @@ -24,6 +24,7 @@ public sealed class MainPageViewModel : ObservableObject private IUserSettingsService UserSettingsService { get; } = Ioc.Default.GetRequiredService(); private IResourcesService ResourcesService { get; } = Ioc.Default.GetRequiredService(); private DrivesViewModel DrivesViewModel { get; } = Ioc.Default.GetRequiredService(); + private IQuickAccessService WindowsQuickAccessService = Ioc.Default.GetRequiredService(); // Properties @@ -244,7 +245,8 @@ UserSettingsService.GeneralSettingsService.LastSessionTabList is not null && await Task.WhenAll( DrivesViewModel.UpdateDrivesAsync(), NetworkService.UpdateComputersAsync(), - NetworkService.UpdateShortcutsAsync()); + NetworkService.UpdateShortcutsAsync(), + WindowsQuickAccessService.UpdatePinnedFoldersAsync()); } // Command methods diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index e6f76374ad94..f81d4156c218 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -26,6 +26,7 @@ public sealed class SidebarViewModel : ObservableObject, IDisposable, ISidebarVi private ICommandManager Commands { get; } = Ioc.Default.GetRequiredService(); private readonly DrivesViewModel drivesViewModel = Ioc.Default.GetRequiredService(); private readonly IFileTagsService fileTagsService; + private readonly IQuickAccessService WindowsQuickAccessService = Ioc.Default.GetRequiredService(); private IShellPanesPage paneHolder; public IShellPanesPage PaneHolder @@ -44,8 +45,6 @@ public IFilesystemHelpers FilesystemHelpers public object SidebarItems => sidebarItems; public BulkConcurrentObservableCollection sidebarItems { get; init; } - public PinnedFoldersManager SidebarPinnedModel => App.QuickAccessManager.Model; - public IQuickAccessService QuickAccessService { get; } = Ioc.Default.GetRequiredService(); private SidebarDisplayMode sidebarDisplayMode; public SidebarDisplayMode SidebarDisplayMode @@ -242,7 +241,7 @@ public SidebarViewModel() Manager_DataChanged(SectionType.WSL, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); Manager_DataChanged(SectionType.FileTag, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); - App.QuickAccessManager.Model.DataChanged += Manager_DataChanged; + WindowsQuickAccessService.PinnedFoldersChanged += (s, e) => Manager_DataChanged(SectionType.Pinned, e); App.LibraryManager.DataChanged += Manager_DataChanged; drivesViewModel.Drives.CollectionChanged += Manager_DataChangedForDrives; CloudDrivesManager.DataChanged += Manager_DataChanged; @@ -272,7 +271,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(async () => var section = await GetOrCreateSectionAsync(sectionType); Func> getElements = () => sectionType switch { - SectionType.Pinned => App.QuickAccessManager.Model.PinnedFolderItems, + SectionType.Pinned => WindowsQuickAccessService.Folders, SectionType.CloudDrives => CloudDrivesManager.Drives, SectionType.Drives => drivesViewModel.Drives.Cast().ToList().AsReadOnly(), SectionType.Network => NetworkService.Computers.Cast().ToList().AsReadOnly(), @@ -607,7 +606,7 @@ public async Task UpdateSectionVisibilityAsync(SectionType sectionType, bool sho SectionType.WSL when generalSettingsService.ShowWslSection => WSLDistroManager.UpdateDrivesAsync, SectionType.FileTag when generalSettingsService.ShowFileTagsSection => App.FileTagsManager.UpdateFileTagsAsync, SectionType.Library => App.LibraryManager.UpdateLibrariesAsync, - SectionType.Pinned => App.QuickAccessManager.Model.AddAllItemsToSidebarAsync, + SectionType.Pinned => WindowsQuickAccessService.UpdatePinnedFoldersAsync, _ => () => Task.CompletedTask }; @@ -666,7 +665,7 @@ public void Dispose() { UserSettingsService.OnSettingChangedEvent -= UserSettingsService_OnSettingChangedEvent; - App.QuickAccessManager.Model.DataChanged -= Manager_DataChanged; + WindowsQuickAccessService.PinnedFoldersChanged -= Manager_DataChanged; App.LibraryManager.DataChanged -= Manager_DataChanged; drivesViewModel.Drives.CollectionChanged -= Manager_DataChangedForDrives; CloudDrivesManager.DataChanged -= Manager_DataChanged; @@ -836,12 +835,12 @@ public async void HandleItemInvokedAsync(object item, PointerUpdateKind pointerU private void PinItem() { if (rightClickedItem is DriveItem) - _ = QuickAccessService.PinToSidebarAsync(new[] { rightClickedItem.Path }); + _ = WindowsQuickAccessService.PinFolderAsync([rightClickedItem.Path]); } private void UnpinItem() { if (rightClickedItem.Section == SectionType.Pinned || rightClickedItem is DriveItem) - _ = QuickAccessService.UnpinFromSidebarAsync(rightClickedItem.Path); + _ = WindowsQuickAccessService.UnpinFolderAsync([rightClickedItem.Path]); } private void HideSection() @@ -924,9 +923,9 @@ private List GetLocationItemMenuItems(INavigatio { var options = item.MenuOptions; - var pinnedFolderModel = App.QuickAccessManager.Model; - var pinnedFolderIndex = pinnedFolderModel.IndexOfItem(item); - var pinnedFolderCount = pinnedFolderModel.PinnedFolders.Count; + var pinnedFolders = WindowsQuickAccessService.Folders.ToList(); + var pinnedFolderIndex = pinnedFolders.IndexOf(item); + var pinnedFolderCount = pinnedFolders.Count; var isPinnedItem = item.Section is SectionType.Pinned && pinnedFolderIndex is not -1; var showMoveItemUp = isPinnedItem && pinnedFolderIndex > 0; @@ -1083,7 +1082,7 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It if (isPathNull && hasStorageItems && SectionType.Pinned.Equals(locationItem.Section)) { - var haveFoldersToPin = storageItems.Any(item => item.ItemType == FilesystemItemType.Directory && !SidebarPinnedModel.PinnedFolders.Contains(item.Path)); + var haveFoldersToPin = storageItems.Any(item => item.ItemType == FilesystemItemType.Directory && WindowsQuickAccessService.Folders.FirstOrDefault(x => x.Path == item.Path) is null); if (!haveFoldersToPin) { @@ -1253,8 +1252,8 @@ private async Task HandleLocationItemDroppedAsync(LocationItem locationItem, Ite var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem); foreach (var item in storageItems) { - if (item.ItemType == FilesystemItemType.Directory && !SidebarPinnedModel.PinnedFolders.Contains(item.Path)) - await QuickAccessService.PinToSidebarAsync(item.Path); + if (item.ItemType == FilesystemItemType.Directory && WindowsQuickAccessService.Folders.FirstOrDefault(x => x.Path == item.Path) is null) + await WindowsQuickAccessService.PinFolderAsync([item.Path]); } } else diff --git a/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs index 80a4176834f2..7d20f6af2ae1 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs @@ -76,7 +76,7 @@ widgetCardItem.DataContext is not WidgetCardItem item || OnRightClickedItemChanged(item, itemContextMenuFlyout); // Get items for the flyout - var menuItems = GetItemMenuItems(item, QuickAccessService.IsItemPinned(item.Path), fileTagsCardItem is not null && fileTagsCardItem.IsFolder); + var menuItems = GetItemMenuItems(item, QuickAccessService.Folders.ToList().FirstOrDefault(x => x.Path == item.Path) is not null, fileTagsCardItem is not null && fileTagsCardItem.IsFolder); var (_, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems); // Set max width of the flyout @@ -100,12 +100,12 @@ widgetCardItem.DataContext is not WidgetCardItem item || public virtual async Task ExecutePinToSidebarCommand(WidgetCardItem? item) { - await QuickAccessService.PinToSidebarAsync(item?.Path ?? string.Empty); + await QuickAccessService.PinFolderAsync([item?.Path ?? string.Empty]); } public virtual async Task ExecuteUnpinFromSidebarCommand(WidgetCardItem? item) { - await QuickAccessService.UnpinFromSidebarAsync(item?.Path ?? string.Empty); + await QuickAccessService.UnpinFolderAsync([item?.Path ?? string.Empty]); } protected void OnRightClickedItemChanged(WidgetCardItem? item, CommandBarFlyout? flyout) diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index 86114c5e72ff..92a4bb281ded 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -1,6 +1,7 @@ // Copyright (c) 2024 Files Community // Licensed under the MIT License. See the LICENSE. +using Microsoft.Extensions.Logging; using Microsoft.UI.Input; using Microsoft.UI.Xaml.Controls; using System.Collections.Specialized; @@ -17,6 +18,11 @@ namespace Files.App.ViewModels.UserControls.Widgets /// public sealed class QuickAccessWidgetViewModel : BaseWidgetViewModel, IWidgetViewModel { + // Fields + + private readonly SemaphoreSlim _refreshSemaphore; + private CancellationTokenSource _refreshCTS; + // Properties public ObservableCollection Items { get; } = []; @@ -32,9 +38,12 @@ public sealed class QuickAccessWidgetViewModel : BaseWidgetViewModel, IWidgetVie public QuickAccessWidgetViewModel() { - _ = InitializeWidget(); + _refreshSemaphore = new SemaphoreSlim(1, 1); + _refreshCTS = new CancellationTokenSource(); + + _ = RefreshWidgetAsync(); - Items.CollectionChanged += Items_CollectionChanged; + QuickAccessService.PinnedFoldersChanged += QuickAccessService_CollectionChanged; OpenPropertiesCommand = new RelayCommand(ExecuteOpenPropertiesCommand); PinToSidebarCommand = new AsyncRelayCommand(ExecutePinToSidebarCommand); @@ -43,17 +52,9 @@ public QuickAccessWidgetViewModel() // Methods - private async Task InitializeWidget() - { - var itemsToAdd = await QuickAccessService.GetPinnedFoldersAsync(); - ModifyItemAsync(this, new(itemsToAdd.ToArray(), false) { Reset = true }); - - App.QuickAccessManager.UpdateQuickAccessWidget += ModifyItemAsync; - } - public Task RefreshWidgetAsync() { - return Task.CompletedTask; + return QuickAccessService.UpdatePinnedFoldersAsync(); } public override List GetItemMenuItems(WidgetCardItem item, bool isPinned, bool isFolder = false) @@ -124,84 +125,89 @@ public override List GetItemMenuItems(WidgetCard }.Where(x => x.ShowItem).ToList(); } - private async void ModifyItemAsync(object? sender, ModifyQuickAccessEventArgs? e) + private async Task UpdateCollectionAsync(NotifyCollectionChangedEventArgs e) { - if (e is null) + try + { + await _refreshSemaphore.WaitAsync(_refreshCTS.Token); + } + catch (OperationCanceledException) + { return; + } - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => + try { - if (e.Reset) - { - // Find the intersection between the two lists and determine whether to remove or add - var originalItems = Items.ToList(); - var itemsToRemove = originalItems.Where(x => !e.Paths.Contains(x.Path)); - var itemsToAdd = e.Paths.Where(x => !originalItems.Any(y => y.Path == x)); - - // Remove items - foreach (var itemToRemove in itemsToRemove) - Items.Remove(itemToRemove); + // Drop other waiting instances + _refreshCTS.Cancel(); + _refreshCTS = new CancellationTokenSource(); - // Add items - foreach (var itemToAdd in itemsToAdd) - { - var interimItems = Items.ToList(); - var item = await App.QuickAccessManager.Model.CreateLocationItemFromPathAsync(itemToAdd); - var lastIndex = Items.IndexOf(interimItems.FirstOrDefault(x => !x.IsPinned)); - var isPinned = (bool?)e.Items.Where(x => x.FilePath == itemToAdd).FirstOrDefault()?.Properties["System.Home.IsPinned"] ?? false; - if (interimItems.Any(x => x.Path == itemToAdd)) - continue; - - Items.Insert(isPinned && lastIndex >= 0 ? Math.Min(lastIndex, Items.Count) : Items.Count, new WidgetFolderCardItem(item, Path.GetFileName(item.Text), isPinned) + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: { - Path = item.Path, - }); - } - - return; + if (e.NewItems is not null) + { + var item = e.NewItems.Cast().Single(); + var cardItem = new WidgetFolderCardItem(item, SystemIO.Path.GetFileName(item.Text), item.IsPinned) { Path = item.Path }; + AddItemToCollection(cardItem); + } + } + break; + case NotifyCollectionChangedAction.Move: + { + if (e.OldItems is not null) + { + Items.RemoveAt(e.OldStartingIndex); + + var item = e.NewItems.Cast().Single(); + var cardItem = new WidgetFolderCardItem(item, SystemIO.Path.GetFileName(item.Text), item.IsPinned) { Path = item.Path }; + AddItemToCollection(cardItem); + } + } + break; + case NotifyCollectionChangedAction.Remove: + { + if (e.OldItems is not null) + Items.RemoveAt(e.OldStartingIndex); + } + break; + // case NotifyCollectionChangedAction.Reset: + default: + { + Items.Clear(); + foreach (var item in QuickAccessService.Folders.ToList()) + { + if (item is not LocationItem locationItem) + continue; + + var cardItem = new WidgetFolderCardItem(locationItem, SystemIO.Path.GetFileName(locationItem.Text), locationItem.IsPinned) { Path = locationItem.Path }; + AddItemToCollection(cardItem); + } + } + break; } - if (e.Reorder) - { - // Remove pinned items - foreach (var itemToRemove in Items.ToList().Where(x => x.IsPinned)) - Items.Remove(itemToRemove); + } + catch (Exception ex) + { + App.Logger.LogInformation(ex, "Could not populate pinned folders."); + } + finally + { + _refreshSemaphore.Release(); + } - // Add pinned items in the new order - foreach (var itemToAdd in e.Paths) - { - var interimItems = Items.ToList(); - var item = await App.QuickAccessManager.Model.CreateLocationItemFromPathAsync(itemToAdd); - var lastIndex = Items.IndexOf(interimItems.FirstOrDefault(x => !x.IsPinned)); - if (interimItems.Any(x => x.Path == itemToAdd)) - continue; + bool AddItemToCollection(WidgetFolderCardItem? item, int index = -1) + { + if (item is null || Items.Any(x => x.Equals(item))) + return false; - Items.Insert(lastIndex >= 0 ? Math.Min(lastIndex, Items.Count) : Items.Count, new WidgetFolderCardItem(item, Path.GetFileName(item.Text), true) - { - Path = item.Path, - }); - } + Items.Insert(index < 0 ? Items.Count : Math.Min(index, Items.Count), item); + _ = item.LoadCardThumbnailAsync() + .ContinueWith(t => App.Logger.LogWarning(t.Exception, null), TaskContinuationOptions.OnlyOnFaulted); - return; - } - if (e.Add) - { - foreach (var itemToAdd in e.Paths) - { - var interimItems = Items.ToList(); - var item = await App.QuickAccessManager.Model.CreateLocationItemFromPathAsync(itemToAdd); - var lastIndex = Items.IndexOf(interimItems.FirstOrDefault(x => !x.IsPinned)); - if (interimItems.Any(x => x.Path == itemToAdd)) - continue; - Items.Insert(e.Pin && lastIndex >= 0 ? Math.Min(lastIndex, Items.Count) : Items.Count, new WidgetFolderCardItem(item, Path.GetFileName(item.Text), e.Pin) // Add just after the Recent Folders - { - Path = item.Path, - }); - } - } - else - foreach (var itemToRemove in Items.ToList().Where(x => e.Paths.Contains(x.Path))) - Items.Remove(itemToRemove); - }); + return true; + } } public async Task NavigateToPath(string path) @@ -220,13 +226,12 @@ public async Task NavigateToPath(string path) // Event methods - private async void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + private async void QuickAccessService_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (e.Action is NotifyCollectionChangedAction.Add) + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { - foreach (WidgetFolderCardItem cardItem in e.NewItems!) - await cardItem.LoadCardThumbnailAsync(); - } + await UpdateCollectionAsync(e); + }); } // Command methods @@ -236,18 +241,7 @@ public override async Task ExecutePinToSidebarCommand(WidgetCardItem? item) if (item is null || item.Path is null) return; - await QuickAccessService.PinToSidebarAsync(item.Path); - - ModifyItemAsync(this, new(new[] { item.Path }, false)); - - var items = (await QuickAccessService.GetPinnedFoldersAsync()) - .Where(link => !((bool?)link.Properties["System.Home.IsPinned"] ?? false)); - - var recentItem = items.Where(x => !Items.ToList().Select(y => y.Path).Contains(x.FilePath)).FirstOrDefault(); - if (recentItem is not null) - { - ModifyItemAsync(this, new(new[] { recentItem.FilePath }, true) { Pin = false }); - } + await QuickAccessService.PinFolderAsync([item.Path]); } public override async Task ExecuteUnpinFromSidebarCommand(WidgetCardItem? item) @@ -255,9 +249,7 @@ public override async Task ExecuteUnpinFromSidebarCommand(WidgetCardItem? item) if (item is null || item.Path is null) return; - await QuickAccessService.UnpinFromSidebarAsync(item.Path); - - ModifyItemAsync(this, new(new[] { item.Path }, false)); + await QuickAccessService.UnpinFolderAsync([item.Path]); } private void ExecuteOpenPropertiesCommand(WidgetFolderCardItem? item) @@ -300,7 +292,7 @@ private void ExecuteOpenPropertiesCommand(WidgetFolderCardItem? item) public void Dispose() { - App.QuickAccessManager.UpdateQuickAccessWidget -= ModifyItemAsync; + QuickAccessService.PinnedFoldersChanged -= QuickAccessService_CollectionChanged; } } } diff --git a/src/Files.App/Views/Layouts/ColumnsLayoutPage.xaml.cs b/src/Files.App/Views/Layouts/ColumnsLayoutPage.xaml.cs index 9bbf70e47c57..a3b7305c2ae9 100644 --- a/src/Files.App/Views/Layouts/ColumnsLayoutPage.xaml.cs +++ b/src/Files.App/Views/Layouts/ColumnsLayoutPage.xaml.cs @@ -17,6 +17,10 @@ namespace Files.App.Views.Layouts /// public sealed partial class ColumnsLayoutPage : BaseLayoutPage { + // IOC + + private readonly IQuickAccessService WindowsQuickAccessService = Ioc.Default.GetRequiredService(); + // Properties protected override ItemsControl ItemsControl => ColumnHost; @@ -96,7 +100,7 @@ protected override void OnNavigatedTo(NavigationEventArgs eventArgs) if (!string.IsNullOrEmpty(pathRoot)) { - var rootPathList = App.QuickAccessManager.Model.PinnedFolders.Select(NormalizePath) + var rootPathList = WindowsQuickAccessService.Folders.Select(x => NormalizePath(x.Path)) .Concat(CloudDrivesManager.Drives.Select(x => NormalizePath(x.Path))).ToList() .Concat(App.LibraryManager.Libraries.Select(x => NormalizePath(x.Path))).ToList(); rootPathList.Add(NormalizePath(pathRoot));