diff --git a/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs new file mode 100644 index 000000000000..4eb342b9073f --- /dev/null +++ b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs @@ -0,0 +1,118 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.IO; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class CopyItemFromHomeAction : ObservableObject, IAction + { + private readonly IContentPageContext context; + private readonly IHomePageContext HomePageContext; + + public string Label + => Strings.Copy.GetLocalizedResource(); + + public string Description + => Strings.CopyItemDescription.GetLocalizedFormatResource(1); + + public RichGlyph Glyph + => new(themedIconStyle: "App.ThemedIcons.Copy"); + public bool IsExecutable + => GetIsExecutable(); + + public CopyItemFromHomeAction() + { + context = Ioc.Default.GetRequiredService(); + HomePageContext = Ioc.Default.GetRequiredService(); + } + + public async Task ExecuteAsync(object? parameter = null) + { + if (HomePageContext.RightClickedItem is null) + return; + + var item = HomePageContext.RightClickedItem; + var itemPath = item.Path; + + if (string.IsNullOrEmpty(itemPath)) + return; + + try + { + var dataPackage = new DataPackage() { RequestedOperation = DataPackageOperation.Copy }; + IStorageItem? storageItem = null; + + var folderResult = await context.ShellPage?.ShellViewModel?.GetFolderFromPathAsync(itemPath)!; + if (folderResult) + storageItem = folderResult.Result; + + if (storageItem is null) + { + await CopyPathFallback(itemPath); + return; + } + + if (storageItem is SystemStorageFolder or SystemStorageFile) + { + var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync(); + if (standardItems.Any()) + storageItem = standardItems.First(); + } + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(new[] { storageItem }, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + await CopyPathFallback(itemPath); + return; + } + + } + } + + private bool GetIsExecutable() + { + var item = HomePageContext.RightClickedItem; + + return HomePageContext.IsAnyItemRightClicked + && item is not null + && !IsNonCopyableLocation(item); + } + + private async Task CopyPathFallback(string path) + { + try + { + await FileOperationsHelpers.SetClipboard(new[] { path }, DataPackageOperation.Copy); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Failed to copy path to clipboard."); + } + } + + private bool IsNonCopyableLocation(WidgetCardItem item) + { + if (string.IsNullOrEmpty(item.Path)) + return true; + + var normalizedPath = Constants.UserEnvironmentPaths.ShellPlaces.GetValueOrDefault( + item.Path.ToUpperInvariant(), + item.Path); + + return string.Equals(normalizedPath, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedPath, Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedPath, Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs new file mode 100644 index 000000000000..0899f6269164 --- /dev/null +++ b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs @@ -0,0 +1,121 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.IO; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class CopyItemFromSidebarAction : ObservableObject, IAction + { + private readonly IContentPageContext context; + private readonly ISidebarContext SidebarContext; + + public string Label + => Strings.Copy.GetLocalizedResource(); + + public string Description + => Strings.CopyItemDescription.GetLocalizedFormatResource(1); + + public RichGlyph Glyph + => new(themedIconStyle: "App.ThemedIcons.Copy"); + public bool IsExecutable + => GetIsExecutable(); + + public CopyItemFromSidebarAction() + { + context = Ioc.Default.GetRequiredService(); + SidebarContext = Ioc.Default.GetRequiredService(); + } + + public async Task ExecuteAsync(object? parameter = null) + { + if (SidebarContext.RightClickedItem is null) + return; + + var item = SidebarContext.RightClickedItem; + var itemPath = item.Path; + + if (string.IsNullOrEmpty(itemPath)) + return; + + try + { + var dataPackage = new DataPackage() { RequestedOperation = DataPackageOperation.Copy }; + IStorageItem? storageItem = null; + + var folderResult = await context.ShellPage?.ShellViewModel?.GetFolderFromPathAsync(itemPath)!; + if (folderResult) + storageItem = folderResult.Result; + + if (storageItem is null) + { + await CopyPathFallback(itemPath); + return; + } + + if (storageItem is SystemStorageFolder or SystemStorageFile) + { + var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync(); + if (standardItems.Any()) + storageItem = standardItems.First(); + } + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(new[] { storageItem }, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + await CopyPathFallback(itemPath); + return; + } + + } + } + + private bool GetIsExecutable() + { + var item = SidebarContext.RightClickedItem; + + return SidebarContext.IsItemRightClicked + && item is not null + && item.MenuOptions.IsLocationItem + && !IsNonCopyableLocation(item); + } + + private async Task CopyPathFallback(string path) + { + try + { + await FileOperationsHelpers.SetClipboard(new[] { path }, DataPackageOperation.Copy); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Failed to copy path to clipboard."); + } + } + + private bool IsNonCopyableLocation(INavigationControlItem item) + { + if (string.IsNullOrEmpty(item.Path)) + return true; + + var normalizedPath = Constants.UserEnvironmentPaths.ShellPlaces.GetValueOrDefault( + item.Path.ToUpperInvariant(), + item.Path); + + return item.Path.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) || + string.Equals(item.Path, "Home", StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedPath, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedPath, Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedPath, Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Files.App/Helpers/PathNormalization.cs b/src/Files.App/Helpers/PathNormalization.cs index b992063950c6..5ce03617e376 100644 --- a/src/Files.App/Helpers/PathNormalization.cs +++ b/src/Files.App/Helpers/PathNormalization.cs @@ -77,6 +77,15 @@ public static string Combine(string folder, string name) if (string.IsNullOrEmpty(folder)) return name; + // Handle case where name is a rooted path (e.g., "E:\") + if (Path.IsPathRooted(name)) + { + var root = Path.GetPathRoot(name); + if (!string.IsNullOrEmpty(root) && name.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) == root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + // Just use the drive letter + name = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':'); + } + return folder.Contains('/', StringComparison.Ordinal) ? Path.Combine(folder, name).Replace("\\", "/", StringComparison.Ordinal) : Path.Combine(folder, name); } } diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 6c07f8ea1d20..1f62966c5bf9 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -736,13 +736,25 @@ public async void HandleItemContextInvokedAsync(object sender, ItemContextInvoke var itemContextMenuFlyout = new CommandBarFlyout() { - Placement = FlyoutPlacementMode.Full + Placement = FlyoutPlacementMode.Right, + AlwaysExpanded = true }; itemContextMenuFlyout.Opening += (sender, e) => App.LastOpenedFlyout = sender as CommandBarFlyout; var menuItems = GetLocationItemMenuItems(item, itemContextMenuFlyout); - var (_, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems); + var (primaryElements, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems); + + // Workaround for WinUI (#5508) - AppBarButtons don't auto-close CommandBarFlyout + var closeHandler = new RoutedEventHandler((s, e) => itemContextMenuFlyout.Hide()); + primaryElements + .OfType() + .ForEach(button => button.Click += closeHandler); + primaryElements + .OfType() + .ForEach(button => button.Click += closeHandler); + + primaryElements.ForEach(itemContextMenuFlyout.PrimaryCommands.Add); secondaryElements .OfType() @@ -952,7 +964,7 @@ private List GetLocationItemMenuItems(INavigatio var isDriveItem = item is DriveItem; var isDriveItemPinned = isDriveItem && ((DriveItem)item).IsPinned; - + return new List() { new ContextMenuFlyoutItemViewModel() @@ -989,6 +1001,11 @@ private List GetLocationItemMenuItems(INavigatio { IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && Commands.OpenInNewPaneFromSidebar.IsExecutable }.Build(), + new ContextMenuFlyoutItemViewModelBuilder(Commands.CopyItemFromSidebar) + { + IsPrimary = true, + IsVisible = Commands.CopyItemFromSidebar.IsExecutable + }.Build(), new ContextMenuFlyoutItemViewModel() { Text = Strings.PinFolderToSidebar.GetLocalizedResource(), diff --git a/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs index f8d36f718095..0ed33641858c 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs @@ -65,7 +65,8 @@ widgetCardItem.DataContext is not WidgetCardItem item || // Create a new Flyout var itemContextMenuFlyout = new CommandBarFlyout() { - Placement = FlyoutPlacementMode.Right + Placement = FlyoutPlacementMode.Right, + AlwaysExpanded = true }; // Hook events @@ -78,7 +79,19 @@ widgetCardItem.DataContext is not WidgetCardItem item || // Get items for the flyout var menuItems = GetItemMenuItems(item, QuickAccessService.IsItemPinned(item.Path), fileTagsCardItem is not null && fileTagsCardItem.IsFolder); - var (_, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems); + var (primaryElements, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems); + + // Workaround for WinUI (#5508) - AppBarButtons don't auto-close CommandBarFlyout + var closeHandler = new RoutedEventHandler((s, e) => itemContextMenuFlyout.Hide()); + primaryElements + .OfType() + .ForEach(button => button.Click += closeHandler); + primaryElements + .OfType() + .ForEach(button => button.Click += closeHandler); + + // Add menu items to the primary flyout + primaryElements.ForEach(itemContextMenuFlyout.PrimaryCommands.Add); // Set max width of the flyout secondaryElements diff --git a/src/Files.App/ViewModels/UserControls/Widgets/DrivesWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/DrivesWidgetViewModel.cs index f223f6c85d1f..9989ae4546aa 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/DrivesWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/DrivesWidgetViewModel.cs @@ -108,6 +108,11 @@ public override List GetItemMenuItems(WidgetCard { IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable }.Build(), + new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome) + { + IsPrimary = true, + IsVisible = CommandManager.CopyItemFromHome.IsExecutable + }.Build(), new() { Text = Strings.PinFolderToSidebar.GetLocalizedResource(), diff --git a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs index 639c0afd69f4..d4ffde53655a 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs @@ -113,6 +113,11 @@ public override List GetItemMenuItems(WidgetCard { IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable }.Build(), + new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome) + { + IsPrimary = true, + IsVisible = CommandManager.CopyItemFromHome.IsExecutable + }.Build(), new() { Text = Strings.PinFolderToSidebar.GetLocalizedResource(), diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index b56a8c62b318..1d28e2b413ee 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -104,6 +104,11 @@ public override List GetItemMenuItems(WidgetCard { IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable }.Build(), + new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome) + { + IsPrimary = true, + IsVisible = CommandManager.CopyItemFromHome.IsExecutable + }.Build(), new() { Text = Strings.PinFolderToSidebar.GetLocalizedResource(), diff --git a/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs index bec67ac1f66d..7fefcf05394b 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs @@ -76,6 +76,11 @@ public override List GetItemMenuItems(WidgetCard { return new List() { + new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome) + { + IsPrimary = true, + IsVisible = CommandManager.CopyItemFromHome.IsExecutable + }.Build(), new() { Text = Strings.OpenWith.GetLocalizedResource(),