From 1024760cef6db5a83ed3adc00ba2bc653b0db072 Mon Sep 17 00:00:00 2001
From: d2dyno <53011783+d2dyno1@users.noreply.github.com>
Date: Mon, 6 Jan 2025 03:50:22 +0100
Subject: [PATCH] Code Quality: Added design and basic functionality for the
 upcoming Shelf feature (#16673)

---
 .../Data/Contracts/ImagingService.cs          |  7 +-
 src/Files.App/Data/Items/ShelfItem.cs         | 40 +++++++++
 src/Files.App/Strings/en-US/Resources.resw    | 14 +++
 .../UserControls/Pane/ShelfPane.xaml          | 87 +++++++++++++++++-
 .../UserControls/Pane/ShelfPane.xaml.cs       | 88 +++++++++++++++++++
 src/Files.App/Views/MainPage.xaml             |  1 +
 src/Files.Shared/Utils/IAsyncInitialize.cs    |  5 +-
 src/Files.Shared/Utils/IWrapper.cs            | 14 +++
 8 files changed, 244 insertions(+), 12 deletions(-)
 create mode 100644 src/Files.App/Data/Items/ShelfItem.cs
 create mode 100644 src/Files.Shared/Utils/IWrapper.cs

diff --git a/src/Files.App/Data/Contracts/ImagingService.cs b/src/Files.App/Data/Contracts/ImagingService.cs
index 390824ac9d93..30d3dcd0a427 100644
--- a/src/Files.App/Data/Contracts/ImagingService.cs
+++ b/src/Files.App/Data/Contracts/ImagingService.cs
@@ -13,15 +13,12 @@ internal sealed class ImagingService : IImageService
 		/// <inheritdoc/>
 		public async Task<IImage?> GetIconAsync(IStorable storable, CancellationToken cancellationToken)
 		{
-			if (storable is not ILocatableStorable locatableStorable)
-				return null;
-
-			var iconData = await FileThumbnailHelper.LoadIconFromPathAsync(locatableStorable.Path, 24u, ThumbnailMode.ListView, ThumbnailOptions.ResizeThumbnail);
+			var iconData = await FileThumbnailHelper.GetIconAsync(storable.Id, Constants.ShellIconSizes.Small, storable is IFolder, IconOptions.ReturnIconOnly | IconOptions.UseCurrentScale);
 			if (iconData is null)
 				return null;
 
 			var bitmapImage = await iconData.ToBitmapAsync();
-			return new BitmapImageModel(bitmapImage);
+			return bitmapImage is null ? null : new BitmapImageModel(bitmapImage);
 		}
 
 		public async Task<IImage?> GetImageModelFromDataAsync(byte[] rawData)
diff --git a/src/Files.App/Data/Items/ShelfItem.cs b/src/Files.App/Data/Items/ShelfItem.cs
new file mode 100644
index 000000000000..7facfabce5b9
--- /dev/null
+++ b/src/Files.App/Data/Items/ShelfItem.cs
@@ -0,0 +1,40 @@
+using Files.Shared.Utils;
+
+namespace Files.App.Data.Items
+{
+	[Bindable(true)]
+	public sealed partial class ShelfItem : ObservableObject, IWrapper<IStorable>, IAsyncInitialize
+	{
+		private readonly IImageService _imageService;
+		private readonly ICollection<ShelfItem> _sourceCollection;
+
+		[ObservableProperty] private IImage? _Icon;
+		[ObservableProperty] private string? _Name;
+		[ObservableProperty] private string? _Path;
+
+		/// <inheritdoc/>
+		public IStorable Inner { get; }
+
+		public ShelfItem(IStorable storable, ICollection<ShelfItem> sourceCollection, IImage? icon = null)
+		{
+			_imageService = Ioc.Default.GetRequiredService<IImageService>();
+			_sourceCollection = sourceCollection;
+			Inner = storable;
+			Icon = icon;
+			Name = storable.Name;
+			Path = storable.Id;
+		}
+
+		/// <inheritdoc/>
+		public async Task InitAsync(CancellationToken cancellationToken = default)
+		{
+			Icon = await _imageService.GetIconAsync(Inner, cancellationToken);
+		}
+
+		[RelayCommand]
+		private void Remove()
+		{
+			_sourceCollection.Remove(this);
+		}
+	}
+}
diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw
index 02dbe67ff39d..a219e91f76cc 100644
--- a/src/Files.App/Strings/en-US/Resources.resw
+++ b/src/Files.App/Strings/en-US/Resources.resw
@@ -4022,4 +4022,18 @@
   <data name="ShowShelfPane" xml:space="preserve">
     <value>Show Shelf Pane</value>
   </data>
+  <data name="Shelf" xml:space="preserve">
+    <value>Shelf</value>
+    <comment>'Shelf' refers to the Shelf Pane feature, where users can conveniently drag and drop files for quick access and perform bulk actions with ease.</comment>
+  </data>
+  <data name="ClearItems" xml:space="preserve">
+    <value>Clear items</value>
+  </data>
+  <data name="RemoveFromShelf" xml:space="preserve">
+    <value>Remove from shelf</value>
+  </data>
+  <data name="AddToShelf" xml:space="preserve">
+    <value>Add to Shelf</value>
+    <comment>Tooltip that displays when dragging items to the Shelf Pane</comment>
+  </data>
 </root>
\ No newline at end of file
diff --git a/src/Files.App/UserControls/Pane/ShelfPane.xaml b/src/Files.App/UserControls/Pane/ShelfPane.xaml
index 1804873e84d6..952b0ca4861b 100644
--- a/src/Files.App/UserControls/Pane/ShelfPane.xaml
+++ b/src/Files.App/UserControls/Pane/ShelfPane.xaml
@@ -3,20 +3,101 @@
 	x:Class="Files.App.UserControls.ShelfPane"
 	xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
 	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-	xmlns:controls="using:Files.App.Controls"
 	xmlns:converters="using:Files.App.Converters"
 	xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
 	xmlns:data="using:Files.App.Data.Items"
 	xmlns:helpers="using:Files.App.Helpers"
 	xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
-	xmlns:usercontrols="using:Files.App.UserControls"
 	mc:Ignorable="d">
 
+	<UserControl.Resources>
+		<converters:ImageModelToImageConverter x:Key="ImageModelToImageConverter" />
+	</UserControl.Resources>
+
 	<Grid
 		Width="240"
+		Padding="12"
+		AllowDrop="True"
 		Background="{ThemeResource App.Theme.InfoPane.BackgroundBrush}"
 		BackgroundSizing="InnerBorderEdge"
 		BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
 		BorderThickness="1"
-		CornerRadius="8" />
+		CornerRadius="8"
+		DragOver="Shelf_DragOver"
+		Drop="Shelf_Drop"
+		RowSpacing="8">
+
+		<Grid.RowDefinitions>
+			<RowDefinition Height="Auto" />
+			<RowDefinition Height="*" />
+			<RowDefinition Height="Auto" />
+		</Grid.RowDefinitions>
+
+		<StackPanel Grid.Row="0" Spacing="8">
+
+			<!--  Title  -->
+			<TextBlock
+				HorizontalAlignment="Center"
+				Foreground="{ThemeResource TextFillColorTertiaryBrush}"
+				Style="{StaticResource App.Theme.BodyTextBlockStyle}"
+				Text="{helpers:ResourceString Name=Shelf}" />
+
+			<!--  (Divider)  -->
+			<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
+
+		</StackPanel>
+
+		<!--  Items List  -->
+		<ListView
+			Grid.Row="1"
+			DragItemsStarting="ListView_DragItemsStarting"
+			ItemsSource="{x:Bind ItemsSource, Mode=OneWay}"
+			ScrollViewer.VerticalScrollBarVisibility="Auto"
+			ScrollViewer.VerticalScrollMode="Auto"
+			SelectionMode="Extended">
+			<ListView.ItemTemplate>
+				<DataTemplate x:DataType="data:ShelfItem">
+					<StackPanel Orientation="Horizontal" Spacing="8">
+						<Image
+							Width="16"
+							Height="16"
+							Source="{x:Bind Icon, Mode=OneWay, Converter={StaticResource ImageModelToImageConverter}}" />
+						<TextBlock Text="{x:Bind Name, Mode=OneWay}" />
+
+						<StackPanel.ContextFlyout>
+							<MenuFlyout>
+								<MenuFlyoutItem Command="{x:Bind RemoveCommand}" Text="{helpers:ResourceString Name=RemoveFromShelf}">
+									<MenuFlyoutItem.Icon>
+										<FontIcon Glyph="&#xE738;" />
+									</MenuFlyoutItem.Icon>
+								</MenuFlyoutItem>
+							</MenuFlyout>
+						</StackPanel.ContextFlyout>
+					</StackPanel>
+				</DataTemplate>
+			</ListView.ItemTemplate>
+
+			<ListView.ItemContainerStyle>
+				<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
+					<Setter Property="Margin" Value="-4,0,-4,0" />
+					<Setter Property="MinHeight" Value="36" />
+				</Style>
+			</ListView.ItemContainerStyle>
+		</ListView>
+
+
+		<StackPanel Grid.Row="2" Spacing="4">
+
+			<!--  (Divider)  -->
+			<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}" />
+
+			<!--  Bottom Actions  -->
+			<HyperlinkButton
+				HorizontalAlignment="Center"
+				VerticalAlignment="Center"
+				Command="{x:Bind ClearCommand, Mode=OneWay}"
+				Content="{helpers:ResourceString Name=ClearItems}" />
+
+		</StackPanel>
+	</Grid>
 </UserControl>
diff --git a/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs b/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs
index 25946f599056..baf685530c70 100644
--- a/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs
+++ b/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs
@@ -1,7 +1,13 @@
 // Copyright (c) Files Community
 // Licensed under the MIT License.
 
+using Microsoft.UI.Xaml;
 using Microsoft.UI.Xaml.Controls;
+using System.Runtime.InteropServices.ComTypes;
+using System.Windows.Input;
+using Vanara.PInvoke;
+using Windows.ApplicationModel.DataTransfer;
+using WinRT;
 
 namespace Files.App.UserControls
 {
@@ -9,7 +15,89 @@ public sealed partial class ShelfPane : UserControl
 	{
 		public ShelfPane()
 		{
+			// TODO: [Shelf] Remove once view model is connected
+			ItemsSource = new ObservableCollection<ShelfItem>();
+
 			InitializeComponent();
 		}
+
+		private void Shelf_DragOver(object sender, DragEventArgs e)
+		{
+			if (!FilesystemHelpers.HasDraggedStorageItems(e.DataView))
+				return;
+
+			e.Handled = true;
+			e.DragUIOverride.Caption = Strings.AddToShelf.GetLocalizedResource();
+			e.AcceptedOperation = DataPackageOperation.Link;
+		}
+
+		private async void Shelf_Drop(object sender, DragEventArgs e)
+		{
+			if (ItemsSource is null)
+				return;
+
+			// Get items
+			var storageService = Ioc.Default.GetRequiredService<IStorageService>();
+			var storageItems = (await FilesystemHelpers.GetDraggedStorageItems(e.DataView)).ToArray();
+
+			// Add to list
+			foreach (var item in storageItems)
+			{
+				var storable = item switch
+				{
+					StorageFileWithPath => (IStorable?)await storageService.TryGetFileAsync(item.Path),
+					StorageFolderWithPath => (IStorable?)await storageService.TryGetFolderAsync(item.Path),
+					_ => null
+				};
+
+				if (storable is null)
+					continue;
+
+				var shelfItem = new ShelfItem(storable, ItemsSource);
+				_ = shelfItem.InitAsync();
+
+				ItemsSource.Add(shelfItem);
+			}
+		}
+
+		private void ListView_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
+		{
+			if (ItemsSource is null)
+				return;
+
+			var shellItemList = SafetyExtensions.IgnoreExceptions(() => ItemsSource.Select(x => new Vanara.Windows.Shell.ShellItem(x.Inner.Id)).ToArray());
+			if (shellItemList?[0].FileSystemPath is not null)
+			{
+				var iddo = shellItemList[0].Parent?.GetChildrenUIObjects<IDataObject>(HWND.NULL, shellItemList);
+				if (iddo is null)
+					return;
+
+				shellItemList.ForEach(x => x.Dispose());
+				var dataObjectProvider = e.Data.As<Shell32.IDataObjectProvider>();
+				dataObjectProvider.SetDataObject(iddo);
+			}
+			else
+			{
+				// Only support IStorageItem capable paths
+				var storageItems = ItemsSource.Select(x => VirtualStorageItem.FromPath(x.Inner.Id));
+				e.Data.SetStorageItems(storageItems, false);
+			}
+		}
+
+		public IList<ShelfItem>? ItemsSource
+		{
+			get => (IList<ShelfItem>?)GetValue(ItemsSourceProperty);
+			set => SetValue(ItemsSourceProperty, value);
+		}
+		public static readonly DependencyProperty ItemsSourceProperty =
+			DependencyProperty.Register(nameof(ItemsSource), typeof(IList<ShelfItem>), typeof(ShelfPane), new PropertyMetadata(null));
+
+		public ICommand? ClearCommand
+		{
+			get => (ICommand?)GetValue(ClearCommandProperty);
+			set => SetValue(ClearCommandProperty, value);
+		}
+		public static readonly DependencyProperty ClearCommandProperty =
+			DependencyProperty.Register(nameof(ClearCommand), typeof(ICommand), typeof(ShelfPane), new PropertyMetadata(null));
 	}
 }
diff --git a/src/Files.App/Views/MainPage.xaml b/src/Files.App/Views/MainPage.xaml
index 0c9da874c568..74aec3c6d77c 100644
--- a/src/Files.App/Views/MainPage.xaml
+++ b/src/Files.App/Views/MainPage.xaml
@@ -248,6 +248,7 @@
 						ShowInfoText="{x:Bind SidebarAdaptiveViewModel.PaneHolder.ActivePaneOrColumn.InstanceViewModel.IsPageTypeNotHome, Mode=OneWay}"
 						Visibility="{x:Bind SidebarAdaptiveViewModel.PaneHolder.ActivePaneOrColumn.InstanceViewModel.IsPageTypeNotHome, Mode=OneWay}" />
 
+					<!--  Shelf Pane  -->
 					<uc:ShelfPane
 						x:Name="ShelfPane"
 						Grid.Row="0"
diff --git a/src/Files.Shared/Utils/IAsyncInitialize.cs b/src/Files.Shared/Utils/IAsyncInitialize.cs
index 8591c225a96f..86bcfd7a8795 100644
--- a/src/Files.Shared/Utils/IAsyncInitialize.cs
+++ b/src/Files.Shared/Utils/IAsyncInitialize.cs
@@ -1,7 +1,4 @@
-// Copyright (c) Files Community
-// Licensed under the MIT License.
-
-using System.Threading;
+using System.Threading;
 using System.Threading.Tasks;
 
 namespace Files.Shared.Utils
diff --git a/src/Files.Shared/Utils/IWrapper.cs b/src/Files.Shared/Utils/IWrapper.cs
new file mode 100644
index 000000000000..cb9b39701d87
--- /dev/null
+++ b/src/Files.Shared/Utils/IWrapper.cs
@@ -0,0 +1,14 @@
+namespace Files.Shared.Utils
+{
+	/// <summary>
+	/// Wraps and exposes <typeparamref name="T"/> implementation for access.
+	/// </summary>
+	/// <typeparam name="T">The wrapped type.</typeparam>
+	public interface IWrapper<out T>
+	{
+		/// <summary>
+		/// Gets the inner member wrapped by the implementation.
+		/// </summary>
+		T Inner { get; }
+	}
+}