diff --git a/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs b/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs index eedd91e8f4d1..da5facd8a33a 100644 --- a/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs +++ b/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs @@ -30,5 +30,10 @@ public interface ISidebarItemModel : INotifyPropertyChanged /// Expansion participant that keeps the regular row appearance (icon + normal text) instead of the section-header style. /// bool IsLeafWithChildren => false; + + /// + /// Indicates whether the item supports reorder dropping. + /// + bool IsReorderDropItem => false; } } diff --git a/src/Files.App.Controls/Sidebar/SidebarItem.cs b/src/Files.App.Controls/Sidebar/SidebarItem.cs index 8047481800ac..9ce37887a282 100644 --- a/src/Files.App.Controls/Sidebar/SidebarItem.cs +++ b/src/Files.App.Controls/Sidebar/SidebarItem.cs @@ -4,10 +4,15 @@ using CommunityToolkit.WinUI; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Automation.Peers; +using System.Collections; using System.Collections.Specialized; using System.IO; +using System.Runtime.InteropServices; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; @@ -31,6 +36,7 @@ public sealed partial class SidebarItem : Control private bool isTemplateWired; private DispatcherQueueTimer? dragOverTimer; private DispatcherQueueTimer? dragOverExpandTimer; + private SidebarItemDropPosition lastDropPosition = SidebarItemDropPosition.Center; public SidebarItem() { @@ -126,7 +132,17 @@ public void HandleItemChange() HookupItemChangeListener(null, Item); UpdateExpansionState(); ReevaluateSelection(); - CanDrag = Item?.Path is string path && Path.IsPathRooted(path); + + if (Item is not null) + { + CanDrag = IsValidDropPath(Item.Path); + UseReorderDrop = !IsGroupHeader && CanDrag && Item.IsReorderDropItem; + } + else + { + CanDrag = false; + UseReorderDrop = false; + } } private void HookupOwners() @@ -190,32 +206,36 @@ private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) } } + private static bool IsValidDropPath(string? path) + => path is not null && (System.IO.Path.IsPathRooted(path) || path.StartsWith("Shell:", StringComparison.OrdinalIgnoreCase)); + private void SidebarItem_DragStarting(UIElement sender, DragStartingEventArgs args) { - if (Item?.Path is not string dragPath || !Path.IsPathRooted(dragPath)) + if (Item?.Path is not string dragPath || !IsValidDropPath(dragPath)) return; - args.Data.SetData(StandardDataFormats.Text, dragPath); - args.Data.RequestedOperation = DataPackageOperation.Move | DataPackageOperation.Copy | DataPackageOperation.Link; - args.Data.SetDataProvider(StandardDataFormats.StorageItems, async request => + SafetyExtensions.IgnoreExceptions(() => { - var deferral = request.GetDeferral(); - try + args.Data.SetData(StandardDataFormats.Text, dragPath); + args.Data.RequestedOperation = DataPackageOperation.Move | DataPackageOperation.Copy | DataPackageOperation.Link; + args.Data.SetDataProvider(StandardDataFormats.StorageItems, async request => { - if (Directory.Exists(dragPath)) + var deferral = SafetyExtensions.IgnoreExceptions(() => request.GetDeferral(), null, typeof(COMException)); + try { - var folder = await StorageFolder.GetFolderFromPathAsync(dragPath); - request.SetData(new IStorageItem[] { folder }); + if (Directory.Exists(dragPath)) + { + var folder = await StorageFolder.GetFolderFromPathAsync(dragPath); + request.SetData(new IStorageItem[] { folder }); + } } - } - catch - { - } - finally - { - deferral.Complete(); - } - }); + finally + { + if (deferral is not null) + SafetyExtensions.IgnoreExceptions(() => deferral.Complete(), null, typeof(COMException)); + } + }); + }, null, typeof(COMException)); } private void SetFlyoutOpen(bool isOpen = true) @@ -464,58 +484,92 @@ private void Item_PointerReleased(object sender, Microsoft.UI.Xaml.Input.Pointer private async void ItemBorder_DragOver(object sender, DragEventArgs e) { - var insertsAbove = DetermineDropTargetPosition(e); - if (insertsAbove == SidebarItemDropPosition.Center) - { - VisualStateManager.GoToState(this, "DragOnTop", true); - } - else if (insertsAbove == SidebarItemDropPosition.Top) - { - VisualStateManager.GoToState(this, "DragInsertAbove", true); - } - else if (insertsAbove == SidebarItemDropPosition.Bottom) + // Expected to fail with COMException if the OLE drag payload is stale + var deferral = SafetyExtensions.IgnoreExceptions(() => e.GetDeferral(), null, typeof(COMException)); + + try { - VisualStateManager.GoToState(this, "DragInsertBelow", true); - } + var dropPosition = DetermineDropTargetPosition(e); - Owner?.RaiseItemDragOver(this, insertsAbove, e); + if (Owner is not null) + Owner.RaiseItemDragOver(this, dropPosition, e); - var openDelay = Owner?.HoverToOpenDelay ?? TimeSpan.Zero; - var expandDelay = Owner?.HoverToExpandDelay ?? TimeSpan.Zero; - var isCenter = insertsAbove == SidebarItemDropPosition.Center; - var canHoverOpen = openDelay > TimeSpan.Zero && isCenter && Item is not null && (!IsGroupHeader || Item.IsLeafWithChildren); - var canHoverExpand = expandDelay > TimeSpan.Zero && isCenter && HasChildren && CollapseEnabled; - if (canHoverExpand) - { - dragOverExpandTimer ??= DispatcherQueue.CreateTimer(); - dragOverExpandTimer.Debounce( - () => - { - dragOverExpandTimer!.Stop(); - IsExpanded = true; - }, - expandDelay, - false); - } - else - { - dragOverExpandTimer?.Stop(); - } - if (canHoverOpen) - { - dragOverTimer ??= DispatcherQueue.CreateTimer(); - dragOverTimer.Debounce( - () => - { - dragOverTimer!.Stop(); - RaiseItemInvoked(PointerUpdateKind.Other); - }, - openDelay, - false); + bool isHandled = false; + DataPackageOperation acceptedOperation = DataPackageOperation.None; + + var propertiesRead = SafetyExtensions.IgnoreExceptions(() => + { + isHandled = e.Handled; + acceptedOperation = e.AcceptedOperation; + }, null, typeof(COMException)); + + if (dropPosition != lastDropPosition) + { + acceptedOperation = DataPackageOperation.None; + } + lastDropPosition = dropPosition; + + if (!propertiesRead || !isHandled || acceptedOperation == DataPackageOperation.None) + { + VisualStateManager.GoToState(this, "Normal", true); + return; + } + + if (dropPosition == SidebarItemDropPosition.Center) + { + VisualStateManager.GoToState(this, "DragOnTop", true); + } + else if (dropPosition == SidebarItemDropPosition.Top) + { + VisualStateManager.GoToState(this, "DragInsertAbove", true); + } + else if (dropPosition == SidebarItemDropPosition.Bottom) + { + VisualStateManager.GoToState(this, "DragInsertBelow", true); + } + + var openDelay = Owner?.HoverToOpenDelay ?? TimeSpan.Zero; + var expandDelay = Owner?.HoverToExpandDelay ?? TimeSpan.Zero; + var isCenter = dropPosition == SidebarItemDropPosition.Center; + var canHoverOpen = openDelay > TimeSpan.Zero && isCenter && Item is not null && (!IsGroupHeader || Item.IsLeafWithChildren); + var canHoverExpand = expandDelay > TimeSpan.Zero && isCenter && HasChildren && CollapseEnabled; + if (canHoverExpand) + { + dragOverExpandTimer ??= DispatcherQueue.CreateTimer(); + dragOverExpandTimer.Debounce( + () => + { + dragOverExpandTimer!.Stop(); + IsExpanded = true; + }, + expandDelay, + false); + } + else + { + dragOverExpandTimer?.Stop(); + } + if (canHoverOpen) + { + dragOverTimer ??= DispatcherQueue.CreateTimer(); + dragOverTimer.Debounce( + () => + { + dragOverTimer!.Stop(); + RaiseItemInvoked(PointerUpdateKind.Other); + }, + openDelay, + false); + } + else + { + dragOverTimer?.Stop(); + } } - else + finally { - dragOverTimer?.Stop(); + if (deferral is not null) + SafetyExtensions.IgnoreExceptions(() => deferral.Complete(), null, typeof(COMException)); } } @@ -529,6 +583,7 @@ private void ItemBorder_DragLeave(object sender, DragEventArgs e) { dragOverTimer?.Stop(); dragOverExpandTimer?.Stop(); + lastDropPosition = SidebarItemDropPosition.Center; UpdatePointerState(); } @@ -536,6 +591,7 @@ private void ItemBorder_Drop(object sender, DragEventArgs e) { dragOverTimer?.Stop(); dragOverExpandTimer?.Stop(); + lastDropPosition = SidebarItemDropPosition.Center; UpdatePointerState(); Owner?.RaiseItemDropped(this, DetermineDropTargetPosition(e), e); } diff --git a/src/Files.App.Controls/Sidebar/SidebarStyles.xaml b/src/Files.App.Controls/Sidebar/SidebarStyles.xaml index 7f5dd3738777..b3d6d979ac08 100644 --- a/src/Files.App.Controls/Sidebar/SidebarStyles.xaml +++ b/src/Files.App.Controls/Sidebar/SidebarStyles.xaml @@ -1,4 +1,4 @@ - + - - diff --git a/src/Files.App.Controls/Sidebar/SidebarView.xaml.cs b/src/Files.App.Controls/Sidebar/SidebarView.xaml.cs index 7fdbfb613397..75ea617937ab 100644 --- a/src/Files.App.Controls/Sidebar/SidebarView.xaml.cs +++ b/src/Files.App.Controls/Sidebar/SidebarView.xaml.cs @@ -4,8 +4,10 @@ using Microsoft.UI.Input; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Markup; +using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; using Windows.System; +using System.Runtime.InteropServices; using Windows.UI.Core; namespace Files.App.Controls @@ -51,13 +53,24 @@ internal void RaiseContextRequested(SidebarItem item, Point e) internal void RaiseItemDropped(SidebarItem sideBarItem, SidebarItemDropPosition dropPosition, DragEventArgs rawEvent) { if (sideBarItem.Item is null) return; - ItemDropped?.Invoke(this, new(sideBarItem.Item, rawEvent.DataView, dropPosition, rawEvent)); + + // Expected to fail with COMException if the OLE drag payload is stale + var dataView = SafetyExtensions.IgnoreExceptions(() => rawEvent.DataView, null, typeof(COMException)); + if (dataView is null) return; + + ItemDropped?.Invoke(this, new(sideBarItem.Item, dataView, dropPosition, rawEvent)); } internal void RaiseItemDragOver(SidebarItem sideBarItem, SidebarItemDropPosition dropPosition, DragEventArgs rawEvent) { if (sideBarItem.Item is null) return; - ItemDragOver?.Invoke(this, new(sideBarItem.Item, rawEvent.DataView, dropPosition, rawEvent)); + + // Expected to fail with COMException if the OLE drag payload is stale + var dataView = SafetyExtensions.IgnoreExceptions(() => rawEvent.DataView, null, typeof(COMException)); + if (dataView is null) return; + + var args = new ItemDragOverEventArgs(sideBarItem.Item, dataView, dropPosition, rawEvent); + ItemDragOver?.Invoke(this, args); } private void UpdateMinimalMode() diff --git a/src/Files.App/Data/Contracts/INavigationControlItem.cs b/src/Files.App/Data/Contracts/INavigationControlItem.cs index da7f64839e41..4f5bf64a1f62 100644 --- a/src/Files.App/Data/Contracts/INavigationControlItem.cs +++ b/src/Files.App/Data/Contracts/INavigationControlItem.cs @@ -11,6 +11,8 @@ public interface INavigationControlItem : IComparable, I public string Path { get; } + bool ISidebarItemModel.IsReorderDropItem => Section == SectionType.Pinned; + public SectionType Section { get; } public NavigationControlItemType ItemType { get; } diff --git a/src/Files.App/Data/Items/LocationItem.cs b/src/Files.App/Data/Items/LocationItem.cs index 6df43a36c31e..4399516e8849 100644 --- a/src/Files.App/Data/Items/LocationItem.cs +++ b/src/Files.App/Data/Items/LocationItem.cs @@ -50,6 +50,8 @@ public string Path ToolTip = string.IsNullOrEmpty(Path) || Path.Contains('?', StringComparison.Ordinal) || Path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || + Path.StartsWith("::{", StringComparison.Ordinal) || + Path.StartsWith(@"\\SHELL\", StringComparison.OrdinalIgnoreCase) || Path.EndsWith(ShellLibraryItem.EXTENSION, StringComparison.OrdinalIgnoreCase) || Path == "Home" || Path == "ReleaseNotes" || diff --git a/src/Files.App/Data/Models/PinnedFoldersManager.cs b/src/Files.App/Data/Models/PinnedFoldersManager.cs index 33eef2120c63..a4f8c8c2c6df 100644 --- a/src/Files.App/Data/Models/PinnedFoldersManager.cs +++ b/src/Files.App/Data/Models/PinnedFoldersManager.cs @@ -1,6 +1,8 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; using System.Collections.Specialized; using System.IO; @@ -17,6 +19,33 @@ public sealed class PinnedFoldersManager public List PinnedFolders { get; set; } = []; + private int _syncSuspendCount; + + /// + /// Returns true when sync is suspended + /// + public bool IsSyncSuspended => _syncSuspendCount > 0; + + /// + /// Suspends sync operations until the returned value is disposed + /// + public IDisposable SuspendSync() + { + Interlocked.Increment(ref _syncSuspendCount); + return new SyncSuspensionScope(this); + } + + private sealed class SyncSuspensionScope(PinnedFoldersManager owner) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + Interlocked.Decrement(ref owner._syncSuspendCount); + } + } + public readonly List _PinnedFolderItems = []; [JsonIgnore] @@ -34,6 +63,9 @@ public IReadOnlyList PinnedFolderItems /// public async Task UpdateItemsWithExplorerAsync() { + if (IsSyncSuspended) + return; + await addSyncSemaphore.WaitAsync(); try @@ -46,9 +78,26 @@ public async Task UpdateItemsWithExplorerAsync() if (formerPinnedFolders.SequenceEqual(PinnedFolders)) return; + if (formerPinnedFolders.Count == PinnedFolders.Count && + new HashSet(formerPinnedFolders, StringComparer.OrdinalIgnoreCase) + .SetEquals(PinnedFolders)) + { + ApplyReorderToPinnedItems(); + return; + } RemoveStaleSidebarItems(); - await AddAllItemsToSidebarAsync(); + foreach (var path in PinnedFolders) + { + bool exists; + lock (_PinnedFolderItems) + { + exists = _PinnedFolderItems.Any(x => x.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + } + if (!exists) + await AddItemToSidebarAsync(path); + } + ApplyReorderToPinnedItems(); } finally { @@ -56,6 +105,95 @@ public async Task UpdateItemsWithExplorerAsync() } } + /// + /// Reorders and to match + /// without firing events. + /// Only intended to be called from SidebarViewModel. + /// + internal void UpdateOrderSilently(string[] newOrder) + { + lock (_PinnedFolderItems) + { + ReorderPinnedItemsCore(newOrder, moves: null); + } + + PinnedFolders = newOrder.ToList(); + } + + private void ApplyReorderToPinnedItems() + { + var moves = new List<(INavigationControlItem item, int newIndex, int oldIndex)>(); + + lock (_PinnedFolderItems) + { + ReorderPinnedItemsCore(PinnedFolders, moves); + } + + foreach (var move in moves) + { + DataChanged?.Invoke(SectionType.Pinned, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, move.item, move.newIndex, move.oldIndex)); + } + } + + /// + /// Reorders to match . + /// Must be called while holding the _PinnedFolderItems lock + /// + private void ReorderPinnedItemsCore(IList desiredOrder, List<(INavigationControlItem item, int newIndex, int oldIndex)>? moves) + { + int baseIndex = GetPinnedItemsBaseIndex(); + + for (int i = 0; i < desiredOrder.Count; i++) + { + var path = desiredOrder[i]; + var currentItem = _PinnedFolderItems.FirstOrDefault(x => x.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); + if (currentItem is null) + continue; + + int oldIndex = _PinnedFolderItems.IndexOf(currentItem); + int newIndex = baseIndex + i; + + if (oldIndex != newIndex && newIndex < _PinnedFolderItems.Count) + { + _PinnedFolderItems.RemoveAt(oldIndex); + _PinnedFolderItems.Insert(newIndex, currentItem); + moves?.Add((currentItem, newIndex, oldIndex)); + } + } + } + + /// + /// Returns the base index of user-pinned items in . + /// Must be called while holding the _PinnedFolderItems lock. + /// + /// Invariant assumed: default-location items always appear before user-pinned items and + /// are never interspersed with them. If the first non-default item is found, that is the + /// base index. + /// + /// + private int GetPinnedItemsBaseIndex() + { + int firstPinnedIndex = _PinnedFolderItems.FindIndex(x => x is LocationItem item && !item.IsDefaultLocation); + if (firstPinnedIndex >= 0) + { + // Default locations should always appear before user-pinned locations + var hasDefaultAfterFirstPinned = _PinnedFolderItems + .Skip(firstPinnedIndex) + .Any(x => x is LocationItem item && item.IsDefaultLocation); + + Debug.Assert(!hasDefaultAfterFirstPinned); + + if (!hasDefaultAfterFirstPinned) + return firstPinnedIndex; + + int lastDefaultIndex = _PinnedFolderItems.FindLastIndex(x => x is LocationItem item && item.IsDefaultLocation); + return lastDefaultIndex == -1 ? 0 : lastDefaultIndex + 1; + } + + int trailingDefaultIndex = _PinnedFolderItems.FindLastIndex(x => x is LocationItem item && item.IsDefaultLocation); + return trailingDefaultIndex == -1 ? 0 : trailingDefaultIndex + 1; + } + /// /// Returns the index of the location item in the navigation sidebar /// @@ -198,8 +336,7 @@ public async Task AddAllItemsToSidebarAsync() /// public void RemoveStaleSidebarItems() { - // Remove unpinned items from PinnedFolderItems - foreach (var childItem in PinnedFolderItems) + foreach (var childItem in PinnedFolderItems.ToList()) { if (childItem is LocationItem item && !item.IsDefaultLocation && !PinnedFolders.Contains(item.Path)) { @@ -210,18 +347,19 @@ public void RemoveStaleSidebarItems() 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) + await SafetyExtensions.IgnoreExceptions(async () => { - Reset = true - }); + await LoadAsync(); + var pinnedFolders = await QuickAccessService.GetPinnedFoldersAsync(); + App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(null, new ModifyQuickAccessEventArgs(pinnedFolders.ToArray(), true) + { + Reset = true + }); + }, App.Logger, typeof(COMException)); } public async Task LoadAsync() diff --git a/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml b/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml deleted file mode 100644 index 5550d40c5ae1..000000000000 --- a/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml.cs b/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml.cs deleted file mode 100644 index b5be41005632..000000000000 --- a/src/Files.App/Dialogs/ReorderSidebarItemsDialog.xaml.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Files Community -// Licensed under the MIT License. - -using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Input; -using Windows.ApplicationModel.DataTransfer; - -namespace Files.App.Dialogs -{ - public sealed partial class ReorderSidebarItemsDialog : ContentDialog, IDialog - { - private FrameworkElement RootAppElement - => (FrameworkElement)MainWindow.Instance.Content; - - public ReorderSidebarItemsDialogViewModel ViewModel - { - get => (ReorderSidebarItemsDialogViewModel)DataContext; - set => DataContext = value; - } - - public ReorderSidebarItemsDialog() - { - InitializeComponent(); - } - - private async void MoveItemAsync(object sender, PointerRoutedEventArgs e) - { - var properties = e.GetCurrentPoint(null).Properties; - if (!properties.IsLeftButtonPressed) - return; - - var icon = sender as FontIcon; - - var navItem = icon?.FindAscendant(); - if (navItem is not null) - await navItem.StartDragAsync(e.GetCurrentPoint(navItem)); - } - - private void ListViewItem_DragStarting(object sender, DragStartingEventArgs e) - { - if (sender is not Grid nav || nav.DataContext is not LocationItem) - return; - - // Adding the original Location item dragged to the DragEvents data view - e.Data.Properties.Add("sourceLocationItem", nav); - e.AllowedOperations = DataPackageOperation.Move; - } - - private void ListViewItem_DragOver(object sender, DragEventArgs e) - { - if ((sender as Grid)?.DataContext is not LocationItem locationItem) - return; - var deferral = e.GetDeferral(); - - if ((e.DataView.Properties["sourceLocationItem"] as Grid)?.DataContext is LocationItem sourceLocationItem) - { - DragOver_SetCaptions(sourceLocationItem, locationItem, e); - } - - deferral.Complete(); - } - - private void DragOver_SetCaptions(LocationItem senderLocationItem, LocationItem sourceLocationItem, DragEventArgs e) - { - // If the location item is the same as the original dragged item - if (sourceLocationItem.CompareTo(senderLocationItem) == 0) - { - e.AcceptedOperation = DataPackageOperation.None; - e.DragUIOverride.IsCaptionVisible = false; - } - else - { - e.DragUIOverride.IsCaptionVisible = true; - e.DragUIOverride.Caption = Strings.MoveItemsDialogPrimaryButtonText.GetLocalizedResource(); - e.AcceptedOperation = DataPackageOperation.Move; - } - } - - private void ListViewItem_Drop(object sender, DragEventArgs e) - { - if (sender is not Grid navView || navView.DataContext is not LocationItem locationItem) - return; - - if ((e.DataView.Properties["sourceLocationItem"] as Grid)?.DataContext is LocationItem sourceLocationItem) - ViewModel.SidebarPinnedFolderItems.Move(ViewModel.SidebarPinnedFolderItems.IndexOf(sourceLocationItem), ViewModel.SidebarPinnedFolderItems.IndexOf(locationItem)); - } - - public new async Task ShowAsync() - { - return (DialogResult)await base.ShowAsync(); - } - } -} diff --git a/src/Files.App/Services/App/AppDialogService.cs b/src/Files.App/Services/App/AppDialogService.cs index 808fd625587e..cec673ae777c 100644 --- a/src/Files.App/Services/App/AppDialogService.cs +++ b/src/Files.App/Services/App/AppDialogService.cs @@ -24,7 +24,6 @@ public DialogService() { typeof(FileSystemDialogViewModel), () => new FilesystemOperationDialog() }, { typeof(DecompressArchiveDialogViewModel), () => new DecompressArchiveDialog() }, { typeof(CreateShortcutDialogViewModel), () => new CreateShortcutDialog() }, - { typeof(ReorderSidebarItemsDialogViewModel), () => new ReorderSidebarItemsDialog() }, { typeof(AddBranchDialogViewModel), () => new AddBranchDialog() }, { typeof(GitHubLoginDialogViewModel), () => new GitHubLoginDialog() }, { typeof(FileTooLargeDialogViewModel), () => new FileTooLargeDialog() }, diff --git a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs index 86cc2008cefd..326b4a792961 100644 --- a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs +++ b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs @@ -1,6 +1,10 @@ -// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + namespace Files.App.Services { internal sealed class QuickAccessService : IQuickAccessService @@ -8,6 +12,8 @@ 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}"; + private static readonly TimeSpan UnpinSettleTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ReconciliationTimeout = TimeSpan.FromSeconds(5); public async Task> GetPinnedFoldersAsync() { @@ -20,14 +26,51 @@ public async Task> GetPinnedFoldersAsync() public Task PinToSidebarAsync(string[] folderPaths) => PinToSidebarAsync(folderPaths, true); - private async Task PinToSidebarAsync(string[] folderPaths, bool doUpdateQuickAccessWidget) + private async Task PinToSidebarAsync(string[] folderPaths, bool doUpdateQuickAccessWidget, bool force = false) { foreach (string folderPath in folderPaths) { // make sure that the item has not yet been pinned // the verb 'pintohome' is for both adding and removing - if (!IsItemPinned(folderPath)) - await ContextMenu.InvokeVerb("pintohome", folderPath); + if (force || !IsItemPinned(folderPath)) + { + if (ShellStorageFolder.IsShellPath(folderPath)) + { + bool success = false; + await STATask.Run(() => + { + Type? shellAppType = Type.GetTypeFromProgID("Shell.Application"); + if (shellAppType == null) + return; + + object? shell = Activator.CreateInstance(shellAppType); + string pathForShell = folderPath; + if (folderPath.StartsWith(@"\\SHELL\", StringComparison.OrdinalIgnoreCase)) + { + using var shellItem = ShellFolderExtensions.GetShellItemFromPathOrPIDL(folderPath); + if (shellItem is null) + return; + pathForShell = shellItem.ParsingName ?? folderPath; + } + + object? f2 = shellAppType.InvokeMember("NameSpace", System.Reflection.BindingFlags.InvokeMethod, null, shell, [pathForShell]); + if (f2 != null) + { + object? fi = f2.GetType().InvokeMember("Self", System.Reflection.BindingFlags.GetProperty, null, f2, []); + success = TryInvokeShellVerb(fi, "pintohome", pathForShell); + } + }, App.Logger); + + if (!success) + { + await ContextMenu.InvokeVerb("pintohome", folderPath); + } + } + else + { + await ContextMenu.InvokeVerb("pintohome", folderPath); + } + } } await App.QuickAccessManager.Model.LoadAsync(); @@ -41,74 +84,330 @@ private async Task PinToSidebarAsync(string[] folderPaths, bool doUpdateQuickAcc 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}"]); + folderPaths = NormalizeAndDeduplicatePaths(folderPaths); if (folderPaths.Length == 0) - folderPaths = (await GetPinnedFoldersAsync()) + { + folderPaths = NormalizeAndDeduplicatePaths((await GetPinnedFoldersAsync()) .Where(link => (bool?)link.Properties["System.Home.IsPinned"] ?? false) - .Select(link => link.FilePath).ToArray(); + .Select(link => link.FilePath) + .ToArray()); + } - foreach (dynamic? fi in f2.Items()) + try { - string pathStr = (string)fi.Path; + Type? shellAppType = Type.GetTypeFromProgID("Shell.Application"); + if (shellAppType == null) + return; + + object? shell = Activator.CreateInstance(shellAppType); + object? f2 = shellAppType.InvokeMember("NameSpace", System.Reflection.BindingFlags.InvokeMethod, null, shell, [$"shell:{guid}"]); + if (f2 == null) + return; - if (ShellStorageFolder.IsShellPath(pathStr)) + List pathsToUnpin = new(); + var normalizedTargetPaths = BuildNormalizedPathSet(folderPaths); + + object? items = f2.GetType().InvokeMember("Items", System.Reflection.BindingFlags.InvokeMethod, null, f2, []); + if (items is System.Collections.IEnumerable enumerable) { - var folder = await ShellStorageFolder.FromPathAsync(pathStr); - var path = folder?.Path; + foreach (object? fi in enumerable) + { + if (fi is null) continue; + string pathStr = (string)fi.GetType().InvokeMember("Path", System.Reflection.BindingFlags.GetProperty, null, fi, [])!; + var normalizedPathStr = NormalizeQuickAccessPath(pathStr); + bool shouldUnpin = normalizedTargetPaths.Contains(normalizedPathStr); - if (path is not null && - (folderPaths.Contains(path) || - (path.StartsWith(@"\\SHELL\\") && folderPaths.Any(x => x.StartsWith(@"\\SHELL\\"))))) + if (!shouldUnpin && ShellStorageFolder.IsShellPath(pathStr)) { - await STATask.Run(async () => - { - fi.InvokeVerb("unpinfromhome"); - }, App.Logger); - continue; + var folder = await ShellStorageFolder.FromPathAsync(pathStr); + var path = folder?.Path; + + if (!string.IsNullOrWhiteSpace(path)) + shouldUnpin = normalizedTargetPaths.Contains(NormalizeQuickAccessPath(path)); + } + + if (shouldUnpin) + { + pathsToUnpin.Add(pathStr); } } + } - if (folderPaths.Contains(pathStr)) + if (pathsToUnpin.Count > 0) { - await STATask.Run(async () => + var normalizedPathsToUnpin = BuildNormalizedPathSet(pathsToUnpin); + await STATask.Run(() => { - fi.InvokeVerb("unpinfromhome"); + Type? shellAppTypeSTA = Type.GetTypeFromProgID("Shell.Application"); + if (shellAppTypeSTA == null) return; + object? shellSTA = Activator.CreateInstance(shellAppTypeSTA); + object? f2STA = shellAppTypeSTA.InvokeMember("NameSpace", System.Reflection.BindingFlags.InvokeMethod, null, shellSTA, [$"shell:{guid}"]); + if (f2STA == null) return; + + object? itemsSTA = f2STA.GetType().InvokeMember("Items", System.Reflection.BindingFlags.InvokeMethod, null, f2STA, []); + if (itemsSTA is System.Collections.IEnumerable enumerableSTA) + { + foreach (object? fi in enumerableSTA) + { + if (fi is null) continue; + string pathStr = (string)fi.GetType().InvokeMember("Path", System.Reflection.BindingFlags.GetProperty, null, fi, [])!; + if (normalizedPathsToUnpin.Contains(NormalizeQuickAccessPath(pathStr))) + { + var unpinned = TryInvokeShellVerb(fi, "unpinfromhome", pathStr); + if (!unpinned && ShellStorageFolder.IsShellPath(pathStr)) + TryInvokeShellVerb(fi, "remove", pathStr); + } + } + } }, App.Logger); } } - - await App.QuickAccessManager.Model.LoadAsync(); - if (doUpdateQuickAccessWidget) - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(folderPaths, false)); + finally + { + await App.QuickAccessManager.Model.LoadAsync(); + if (doUpdateQuickAccessWidget) + App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(folderPaths, false)); + } } public bool IsItemPinned(string folderPath) { - return App.QuickAccessManager.Model.PinnedFolders.Contains(folderPath); + if (App.QuickAccessManager.Model.PinnedFolders.Contains(folderPath, StringComparer.OrdinalIgnoreCase)) + return true; + + if (!ShellStorageFolder.IsShellPath(folderPath)) + return false; + + var normalizedPath = NormalizeQuickAccessPath(folderPath); + return App.QuickAccessManager.Model.PinnedFolders + .Any(x => string.Equals(NormalizeQuickAccessPath(x), normalizedPath, StringComparison.OrdinalIgnoreCase)); + } + + private static bool TryInvokeShellVerb(object? shellItem, string verb, string path) + { + if (shellItem is null) + return false; + + return Files.Shared.Extensions.SafetyExtensions.IgnoreExceptions(() => + { + shellItem.GetType().InvokeMember("InvokeVerb", System.Reflection.BindingFlags.InvokeMethod, null, shellItem, [verb]); + return true; + }, App.Logger, typeof(Exception)); + } + + private static string[] NormalizeAndDeduplicatePaths(IEnumerable? paths) + { + if (paths is null) + return []; + + List result = []; + HashSet normalizedSet = new(StringComparer.OrdinalIgnoreCase); + + foreach (var path in paths.Where(x => !string.IsNullOrWhiteSpace(x))) + { + var normalizedPath = NormalizeQuickAccessPath(path); + if (normalizedSet.Add(normalizedPath)) + result.Add(path); + } + + return result.ToArray(); + } + + private static HashSet BuildNormalizedPathSet(IEnumerable paths) + { + return new HashSet( + paths + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(NormalizeQuickAccessPath), + StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeQuickAccessPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + if (!ShellStorageFolder.IsShellPath(path)) + return path; + + try + { + using var shellItem = ShellFolderExtensions.GetShellItemFromPathOrPIDL(path); + var parsingName = shellItem?.ParsingName; + if (!string.IsNullOrWhiteSpace(parsingName)) + return parsingName; + } + catch (COMException ex) + { + App.Logger.LogDebug(ex, "Failed to resolve shell path {Path}", path); + } + + return path.StartsWith(@"\\SHELL\", StringComparison.OrdinalIgnoreCase) + ? path.Replace(@"\\SHELL\", string.Empty, StringComparison.OrdinalIgnoreCase) + : path; + } + + private async Task GetPinnedFolderPathsAsync() + { + return (await GetPinnedFoldersAsync()) + .Where(link => (bool?)link.Properties["System.Home.IsPinned"] ?? false) + .Select(link => link.FilePath) + .ToArray(); + } + + private async Task GetMissingPinnedItemsAsync(IEnumerable desiredItems) + { + var normalizedCurrentPinned = BuildNormalizedPathSet(await GetPinnedFolderPathsAsync()); + return desiredItems + .Where(x => !normalizedCurrentPinned.Contains(NormalizeQuickAccessPath(x))) + .ToArray(); + } + + private static async Task WaitUntilAsync(Func> condition, TimeSpan timeout) + { + if (await condition()) + return true; + + // Quick Access state is saved by the OS into f01b...automaticDestinations-ms + var automaticDestinationsPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Recent", "AutomaticDestinations"); + + if (!Directory.Exists(automaticDestinationsPath)) + return await PollWaitAsync(condition, timeout, TimeSpan.FromMilliseconds(200)); + + try + { + using var cts = new CancellationTokenSource(timeout); + using var watcher = new FileSystemWatcher(automaticDestinationsPath, "f01b4d95cf55d32a.automaticDestinations-ms") + { + NotifyFilter = NotifyFilters.LastAccess | NotifyFilters.LastWrite | NotifyFilters.FileName + }; + + using var semaphore = new SemaphoreSlim(0); + void OnChanged(object sender, FileSystemEventArgs e) + { + try + { + semaphore.Release(); + } + catch (ObjectDisposedException) + { + + } + } + + watcher.Changed += OnChanged; + watcher.Created += OnChanged; + watcher.Deleted += OnChanged; + + try + { + watcher.EnableRaisingEvents = true; + + while (!cts.IsCancellationRequested) + { + if (await condition()) + return true; + + try + { + // prevents wait deadlocks if the FileSystemWatcher + // randomly swallows a background COM completion event + await semaphore.WaitAsync(TimeSpan.FromMilliseconds(400), cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + } + finally + { + watcher.Changed -= OnChanged; + watcher.Created -= OnChanged; + watcher.Deleted -= OnChanged; + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "FileSystemWatcher failed to initialize for automaticDestinations-ms. Falling back to polling."); + return await PollWaitAsync(condition, timeout, TimeSpan.FromMilliseconds(200)); + } + + return await condition(); + } + + private static async Task PollWaitAsync(Func> condition, TimeSpan timeout, TimeSpan pollInterval) + { + using var cts = new CancellationTokenSource(timeout); + while (!cts.IsCancellationRequested) + { + if (await condition()) + return true; + + try + { + await Task.Delay(pollInterval, cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + + return await condition(); + } + + private async Task ReconcilePinsAsync(string[] desiredItems) + { + await WaitUntilAsync(async () => + { + var missingItems = await GetMissingPinnedItemsAsync(desiredItems); + if (missingItems.Length == 0) + return true; + + await PinToSidebarAsync(missingItems, false, force: true); + return false; + }, ReconciliationTimeout); } public async Task SaveAsync(string[] items) { - if (Equals(items, App.QuickAccessManager.Model.PinnedFolders.ToArray())) + var desiredItems = items + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (desiredItems.SequenceEqual(App.QuickAccessManager.Model.PinnedFolders, StringComparer.OrdinalIgnoreCase)) return; if (App.QuickAccessManager.PinnedItemsWatcher is not null) App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = false; - // Unpin every item that is below this index and then pin them all in order - await UnpinFromSidebarAsync([], false); + try + { + var itemsToUnpin = await GetPinnedFolderPathsAsync(); - await PinToSidebarAsync(items, false); - if (App.QuickAccessManager.PinnedItemsWatcher is not null) - App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = true; + if (itemsToUnpin.Length > 0) + { + await UnpinFromSidebarAsync(itemsToUnpin, false); + await WaitUntilAsync(async () => + { + var currentPinned = await GetPinnedFolderPathsAsync(); + var normalizedCurrentPinned = BuildNormalizedPathSet(currentPinned); - App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(items, true) + return !itemsToUnpin.Any(x => normalizedCurrentPinned.Contains(NormalizeQuickAccessPath(x))); + }, UnpinSettleTimeout); + } + + await ReconcilePinsAsync(desiredItems); + await App.QuickAccessManager.Model.LoadAsync(); + } + finally { - Reorder = true - }); + if (App.QuickAccessManager.PinnedItemsWatcher is not null) + App.QuickAccessManager.PinnedItemsWatcher.EnableRaisingEvents = true; + } } } } diff --git a/src/Files.App/Utils/Storage/Collection/BulkConcurrentObservableCollection.cs b/src/Files.App/Utils/Storage/Collection/BulkConcurrentObservableCollection.cs index 528f1e9a0b46..681c6171e259 100644 --- a/src/Files.App/Utils/Storage/Collection/BulkConcurrentObservableCollection.cs +++ b/src/Files.App/Utils/Storage/Collection/BulkConcurrentObservableCollection.cs @@ -375,6 +375,21 @@ public void RemoveAt(int index) UpdateGroups(e); } + public void Move(int oldIndex, int newIndex) + { + NotifyCollectionChangedEventArgs e; + + lock (syncRoot) + { + var item = collection[oldIndex]; + collection.RemoveAt(oldIndex); + collection.Insert(newIndex, item); + + e = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, item, newIndex, oldIndex); + OnCollectionChanged(e, false); + } + } + public void AddRange(IEnumerable items) { if (!items.Any()) diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs index 3d14576c6aea..4cacdf064a25 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs @@ -74,7 +74,7 @@ public static bool AreItemsAlreadyInFolder(this IEnumerable itemsPath, s try { var trimmedPath = destinationPath.TrimPath(); - return itemsPath.All(itemPath => Path.GetDirectoryName(itemPath).Equals(trimmedPath, StringComparison.OrdinalIgnoreCase)); + return itemsPath.All(itemPath => Path.GetDirectoryName(itemPath)?.Equals(trimmedPath, StringComparison.OrdinalIgnoreCase) == true); } catch { diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs index 47b6ef0992f1..f6bc1a5a2b42 100644 --- a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs @@ -6,6 +6,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; +using Files.App.Controls; using Vanara.PInvoke; using Vanara.Windows.Shell; using Windows.ApplicationModel.DataTransfer; @@ -276,7 +277,7 @@ public async Task PerformOperationTypeAsync( } finally { - packageView.ReportOperationCompleted(packageView.RequestedOperation); + SafetyExtensions.IgnoreExceptions(() => packageView.ReportOperationCompleted(packageView.RequestedOperation), App.Logger, typeof(System.Runtime.InteropServices.COMException)); } } @@ -732,15 +733,19 @@ await Ioc.Default.GetRequiredService().TryGetFileAsync(dest) public static bool HasDraggedStorageItems(DataPackageView packageView) { - return packageView is not null && (packageView.Contains(StandardDataFormats.StorageItems) || packageView.Contains("FileDrop")); + if (packageView is null) + return false; + + return packageView.Contains(StandardDataFormats.StorageItems) || packageView.Contains("FileDrop"); } public static async Task> GetDraggedStorageItems(DataPackageView packageView) { var itemsList = new List(); var hasVirtualItems = false; + bool containsStorageItems = packageView.Contains(StandardDataFormats.StorageItems); - if (packageView.Contains(StandardDataFormats.StorageItems)) + if (containsStorageItems) { try { @@ -761,7 +766,11 @@ public static async Task> GetDraggedStorageIte // workaround for pasting folders from remote desktop (#12318) try { - if (hasVirtualItems && packageView.Contains("FileContents")) + var containsFileContents = false; + if (hasVirtualItems) + containsFileContents = packageView.Contains("FileContents"); + + if (hasVirtualItems && containsFileContents) { var descriptor = NativeClipboard.CurrentDataObject.GetData("FileGroupDescriptorW"); for (var ii = 0; ii < descriptor.cItems; ii++) @@ -783,7 +792,9 @@ public static async Task> GetDraggedStorageIte // workaround for GetStorageItemsAsync() bug that only yields 16 items at most // https://learn.microsoft.com/windows/win32/shell/clipboard#cf_hdrop - if (packageView.Contains("FileDrop")) + bool containsFileDrop = packageView.Contains("FileDrop"); + + if (containsFileDrop) { var fileDropData = await SafetyExtensions.IgnoreExceptions( () => packageView.GetDataAsync("FileDrop").AsTask()); diff --git a/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs deleted file mode 100644 index f4f90ffc4ba1..000000000000 --- a/src/Files.App/ViewModels/Dialogs/ReorderSidebarItemsDialogViewModel.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Files Community -// Licensed under the MIT License. - -using System.Windows.Input; - -namespace Files.App.ViewModels.Dialogs -{ - public sealed partial class ReorderSidebarItemsDialogViewModel : ObservableObject - { - private readonly IQuickAccessService quickAccessService = Ioc.Default.GetRequiredService(); - - public string HeaderText = Strings.ReorderSidebarItemsDialogText.GetLocalizedResource(); - public ICommand PrimaryButtonCommand { get; private set; } - - public ObservableCollection SidebarPinnedFolderItems = new(App.QuickAccessManager.Model._PinnedFolderItems - .Where(x => x is LocationItem loc && loc.Section is SectionType.Pinned && !loc.IsHeader) - .Cast()); - - public ReorderSidebarItemsDialogViewModel() - { - //App.Logger.LogWarning(string.Join(", ", SidebarPinnedFolderItems.Select(x => x.Path))); - PrimaryButtonCommand = new RelayCommand(SaveChanges); - } - - public void SaveChanges() - { - quickAccessService.SaveAsync(SidebarPinnedFolderItems.Select(x => x.Path).ToArray()); - } - } -} diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 6f5cdf2e7fa7..fb4be709c399 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -3,6 +3,8 @@ using Files.App.Controls; using Files.App.Helpers.ContextFlyouts; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; using Microsoft.UI.Input; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -48,6 +50,8 @@ public IFilesystemHelpers FilesystemHelpers public PinnedFoldersManager SidebarPinnedModel => App.QuickAccessManager.Model; public IQuickAccessService QuickAccessService { get; } = Ioc.Default.GetRequiredService(); + private bool isReordering = false; + private SidebarDisplayMode sidebarDisplayMode; public SidebarDisplayMode SidebarDisplayMode { @@ -327,7 +331,6 @@ public SidebarViewModel() PinItemCommand = new RelayCommand(PinItem); EjectDeviceCommand = new RelayCommand(EjectDevice); OpenPropertiesCommand = new RelayCommand(OpenProperties); - ReorderItemsCommand = new AsyncRelayCommand(ReorderItemsAsync); } private Task CreateItemHomeAsync() @@ -340,11 +343,15 @@ private async void Manager_DataChanged(object sender, NotifyCollectionChangedEve if (dispatcherQueue is null) return; - await dispatcherQueue.EnqueueOrInvokeAsync(async () => + await dispatcherQueue.EnqueueOrInvokeAsync(() => SyncSidebarDataAsync((SectionType)sender, e)); + } + + private async Task SyncSidebarDataAsync(SectionType sectionType, NotifyCollectionChangedEventArgs e) + { + await SafetyExtensions.IgnoreExceptions(async () => { - var sectionType = (SectionType)sender; var section = await GetOrCreateSectionAsync(sectionType); - Func> getElements = () => sectionType switch + IReadOnlyList? getElements() => sectionType switch { SectionType.Pinned => App.QuickAccessManager.Model.PinnedFolderItems, SectionType.CloudDrives => CloudDrivesManager.Drives, @@ -356,7 +363,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(async () => _ => null }; await SyncSidebarItemsAsync(section, getElements, e); - }); + }, App.Logger, typeof(COMException)); } private void Manager_DataChangedForDrives(object? sender, NotifyCollectionChangedEventArgs e) => Manager_DataChanged(SectionType.Drives, e); @@ -384,6 +391,32 @@ private async Task SyncSidebarItemsAsync(LocationItem section, Func x.Path == item.Path); + if (match is not null) + { + var oldIndex = section.ChildItems.IndexOf(match); + var newIndex = e.NewStartingIndex < 0 ? section.ChildItems.Count - 1 : Math.Min(e.NewStartingIndex, section.ChildItems.Count - 1); + if (oldIndex >= 0 && oldIndex != newIndex) + { + section.ChildItems.Move(oldIndex, newIndex); + } + } + return; + } + + // fallback + section.ChildItems.Clear(); + foreach (INavigationControlItem elem in getElements()) + { + await AddElementToSectionAsync(elem, section); + } + return; + } + case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Replace: { @@ -964,8 +997,6 @@ public async void HandleItemInvokedAsync(object item, PointerUpdateKind pointerU private ICommand OpenPropertiesCommand { get; } - private ICommand ReorderItemsCommand { get; } - private void PinItem() { if (rightClickedItem is DriveItem) @@ -1005,13 +1036,6 @@ private void HideSection() } } - private async Task ReorderItemsAsync() - { - var dialog = new ReorderSidebarItemsDialogViewModel(); - var dialogService = Ioc.Default.GetRequiredService(); - var result = await dialogService.ShowDialogAsync(dialog); - } - private void OpenProperties(CommandBarFlyout menu) { EventHandler flyoutClosed = null!; @@ -1170,13 +1194,6 @@ private List GetLocationItemMenuItems(INavigatio ShowItem = options.ShowUnpinItem || isDriveItemPinned }, new ContextMenuFlyoutItemViewModel() - { - Text = Strings.ReorderSidebarItemsDialogText.GetLocalizedResource(), - Glyph = "\uE8D8", - Command = ReorderItemsCommand, - ShowItem = isPinnedItem || item.Section is SectionType.Pinned - }, - new ContextMenuFlyoutItemViewModel() { Text = string.Format(Strings.SideBarHideSectionFromSideBar_Text.GetLocalizedResource(), rightClickedItem.Text), Glyph = "\uE77A", @@ -1231,6 +1248,15 @@ private List GetLocationItemMenuItems(INavigatio public async Task HandleItemDragOverAsync(ItemDragOverEventArgs args) { + // Reject if reorder is in progress + // Prevents TOCTOU between drag-over and drop handlers + if (isReordering) + { + args.RawEvent.Handled = true; + args.RawEvent.AcceptedOperation = DataPackageOperation.None; + return; + } + if (args.DropTarget is LocationItem locationItem) await HandleLocationItemDragOverAsync(locationItem, args); else if (args.DropTarget is DriveItem driveItem) @@ -1239,9 +1265,55 @@ public async Task HandleItemDragOverAsync(ItemDragOverEventArgs args) await HandleTagItemDragOverAsync(fileTagItem, args); } + private static async Task TryGetDraggedTextAsync(DataPackageView droppedItem) + { + return await SafetyExtensions.IgnoreExceptions(async () => + { + if (!droppedItem.Contains(StandardDataFormats.Text)) + return null; + + return await droppedItem.GetTextAsync(); + }, App.Logger, typeof(COMException)); + } + private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, ItemDragOverEventArgs args) { var rawEvent = args.RawEvent; + var dragPath = locationItem.Section == SectionType.Pinned + ? await TryGetDraggedTextAsync(args.DroppedItem) + : null; + + if (dragPath is not null) + { + var pinnedSection = sidebarItems.FirstOrDefault(x => x.Section == SectionType.Pinned); + if (pinnedSection is LocationItem section && + section.ChildItems?.Any(x => x.Path == dragPath) == true) + { + if (locationItem.IsHeader) + { + rawEvent.Handled = true; + rawEvent.AcceptedOperation = DataPackageOperation.None; + return; + } + + if (args.dropPosition != SidebarItemDropPosition.Center) + { + rawEvent.Handled = true; + rawEvent.AcceptedOperation = DataPackageOperation.Move; + rawEvent.DragUIOverride.IsCaptionVisible = true; + rawEvent.DragUIOverride.Caption = Strings.ReorderSidebarItemsDialogText.GetLocalizedResource(); + return; + } + } + else if (!locationItem.IsHeader && args.dropPosition != SidebarItemDropPosition.Center) + { + rawEvent.Handled = true; + rawEvent.AcceptedOperation = DataPackageOperation.Move; + rawEvent.DragUIOverride.IsCaptionVisible = true; + rawEvent.DragUIOverride.Caption = Strings.PinFolderToSidebar.GetLocalizedResource(); + return; + } + } if (Utils.Storage.FilesystemHelpers.HasDraggedStorageItems(args.DroppedItem)) { @@ -1251,7 +1323,20 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem); var hasStorageItems = storageItems.Any(); - if (isPathNull && hasStorageItems && SectionType.Pinned.Equals(locationItem.Section)) + if (!isPathNull && hasStorageItems && SectionType.Pinned.Equals(locationItem.Section) + && args.dropPosition != SidebarItemDropPosition.Center) + { + if (storageItems.Any(item => item.ItemType == FilesystemItemType.Directory && !SidebarPinnedModel.PinnedFolders.Contains(item.Path))) + { + var captionText = Strings.PinFolderToSidebar.GetLocalizedResource(); + CompleteDragEventArgs(rawEvent, captionText, DataPackageOperation.Move); + } + else + { + rawEvent.AcceptedOperation = DataPackageOperation.None; + } + } + else if (isPathNull && hasStorageItems && SectionType.Pinned.Equals(locationItem.Section)) { var haveFoldersToPin = storageItems.Any(item => item.ItemType == FilesystemItemType.Directory && !SidebarPinnedModel.PinnedFolders.Contains(item.Path)); @@ -1412,9 +1497,120 @@ public async Task HandleItemDroppedAsync(ItemDroppedEventArgs args) private async Task HandleLocationItemDroppedAsync(LocationItem locationItem, ItemDroppedEventArgs args) { + var dragPath = locationItem.Section == SectionType.Pinned + ? await TryGetDraggedTextAsync(args.DroppedItem) + : null; + + if (dragPath is not null) + { + var pinnedSection = sidebarItems.FirstOrDefault(x => x.Section == SectionType.Pinned); + if (pinnedSection is LocationItem section && section.ChildItems is not null) + { + var sourceItem = section.ChildItems.FirstOrDefault(x => x.Path == dragPath); + if (sourceItem is not null) + { + if (args.dropPosition != SidebarItemDropPosition.Center) + { + if (isReordering) return; + isReordering = true; + try + { + var sourceIndex = section.ChildItems.IndexOf(sourceItem); + var targetIndex = section.ChildItems.IndexOf(locationItem); + + if (sourceIndex < 0 || targetIndex < 0) + return; + + if (args.dropPosition == SidebarItemDropPosition.Bottom) + targetIndex++; + + if (sourceIndex < targetIndex) + targetIndex--; + + if (sourceIndex != targetIndex && targetIndex >= 0 && targetIndex < section.ChildItems.Count) + { + section.ChildItems.Move(sourceIndex, targetIndex); + await PersistPinnedOrderAsync(section); + } + } + finally + { + isReordering = false; + } + return; + } + } + else if (args.dropPosition != SidebarItemDropPosition.Center && !locationItem.IsHeader) + { + using (SidebarPinnedModel.SuspendSync()) + { + if (!SidebarPinnedModel.PinnedFolders.Contains(dragPath)) + { + if (section.ChildItems.Any(x => string.Equals(x.Path, dragPath, StringComparison.OrdinalIgnoreCase))) + return; + + var targetIndex = section.ChildItems.IndexOf(locationItem); + if (targetIndex >= 0) + { + if (args.dropPosition == SidebarItemDropPosition.Bottom) + targetIndex++; + + var newLocationItem = await SidebarPinnedModel.CreateLocationItemFromPathAsync(dragPath); + lock (SidebarPinnedModel._PinnedFolderItems) + { + SidebarPinnedModel._PinnedFolderItems.Add(newLocationItem); + } + section.ChildItems.Insert(targetIndex, newLocationItem); + await PersistPinnedOrderAsync(section, isSyncSuspended: true); + } + } + } + return; + } + } + } + if (Utils.Storage.FilesystemHelpers.HasDraggedStorageItems(args.DroppedItem)) { - if (string.IsNullOrEmpty(locationItem.Path) && SectionType.Pinned.Equals(locationItem.Section)) // Pin to "Pinned" section + if (!string.IsNullOrEmpty(locationItem.Path) && SectionType.Pinned.Equals(locationItem.Section) + && args.dropPosition != SidebarItemDropPosition.Center) + { + var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem); + var pinnedSection = sidebarItems.FirstOrDefault(x => x.Section == SectionType.Pinned); + if (pinnedSection is LocationItem section && section.ChildItems is not null) + { + using (SidebarPinnedModel.SuspendSync()) + { + foreach (var item in storageItems) + { + if (item.ItemType != FilesystemItemType.Directory || SidebarPinnedModel.PinnedFolders.Contains(item.Path)) + continue; + + if (section.ChildItems.Any(x => string.Equals(x.Path, item.Path, StringComparison.OrdinalIgnoreCase))) + continue; + + var targetIndex = section.ChildItems.IndexOf(locationItem); + if (targetIndex < 0) + continue; + + if (args.dropPosition == SidebarItemDropPosition.Bottom) + targetIndex++; + + var newLocationItem = await SidebarPinnedModel.CreateLocationItemFromPathAsync(item.Path); + lock (SidebarPinnedModel._PinnedFolderItems) + { + SidebarPinnedModel._PinnedFolderItems.Add(newLocationItem); + } + section.ChildItems.Insert(targetIndex, newLocationItem); + } + + await PersistPinnedOrderAsync(section, isSyncSuspended: true); + } + } + return; + } + + if (string.IsNullOrEmpty(locationItem.Path) && SectionType.Pinned.Equals(locationItem.Section)) { var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem); foreach (var item in storageItems) @@ -1430,6 +1626,39 @@ private async Task HandleLocationItemDroppedAsync(LocationItem locationItem, Ite } } + private static string[] BuildPinnedOrderFromSectionItems(LocationItem section) + { + return section.ChildItems + .OfType() + .Where(x => !x.IsDefaultLocation && !string.IsNullOrEmpty(x.Path)) + .Select(x => x.Path) + .ToArray(); + } + + private async Task PersistPinnedOrderAsync(LocationItem section, bool isSyncSuspended = false) + { + var newOrder = BuildPinnedOrderFromSectionItems(section); + + if (isSyncSuspended) + { + await QuickAccessService.SaveAsync(newOrder); + SidebarPinnedModel.UpdateOrderSilently(newOrder); + } + else + { + using (SidebarPinnedModel.SuspendSync()) + { + await QuickAccessService.SaveAsync(newOrder); + SidebarPinnedModel.UpdateOrderSilently(newOrder); + } + } + + App.QuickAccessManager.UpdateQuickAccessWidget?.Invoke(this, new ModifyQuickAccessEventArgs(newOrder, true) + { + Reorder = true + }); + } + private Task HandleDriveItemDroppedAsync(DriveItem driveItem, ItemDroppedEventArgs args) { return FilesystemHelpers.PerformOperationTypeAsync(args.RawEvent.AcceptedOperation, args.RawEvent.DataView, driveItem.Path, false, true); diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index 1771114901a9..eb301979b99f 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -1,6 +1,8 @@ -// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; using Microsoft.UI.Input; using Microsoft.UI.Xaml.Controls; using System.Collections.Specialized; @@ -35,6 +37,7 @@ public sealed partial class QuickAccessWidgetViewModel : BaseWidgetViewModel, IW // TODO: Replace with IMutableFolder.GetWatcherAsync() once it gets implemented in IWindowsStorable private readonly SystemIO.FileSystemWatcher? _quickAccessFolderWatcher; + private readonly EventHandler _quickAccessWidgetUpdatedHandler; // Constructor @@ -59,35 +62,91 @@ public QuickAccessWidgetViewModel() _quickAccessFolderWatcher.Changed += async (s, e) => { - await RefreshWidgetAsync(); + await SafetyExtensions.IgnoreExceptions(RefreshWidgetAsync, App.Logger, typeof(COMException)); }; _quickAccessFolderWatcher.EnableRaisingEvents = true; + + _quickAccessWidgetUpdatedHandler = async (s, e) => + { + if (e.Reorder) + { + await SafetyExtensions.IgnoreExceptions(() => RefreshWidgetAsync(bypassSuspend: true), App.Logger, typeof(COMException)); + } + }; + App.QuickAccessManager.UpdateQuickAccessWidget += _quickAccessWidgetUpdatedHandler; } // Methods public Task RefreshWidgetAsync() { + return RefreshWidgetAsync(false); + } + + public Task RefreshWidgetAsync(bool bypassSuspend) + { + if (!bypassSuspend && App.QuickAccessManager.Model.IsSyncSuspended) + return Task.CompletedTask; + return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { - foreach (var item in Items) - item.Dispose(); - - Items.Clear(); + var newItems = new List<(IWindowsStorable folder, string name, bool isPinned, string tooltip, string path)>(); await foreach (IWindowsStorable folder in HomePageContext.HomeFolder.GetQuickAccessFolderAsync(default)) { folder.GetPropertyValue("System.Home.IsPinned", out var isPinned); folder.TryGetShellTooltip(out var tooltip); + var name = folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI); + var path = folder.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + + newItems.Add((folder, name, isPinned, tooltip ?? string.Empty, path)); + } + + var currentPaths = Items.Select(i => i.Path).ToList(); + var newPaths = newItems.Select(i => i.path).ToList(); + + if (currentPaths.Count == newPaths.Count && + new HashSet(currentPaths, StringComparer.OrdinalIgnoreCase) + .SetEquals(newPaths)) + { + foreach (var ni in newItems) + ni.folder.Dispose(); + + for (int targetIdx = 0; targetIdx < newPaths.Count; targetIdx++) + { + var currentIdx = -1; + for (int j = targetIdx; j < Items.Count; j++) + { + if (string.Equals(Items[j].Path, newPaths[targetIdx], StringComparison.OrdinalIgnoreCase)) + { + currentIdx = j; + break; + } + } + + if (currentIdx >= 0 && currentIdx != targetIdx) + Items.Move(currentIdx, targetIdx); + } + + return; + } + + foreach (var item in Items) + item.Dispose(); + + Items.Clear(); + + foreach (var (folder, name, isPinned, tooltip, path) in newItems) + { Items.Insert( Items.Count, new WidgetFolderCardItem( folder, - folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI), + name, isPinned, - tooltip ?? string.Empty)); + tooltip)); } }); } @@ -328,6 +387,8 @@ private void ExecuteOpenPropertiesCommand(WidgetFolderCardItem? item) public void Dispose() { + App.QuickAccessManager.UpdateQuickAccessWidget -= _quickAccessWidgetUpdatedHandler; + foreach (var item in Items) item.Dispose(); }