Skip to content

Commit bb05ce1

Browse files
authored
Feature: Added Copy functionality to sidebar and home page widgets (#17969)
1 parent 472347c commit bb05ce1

File tree

9 files changed

+303
-5
lines changed

9 files changed

+303
-5
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) Files Community
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Logging;
5+
using System.IO;
6+
using Windows.ApplicationModel.DataTransfer;
7+
using Windows.Storage;
8+
9+
namespace Files.App.Actions
10+
{
11+
[GeneratedRichCommand]
12+
internal sealed partial class CopyItemFromHomeAction : ObservableObject, IAction
13+
{
14+
private readonly IContentPageContext context;
15+
private readonly IHomePageContext HomePageContext;
16+
17+
public string Label
18+
=> Strings.Copy.GetLocalizedResource();
19+
20+
public string Description
21+
=> Strings.CopyItemDescription.GetLocalizedFormatResource(1);
22+
23+
public RichGlyph Glyph
24+
=> new(themedIconStyle: "App.ThemedIcons.Copy");
25+
public bool IsExecutable
26+
=> GetIsExecutable();
27+
28+
public CopyItemFromHomeAction()
29+
{
30+
context = Ioc.Default.GetRequiredService<IContentPageContext>();
31+
HomePageContext = Ioc.Default.GetRequiredService<IHomePageContext>();
32+
}
33+
34+
public async Task ExecuteAsync(object? parameter = null)
35+
{
36+
if (HomePageContext.RightClickedItem is null)
37+
return;
38+
39+
var item = HomePageContext.RightClickedItem;
40+
var itemPath = item.Path;
41+
42+
if (string.IsNullOrEmpty(itemPath))
43+
return;
44+
45+
try
46+
{
47+
var dataPackage = new DataPackage() { RequestedOperation = DataPackageOperation.Copy };
48+
IStorageItem? storageItem = null;
49+
50+
var folderResult = await context.ShellPage?.ShellViewModel?.GetFolderFromPathAsync(itemPath)!;
51+
if (folderResult)
52+
storageItem = folderResult.Result;
53+
54+
if (storageItem is null)
55+
{
56+
await CopyPathFallback(itemPath);
57+
return;
58+
}
59+
60+
if (storageItem is SystemStorageFolder or SystemStorageFile)
61+
{
62+
var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync();
63+
if (standardItems.Any())
64+
storageItem = standardItems.First();
65+
}
66+
67+
dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName;
68+
dataPackage.SetStorageItems(new[] { storageItem }, false);
69+
70+
Clipboard.SetContent(dataPackage);
71+
}
72+
catch (Exception ex)
73+
{
74+
if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized)
75+
{
76+
await CopyPathFallback(itemPath);
77+
return;
78+
}
79+
80+
}
81+
}
82+
83+
private bool GetIsExecutable()
84+
{
85+
var item = HomePageContext.RightClickedItem;
86+
87+
return HomePageContext.IsAnyItemRightClicked
88+
&& item is not null
89+
&& !IsNonCopyableLocation(item);
90+
}
91+
92+
private async Task CopyPathFallback(string path)
93+
{
94+
try
95+
{
96+
await FileOperationsHelpers.SetClipboard(new[] { path }, DataPackageOperation.Copy);
97+
}
98+
catch (Exception ex)
99+
{
100+
App.Logger.LogWarning(ex, "Failed to copy path to clipboard.");
101+
}
102+
}
103+
104+
private bool IsNonCopyableLocation(WidgetCardItem item)
105+
{
106+
if (string.IsNullOrEmpty(item.Path))
107+
return true;
108+
109+
var normalizedPath = Constants.UserEnvironmentPaths.ShellPlaces.GetValueOrDefault(
110+
item.Path.ToUpperInvariant(),
111+
item.Path);
112+
113+
return string.Equals(normalizedPath, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) ||
114+
string.Equals(normalizedPath, Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase) ||
115+
string.Equals(normalizedPath, Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase);
116+
}
117+
}
118+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Files Community
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Logging;
5+
using System.IO;
6+
using Windows.ApplicationModel.DataTransfer;
7+
using Windows.Storage;
8+
9+
namespace Files.App.Actions
10+
{
11+
[GeneratedRichCommand]
12+
internal sealed partial class CopyItemFromSidebarAction : ObservableObject, IAction
13+
{
14+
private readonly IContentPageContext context;
15+
private readonly ISidebarContext SidebarContext;
16+
17+
public string Label
18+
=> Strings.Copy.GetLocalizedResource();
19+
20+
public string Description
21+
=> Strings.CopyItemDescription.GetLocalizedFormatResource(1);
22+
23+
public RichGlyph Glyph
24+
=> new(themedIconStyle: "App.ThemedIcons.Copy");
25+
public bool IsExecutable
26+
=> GetIsExecutable();
27+
28+
public CopyItemFromSidebarAction()
29+
{
30+
context = Ioc.Default.GetRequiredService<IContentPageContext>();
31+
SidebarContext = Ioc.Default.GetRequiredService<ISidebarContext>();
32+
}
33+
34+
public async Task ExecuteAsync(object? parameter = null)
35+
{
36+
if (SidebarContext.RightClickedItem is null)
37+
return;
38+
39+
var item = SidebarContext.RightClickedItem;
40+
var itemPath = item.Path;
41+
42+
if (string.IsNullOrEmpty(itemPath))
43+
return;
44+
45+
try
46+
{
47+
var dataPackage = new DataPackage() { RequestedOperation = DataPackageOperation.Copy };
48+
IStorageItem? storageItem = null;
49+
50+
var folderResult = await context.ShellPage?.ShellViewModel?.GetFolderFromPathAsync(itemPath)!;
51+
if (folderResult)
52+
storageItem = folderResult.Result;
53+
54+
if (storageItem is null)
55+
{
56+
await CopyPathFallback(itemPath);
57+
return;
58+
}
59+
60+
if (storageItem is SystemStorageFolder or SystemStorageFile)
61+
{
62+
var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync();
63+
if (standardItems.Any())
64+
storageItem = standardItems.First();
65+
}
66+
67+
dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName;
68+
dataPackage.SetStorageItems(new[] { storageItem }, false);
69+
70+
Clipboard.SetContent(dataPackage);
71+
}
72+
catch (Exception ex)
73+
{
74+
if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized)
75+
{
76+
await CopyPathFallback(itemPath);
77+
return;
78+
}
79+
80+
}
81+
}
82+
83+
private bool GetIsExecutable()
84+
{
85+
var item = SidebarContext.RightClickedItem;
86+
87+
return SidebarContext.IsItemRightClicked
88+
&& item is not null
89+
&& item.MenuOptions.IsLocationItem
90+
&& !IsNonCopyableLocation(item);
91+
}
92+
93+
private async Task CopyPathFallback(string path)
94+
{
95+
try
96+
{
97+
await FileOperationsHelpers.SetClipboard(new[] { path }, DataPackageOperation.Copy);
98+
}
99+
catch (Exception ex)
100+
{
101+
App.Logger.LogWarning(ex, "Failed to copy path to clipboard.");
102+
}
103+
}
104+
105+
private bool IsNonCopyableLocation(INavigationControlItem item)
106+
{
107+
if (string.IsNullOrEmpty(item.Path))
108+
return true;
109+
110+
var normalizedPath = Constants.UserEnvironmentPaths.ShellPlaces.GetValueOrDefault(
111+
item.Path.ToUpperInvariant(),
112+
item.Path);
113+
114+
return item.Path.StartsWith("tag:", StringComparison.OrdinalIgnoreCase) ||
115+
string.Equals(item.Path, "Home", StringComparison.OrdinalIgnoreCase) ||
116+
string.Equals(normalizedPath, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase) ||
117+
string.Equals(normalizedPath, Constants.UserEnvironmentPaths.NetworkFolderPath, StringComparison.OrdinalIgnoreCase) ||
118+
string.Equals(normalizedPath, Constants.UserEnvironmentPaths.MyComputerPath, StringComparison.OrdinalIgnoreCase);
119+
}
120+
}
121+
}

src/Files.App/Helpers/PathNormalization.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ public static string Combine(string folder, string name)
7777
if (string.IsNullOrEmpty(folder))
7878
return name;
7979

80+
// Handle case where name is a rooted path (e.g., "E:\")
81+
if (Path.IsPathRooted(name))
82+
{
83+
var root = Path.GetPathRoot(name);
84+
if (!string.IsNullOrEmpty(root) && name.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) == root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
85+
// Just use the drive letter
86+
name = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':');
87+
}
88+
8089
return folder.Contains('/', StringComparison.Ordinal) ? Path.Combine(folder, name).Replace("\\", "/", StringComparison.Ordinal) : Path.Combine(folder, name);
8190
}
8291
}

src/Files.App/ViewModels/UserControls/SidebarViewModel.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -736,13 +736,25 @@ public async void HandleItemContextInvokedAsync(object sender, ItemContextInvoke
736736

737737
var itemContextMenuFlyout = new CommandBarFlyout()
738738
{
739-
Placement = FlyoutPlacementMode.Full
739+
Placement = FlyoutPlacementMode.Right,
740+
AlwaysExpanded = true
740741
};
741742

742743
itemContextMenuFlyout.Opening += (sender, e) => App.LastOpenedFlyout = sender as CommandBarFlyout;
743744

744745
var menuItems = GetLocationItemMenuItems(item, itemContextMenuFlyout);
745-
var (_, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems);
746+
var (primaryElements, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems);
747+
748+
// Workaround for WinUI (#5508) - AppBarButtons don't auto-close CommandBarFlyout
749+
var closeHandler = new RoutedEventHandler((s, e) => itemContextMenuFlyout.Hide());
750+
primaryElements
751+
.OfType<AppBarButton>()
752+
.ForEach(button => button.Click += closeHandler);
753+
primaryElements
754+
.OfType<AppBarToggleButton>()
755+
.ForEach(button => button.Click += closeHandler);
756+
757+
primaryElements.ForEach(itemContextMenuFlyout.PrimaryCommands.Add);
746758

747759
secondaryElements
748760
.OfType<FrameworkElement>()
@@ -952,7 +964,7 @@ private List<ContextMenuFlyoutItemViewModel> GetLocationItemMenuItems(INavigatio
952964

953965
var isDriveItem = item is DriveItem;
954966
var isDriveItemPinned = isDriveItem && ((DriveItem)item).IsPinned;
955-
967+
956968
return new List<ContextMenuFlyoutItemViewModel>()
957969
{
958970
new ContextMenuFlyoutItemViewModel()
@@ -989,6 +1001,11 @@ private List<ContextMenuFlyoutItemViewModel> GetLocationItemMenuItems(INavigatio
9891001
{
9901002
IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && Commands.OpenInNewPaneFromSidebar.IsExecutable
9911003
}.Build(),
1004+
new ContextMenuFlyoutItemViewModelBuilder(Commands.CopyItemFromSidebar)
1005+
{
1006+
IsPrimary = true,
1007+
IsVisible = Commands.CopyItemFromSidebar.IsExecutable
1008+
}.Build(),
9921009
new ContextMenuFlyoutItemViewModel()
9931010
{
9941011
Text = Strings.PinFolderToSidebar.GetLocalizedResource(),

src/Files.App/ViewModels/UserControls/Widgets/BaseWidgetViewModel.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ widgetCardItem.DataContext is not WidgetCardItem item ||
6565
// Create a new Flyout
6666
var itemContextMenuFlyout = new CommandBarFlyout()
6767
{
68-
Placement = FlyoutPlacementMode.Right
68+
Placement = FlyoutPlacementMode.Right,
69+
AlwaysExpanded = true
6970
};
7071

7172
// Hook events
@@ -78,7 +79,19 @@ widgetCardItem.DataContext is not WidgetCardItem item ||
7879

7980
// Get items for the flyout
8081
var menuItems = GetItemMenuItems(item, QuickAccessService.IsItemPinned(item.Path), fileTagsCardItem is not null && fileTagsCardItem.IsFolder);
81-
var (_, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems);
82+
var (primaryElements, secondaryElements) = ContextFlyoutModelToElementHelper.GetAppBarItemsFromModel(menuItems);
83+
84+
// Workaround for WinUI (#5508) - AppBarButtons don't auto-close CommandBarFlyout
85+
var closeHandler = new RoutedEventHandler((s, e) => itemContextMenuFlyout.Hide());
86+
primaryElements
87+
.OfType<AppBarButton>()
88+
.ForEach(button => button.Click += closeHandler);
89+
primaryElements
90+
.OfType<AppBarToggleButton>()
91+
.ForEach(button => button.Click += closeHandler);
92+
93+
// Add menu items to the primary flyout
94+
primaryElements.ForEach(itemContextMenuFlyout.PrimaryCommands.Add);
8295

8396
// Set max width of the flyout
8497
secondaryElements

src/Files.App/ViewModels/UserControls/Widgets/DrivesWidgetViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ public override List<ContextMenuFlyoutItemViewModel> GetItemMenuItems(WidgetCard
108108
{
109109
IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable
110110
}.Build(),
111+
new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome)
112+
{
113+
IsPrimary = true,
114+
IsVisible = CommandManager.CopyItemFromHome.IsExecutable
115+
}.Build(),
111116
new()
112117
{
113118
Text = Strings.PinFolderToSidebar.GetLocalizedResource(),

src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ public override List<ContextMenuFlyoutItemViewModel> GetItemMenuItems(WidgetCard
113113
{
114114
IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable
115115
}.Build(),
116+
new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome)
117+
{
118+
IsPrimary = true,
119+
IsVisible = CommandManager.CopyItemFromHome.IsExecutable
120+
}.Build(),
116121
new()
117122
{
118123
Text = Strings.PinFolderToSidebar.GetLocalizedResource(),

src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ public override List<ContextMenuFlyoutItemViewModel> GetItemMenuItems(WidgetCard
104104
{
105105
IsVisible = UserSettingsService.GeneralSettingsService.ShowOpenInNewPane && CommandManager.OpenInNewPaneFromHome.IsExecutable
106106
}.Build(),
107+
new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome)
108+
{
109+
IsPrimary = true,
110+
IsVisible = CommandManager.CopyItemFromHome.IsExecutable
111+
}.Build(),
107112
new()
108113
{
109114
Text = Strings.PinFolderToSidebar.GetLocalizedResource(),

src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ public override List<ContextMenuFlyoutItemViewModel> GetItemMenuItems(WidgetCard
7676
{
7777
return new List<ContextMenuFlyoutItemViewModel>()
7878
{
79+
new ContextMenuFlyoutItemViewModelBuilder(CommandManager.CopyItemFromHome)
80+
{
81+
IsPrimary = true,
82+
IsVisible = CommandManager.CopyItemFromHome.IsExecutable
83+
}.Build(),
7984
new()
8085
{
8186
Text = Strings.OpenWith.GetLocalizedResource(),

0 commit comments

Comments
 (0)