Make drag-and-drop follow Windows Explorer and refresh the tree after folder operations.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -183,6 +183,10 @@
|
||||
SelectedItemChanged="OnTreeSelected"
|
||||
TreeViewItem.Expanded="OnTreeExpanded"
|
||||
ContextMenuOpening="OnTreeContextOpening"
|
||||
AllowDrop="True"
|
||||
DragEnter="OnTreeDragOver"
|
||||
DragOver="OnTreeDragOver"
|
||||
Drop="OnTreeDrop"
|
||||
HorizontalContentAlignment="Stretch">
|
||||
<TreeView.ContextMenu>
|
||||
<ContextMenu>
|
||||
@@ -244,6 +248,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
@@ -268,7 +275,7 @@
|
||||
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<MenuItem Header="Paste" Command="{Binding DataContext.PasteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<MenuItem Header="Delete" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
|
||||
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
@@ -294,6 +301,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
@@ -316,6 +326,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
GotFocus="OnPaneFocus"
|
||||
@@ -360,6 +373,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
@@ -383,6 +399,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
@@ -405,6 +424,9 @@
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||
ContextMenuOpening="OnListContextMenuOpening"
|
||||
DragEnter="OnListDragOver"
|
||||
Drop="OnListDrop"
|
||||
DragOver="OnListDragOver"
|
||||
GotFocus="OnPaneFocus"
|
||||
|
||||
@@ -14,6 +14,11 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private Point _dragStart;
|
||||
private bool _dragPending;
|
||||
private MouseButton _dragButton;
|
||||
private FolderItemViewModel? _dragItem;
|
||||
private bool _suppressItemContextMenu;
|
||||
private bool _incomingRightDrag;
|
||||
private bool _sourceRightDrag;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -132,6 +137,13 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnTreeContextOpening(object sender, ContextMenuEventArgs e)
|
||||
{
|
||||
if (_suppressItemContextMenu)
|
||||
{
|
||||
e.Handled = true;
|
||||
_suppressItemContextMenu = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var node = FindTreeNode(e.OriginalSource as DependencyObject);
|
||||
if (node is null || !node.CanRemove)
|
||||
{
|
||||
@@ -378,34 +390,85 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_suppressItemContextMenu = false;
|
||||
_dragStart = e.GetPosition(null);
|
||||
_dragPending = true;
|
||||
_dragButton = e.ChangedButton;
|
||||
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
||||
}
|
||||
|
||||
private void OnListContextMenuOpening(object sender, ContextMenuEventArgs e)
|
||||
{
|
||||
if (_suppressItemContextMenu)
|
||||
{
|
||||
e.Handled = true;
|
||||
_suppressItemContextMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnListDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 ? DragDropEffects.Move : DragDropEffects.Copy;
|
||||
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ActivatePaneFromList(list);
|
||||
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||
var dest = ResolveDropDirectory(list, e);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sourceRightDrag || _incomingRightDrag)
|
||||
{
|
||||
e.Effects = DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link;
|
||||
}
|
||||
else
|
||||
{
|
||||
e.Effects = ToEffects(ResolveDropAction(e, files, dest));
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnListDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop) || sender is not ListView list)
|
||||
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ActivatePaneFromList(list);
|
||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
|
||||
var move = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 || e.AllowedEffects == DragDropEffects.Move;
|
||||
await Vm.DropAsync(files, Vm.ActivePane.CurrentPath, move).ConfigureAwait(true);
|
||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||
var dest = ResolveDropDirectory(list, e);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var right = IsRightDrop(e);
|
||||
_incomingRightDrag = false;
|
||||
_sourceRightDrag = false;
|
||||
if (right)
|
||||
{
|
||||
ShowDropMenu(list, e.GetPosition(list), files, dest);
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseMove(e);
|
||||
if (!_dragPending || e.LeftButton != MouseButtonState.Pressed)
|
||||
var held = _dragButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed
|
||||
|| _dragButton == MouseButton.Right && e.RightButton == MouseButtonState.Pressed;
|
||||
if (!_dragPending || !held)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -418,14 +481,219 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
_dragPending = false;
|
||||
var paths = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToArray();
|
||||
if (paths.Length == 0)
|
||||
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
||||
IReadOnlyList<string> paths;
|
||||
if (_dragItem is not null && (selected.Count == 0
|
||||
|| selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
paths = [_dragItem.FullPath];
|
||||
}
|
||||
else
|
||||
{
|
||||
paths = selected;
|
||||
}
|
||||
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new DataObject(DataFormats.FileDrop, paths);
|
||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move);
|
||||
_sourceRightDrag = _dragButton == MouseButton.Right;
|
||||
_suppressItemContextMenu = _sourceRightDrag;
|
||||
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
|
||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
||||
_sourceRightDrag = false;
|
||||
_incomingRightDrag = false;
|
||||
}
|
||||
|
||||
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
||||
{
|
||||
if (e.EscapePressed)
|
||||
{
|
||||
e.Action = DragAction.Cancel;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var buttonHeld = (e.KeyStates & DragDropKeyStates.LeftMouseButton) != 0
|
||||
|| (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||
e.Action = buttonHeld ? DragAction.Continue : DragAction.Drop;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseUp(MouseButtonEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseUp(e);
|
||||
if (e.ChangedButton == _dragButton)
|
||||
{
|
||||
_dragPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetDropFiles(DragEventArgs e, out string[] files)
|
||||
{
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] dropped && dropped.Length > 0)
|
||||
{
|
||||
files = dropped;
|
||||
return true;
|
||||
}
|
||||
|
||||
files = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
|
||||
{
|
||||
var item = HitTestFolderItem(list, e.GetPosition(list));
|
||||
if (item is { IsDirectory: true })
|
||||
{
|
||||
return item.FullPath;
|
||||
}
|
||||
|
||||
var tab = Vm.ActiveTab;
|
||||
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
|
||||
return LocationRoots.IsVirtual(path) ? null : path;
|
||||
}
|
||||
|
||||
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
|
||||
{
|
||||
if (origin is not Visual visual)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hit = VisualTreeHelper.HitTest(visual, point)?.VisualHit as DependencyObject;
|
||||
while (hit is not null)
|
||||
{
|
||||
if (hit is ListViewItem { DataContext: FolderItemViewModel item })
|
||||
{
|
||||
return item;
|
||||
}
|
||||
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DragDropEffects ToEffects(DropAction action)
|
||||
=> action switch
|
||||
{
|
||||
DropAction.Copy => DragDropEffects.Copy,
|
||||
DropAction.Move => DragDropEffects.Move,
|
||||
DropAction.Link => DragDropEffects.Link,
|
||||
_ => DragDropEffects.None
|
||||
};
|
||||
|
||||
private DropAction ResolveDropAction(DragEventArgs e, IReadOnlyList<string> files, string dest)
|
||||
{
|
||||
var sameVolume = files.All(f => PathRules.IsSameVolume(f, dest));
|
||||
return DragDropPolicy.Resolve(
|
||||
(e.KeyStates & DragDropKeyStates.ControlKey) != 0,
|
||||
(e.KeyStates & DragDropKeyStates.ShiftKey) != 0,
|
||||
(e.KeyStates & DragDropKeyStates.AltKey) != 0,
|
||||
sameVolume);
|
||||
}
|
||||
|
||||
private bool IsRightDrop(DragEventArgs e)
|
||||
=> _sourceRightDrag || _incomingRightDrag || (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||
|
||||
private void ShowDropMenu(FrameworkElement target, Point position, IReadOnlyList<string> files, string dest)
|
||||
{
|
||||
var menu = new ContextMenu
|
||||
{
|
||||
PlacementTarget = target,
|
||||
Placement = PlacementMode.RelativePoint,
|
||||
HorizontalOffset = position.X,
|
||||
VerticalOffset = position.Y
|
||||
};
|
||||
|
||||
void Add(string header, DropAction action)
|
||||
{
|
||||
var item = new MenuItem { Header = header };
|
||||
item.Click += async (_, _) => await ApplyDropAsync(files, dest, action).ConfigureAwait(true);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
|
||||
Add("Copy here", DropAction.Copy);
|
||||
Add("Move here", DropAction.Move);
|
||||
Add("Create shortcuts here", DropAction.Link);
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(new MenuItem { Header = "Cancel" });
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
|
||||
private async Task ApplyDropAsync(IReadOnlyList<string> files, string dest, DropAction action)
|
||||
{
|
||||
if (action == DropAction.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Vm.DropAsync(files, dest, action).ConfigureAwait(true);
|
||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private void OnTreeDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (!TryGetDropFiles(e, out var files))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||
var dest = HitTestTreePath(e);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
e.Effects = _incomingRightDrag || _sourceRightDrag
|
||||
? DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link
|
||||
: ToEffects(ResolveDropAction(e, files, dest));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnTreeDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (!TryGetDropFiles(e, out var files))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dest = HitTestTreePath(e);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var right = IsRightDrop(e);
|
||||
_incomingRightDrag = false;
|
||||
_sourceRightDrag = false;
|
||||
if (right)
|
||||
{
|
||||
ShowDropMenu(NavTree, e.GetPosition(NavTree), files, dest);
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private string? HitTestTreePath(DragEventArgs e)
|
||||
{
|
||||
var origin = NavTree.InputHitTest(e.GetPosition(NavTree)) as DependencyObject
|
||||
?? e.OriginalSource as DependencyObject;
|
||||
var node = FindTreeNode(origin);
|
||||
if (node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.Path;
|
||||
}
|
||||
|
||||
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
@@ -492,6 +760,33 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnCtxOpen(object sender, RoutedEventArgs e) => Vm.OpenSelectedCommand.Execute(null);
|
||||
|
||||
private async void OnCtxDelete(object sender, RoutedEventArgs e)
|
||||
=> await DeleteSelectedAsync().ConfigureAwait(true);
|
||||
|
||||
private async Task DeleteSelectedAsync()
|
||||
{
|
||||
if (Vm.ActivePane.SelectedItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var permanent = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
if (permanent)
|
||||
{
|
||||
var count = Vm.ActivePane.SelectedItems.Count;
|
||||
var name = Vm.ActivePane.SelectedItems[0].Name;
|
||||
var text = count == 1
|
||||
? $"Are you sure you want to permanently delete '{name}'?"
|
||||
: $"Are you sure you want to permanently delete these {count} items?";
|
||||
if (MessageBox.Show(this, text, "Delete Permanently", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Vm.DeleteSelectedAsync(permanent).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private void OnCtxRename(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
||||
@@ -576,7 +871,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
else if (e.Key == Key.Delete)
|
||||
{
|
||||
await Vm.DeleteAsync().ConfigureAwait(true);
|
||||
await DeleteSelectedAsync().ConfigureAwait(true);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (ctrl && e.Key == Key.T)
|
||||
{
|
||||
|
||||
@@ -85,6 +85,8 @@ public interface IShellFileOperations
|
||||
{
|
||||
void Open(string path);
|
||||
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
|
||||
bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error);
|
||||
bool CreateShortcut(string targetPath, string shortcutPath, out string? error);
|
||||
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
|
||||
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
|
||||
}
|
||||
|
||||
57
src/Explorer.Domain/DragDropPolicy.cs
Normal file
57
src/Explorer.Domain/DragDropPolicy.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace Explorer.Domain;
|
||||
|
||||
public enum DropAction
|
||||
{
|
||||
None,
|
||||
Copy,
|
||||
Move,
|
||||
Link
|
||||
}
|
||||
|
||||
public static class DragDropPolicy
|
||||
{
|
||||
public static DropAction Resolve(bool control, bool shift, bool alt, bool sameVolume)
|
||||
{
|
||||
if (alt || (control && shift))
|
||||
{
|
||||
return DropAction.Link;
|
||||
}
|
||||
|
||||
if (control)
|
||||
{
|
||||
return DropAction.Copy;
|
||||
}
|
||||
|
||||
if (shift)
|
||||
{
|
||||
return DropAction.Move;
|
||||
}
|
||||
|
||||
return sameVolume ? DropAction.Move : DropAction.Copy;
|
||||
}
|
||||
|
||||
public static bool IsInvalidTarget(IReadOnlyList<string> sources, string destinationDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(destinationDirectory) || LocationRoots.IsVirtual(destinationDirectory))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var dest = PathRules.FromExtended(destinationDirectory).TrimEnd('\\');
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var src = PathRules.FromExtended(source).TrimEnd('\\');
|
||||
if (src.Equals(dest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dest.StartsWith(src + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,9 @@ public sealed class FileSystemItem
|
||||
public int ReparseTag { get; init; }
|
||||
public long? AllocatedSizeBytes { get; init; }
|
||||
public CloudPresence? Cloud { get; init; }
|
||||
public LocationInfo Location { get; init; } = LocationInfo.None;
|
||||
public SizeKnowledge SizeKnowledge { get; init; } = SizeKnowledge.Calculated;
|
||||
public string? DisplayName { get; init; }
|
||||
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
||||
}
|
||||
|
||||
|
||||
88
src/Explorer.Domain/LocationInfo.cs
Normal file
88
src/Explorer.Domain/LocationInfo.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
namespace Explorer.Domain;
|
||||
|
||||
public enum SizeKnowledge
|
||||
{
|
||||
Calculated = 0,
|
||||
Partial = 1,
|
||||
Unknown = 2
|
||||
}
|
||||
|
||||
public sealed record LocationInfo(
|
||||
bool IsHidden,
|
||||
bool IsSystem,
|
||||
bool IsProtected,
|
||||
bool AccessDenied,
|
||||
bool IsRecycleBin)
|
||||
{
|
||||
public static LocationInfo None { get; } = new(false, false, false, false, false);
|
||||
}
|
||||
|
||||
public static class LocationClassifier
|
||||
{
|
||||
private static readonly HashSet<string> ProtectedNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"System Volume Information",
|
||||
"$RECYCLE.BIN",
|
||||
"Recycle.Bin",
|
||||
"Recycler",
|
||||
"Recovery",
|
||||
"Config.Msi",
|
||||
"$WinREAgent",
|
||||
"$WINDOWS.~BT",
|
||||
"$WINDOWS.~WS"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ProtectedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"pagefile.sys",
|
||||
"hiberfil.sys",
|
||||
"swapfile.sys",
|
||||
"DumpStack.log",
|
||||
"DumpStack.log.tmp"
|
||||
};
|
||||
|
||||
public static LocationInfo Classify(
|
||||
string fullPath,
|
||||
string name,
|
||||
int attributes,
|
||||
bool isDirectory,
|
||||
bool accessDenied = false)
|
||||
{
|
||||
var hidden = (attributes & AttributeFlags.Hidden) != 0;
|
||||
var system = (attributes & AttributeFlags.System) != 0;
|
||||
var recycle = IsRecycleBinName(name);
|
||||
var known = recycle
|
||||
|| ProtectedNames.Contains(name)
|
||||
|| (!isDirectory && ProtectedFiles.Contains(name));
|
||||
var protectedLocation = known
|
||||
|| accessDenied
|
||||
|| (isDirectory && hidden && system && IsVolumeRootChild(fullPath));
|
||||
return new LocationInfo(hidden, system, protectedLocation, accessDenied, recycle);
|
||||
}
|
||||
|
||||
public static bool IsRecycleBinName(string name)
|
||||
=> name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("Recycle.Bin", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Equals("Recycler", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsVolumeRootChild(string fullPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parent = PathRules.Parent(fullPath);
|
||||
if (PathRules.IsDriveRoot(parent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!PathRules.IsUnc(parent))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent.Equals(PathRules.CanonicalUncRoot(parent), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,42 @@ public static class PathRules
|
||||
return p.Length == 2 && p[1] == ':';
|
||||
}
|
||||
|
||||
public static string VolumeRoot(string path)
|
||||
{
|
||||
var p = FromExtended(path);
|
||||
if (IsUnc(p))
|
||||
{
|
||||
return CanonicalUncRoot(p);
|
||||
}
|
||||
|
||||
var trimmed = p.TrimEnd('\\');
|
||||
if (trimmed.Length >= 2 && trimmed[1] == ':' && char.IsAsciiLetter(trimmed[0]))
|
||||
{
|
||||
return char.ToUpperInvariant(trimmed[0]) + ":";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static bool IsSameVolume(string first, string second)
|
||||
=> string.Equals(VolumeRoot(first), VolumeRoot(second), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static int DriveLetterSortKey(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
var p = FromExtended(path).TrimEnd('\\');
|
||||
if (p.Length >= 2 && p[1] == ':' && char.IsAsciiLetter(p[0]))
|
||||
{
|
||||
return char.ToUpperInvariant(p[0]);
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
public static string EnsureDirectoryTrailingSlashIfRoot(string path)
|
||||
{
|
||||
var p = FromExtended(path);
|
||||
|
||||
@@ -31,8 +31,36 @@ public sealed class FileOperationService
|
||||
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
||||
|
||||
public Task DeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
=> _queue.EnqueueDeleteAsync(paths, cancellationToken);
|
||||
public Task DeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||
=> _queue.EnqueueDeleteAsync(paths, permanent, cancellationToken);
|
||||
|
||||
public Task CreateShortcutsAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var dest = UniqueShortcutPath(destinationDirectory, PathRules.GetFileName(source));
|
||||
if (!_shell.CreateShortcut(source, dest, out var error) && error is not null)
|
||||
{
|
||||
throw new IOException(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string UniqueShortcutPath(string directory, string sourceName)
|
||||
{
|
||||
var stem = sourceName + " - Shortcut";
|
||||
var dest = Path.Combine(directory, stem + ".lnk");
|
||||
var i = 2;
|
||||
while (File.Exists(PathRules.ToExtended(dest)) || Directory.Exists(PathRules.ToExtended(dest)))
|
||||
{
|
||||
dest = Path.Combine(directory, $"{stem} ({i++}).lnk");
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
public void Rename(string path, string newName)
|
||||
{
|
||||
|
||||
@@ -71,12 +71,13 @@ public sealed class TransferQueue : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnqueueAsync(new TransferJob
|
||||
{
|
||||
Op = TransferOp.Delete,
|
||||
SourcePath = string.Join("|", paths),
|
||||
DestinationPath = permanent ? "permanent" : "recycle",
|
||||
Status = TransferStatus.Queued,
|
||||
CreatedUtc = DateTimeOffset.UtcNow,
|
||||
AdditionalSources = paths.ToList()
|
||||
@@ -291,7 +292,8 @@ public sealed class TransferQueue : BackgroundService
|
||||
var paths = job.AdditionalSources.Count > 0
|
||||
? job.AdditionalSources
|
||||
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!_shell.DeleteToRecycleBin(paths, out var error))
|
||||
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
|
||||
if (!_shell.Delete(paths, recycle, out var error))
|
||||
{
|
||||
job.Status = TransferStatus.Failed;
|
||||
job.Error = error;
|
||||
|
||||
@@ -188,7 +188,11 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
public Task UpAsync() => ActivePane.UpAsync();
|
||||
|
||||
[RelayCommand]
|
||||
public Task RefreshAsync() => ActivePane.RefreshAsync();
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||
await Tree.RefreshAfterChangesAsync(AffectedDirectories(ActivePane.CurrentPath)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task GoAsync()
|
||||
@@ -275,10 +279,20 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public Task DeleteAsync()
|
||||
public Task DeleteAsync() => DeleteSelectedAsync(permanent: false);
|
||||
|
||||
public Task DeleteSelectedAsync(bool permanent)
|
||||
{
|
||||
var paths = SelectedPaths();
|
||||
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
|
||||
var paths = SelectedPaths()
|
||||
.Where(p => !LocationRoots.IsVirtual(p) && !PathRules.IsDriveRoot(p))
|
||||
.ToList();
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Footer = permanent ? "Deleting permanently…" : "Moving to Recycle Bin…";
|
||||
return _ops.DeleteAsync(paths, permanent);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -303,7 +317,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
_ops.NewFolder(ActivePane.CurrentPath);
|
||||
EnqueueReconcile(ActivePane.CurrentPath);
|
||||
_ = ActivePane.RefreshAsync();
|
||||
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
||||
}
|
||||
|
||||
public void RenameSelected(string newName)
|
||||
@@ -316,7 +330,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
_ops.Rename(item.FullPath, newName);
|
||||
EnqueueReconcile(ActivePane.CurrentPath);
|
||||
_ = ActivePane.RefreshAsync();
|
||||
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -619,10 +633,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
foreach (var pane in new[] { tab.Left, tab.Right })
|
||||
{
|
||||
if (LocationRoots.IsVirtual(pane.CurrentPath))
|
||||
{
|
||||
await pane.RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
await pane.RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,17 +649,29 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
|
||||
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, DropAction action)
|
||||
{
|
||||
if (files.Count == 0)
|
||||
if (files.Count == 0 || action is DropAction.None || DragDropPolicy.IsInvalidTarget(files, targetDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (move)
|
||||
Footer = action switch
|
||||
{
|
||||
DropAction.Move => "Moving…",
|
||||
DropAction.Link => "Creating shortcuts…",
|
||||
_ => "Copying…"
|
||||
};
|
||||
|
||||
if (action == DropAction.Move)
|
||||
{
|
||||
await _ops.MoveAsync(files, targetDirectory).ConfigureAwait(true);
|
||||
}
|
||||
else if (action == DropAction.Link)
|
||||
{
|
||||
await _ops.CreateShortcutsAsync(files, targetDirectory).ConfigureAwait(true);
|
||||
await RefreshFolderViewsAsync(targetDirectory).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
|
||||
@@ -697,8 +720,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
foreach (var dir in AffectedDirectories(part))
|
||||
{
|
||||
dirs.Add(dir);
|
||||
}
|
||||
@@ -706,7 +728,11 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
Add(job.SourcePath);
|
||||
Add(job.DestinationPath);
|
||||
if (job.Op != TransferOp.Delete)
|
||||
{
|
||||
Add(job.DestinationPath);
|
||||
}
|
||||
|
||||
foreach (var extra in job.AdditionalSources)
|
||||
{
|
||||
Add(extra);
|
||||
@@ -717,12 +743,54 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
EnqueueReconcile(dir);
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Failed)
|
||||
{
|
||||
Footer = job.Error ?? "Delete failed.";
|
||||
}
|
||||
else if (job.Op == TransferOp.Delete)
|
||||
{
|
||||
Footer = string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
|
||||
? "Deleted permanently."
|
||||
: "Moved to Recycle Bin.";
|
||||
}
|
||||
|
||||
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||
if (ActiveTab.IsSplit)
|
||||
{
|
||||
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
||||
await other.RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
await Tree.RefreshAfterChangesAsync(dirs).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task RefreshFolderViewsAsync(string directory)
|
||||
{
|
||||
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||
await Tree.RefreshAfterChangesAsync(AffectedDirectories(directory)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> AffectedDirectories(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var folder = Directory.Exists(path) ? path : PathRules.Parent(path);
|
||||
if (string.IsNullOrWhiteSpace(folder) || LocationRoots.IsVirtual(folder))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return folder;
|
||||
var parent = PathRules.Parent(folder);
|
||||
if (!string.IsNullOrWhiteSpace(parent)
|
||||
&& !LocationRoots.IsVirtual(parent)
|
||||
&& !NavigationTreeViewModel.PathsEqual(parent, folder))
|
||||
{
|
||||
yield return parent;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueReconcile(string path)
|
||||
|
||||
@@ -75,12 +75,17 @@ public sealed class NavigationTreeViewModel
|
||||
Roots.Add(thisPc);
|
||||
|
||||
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
|
||||
foreach (var source in sources.Where(s => !s.Kind.IsNetwork()))
|
||||
foreach (var source in sources.Where(s => !s.Kind.IsNetwork())
|
||||
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
|
||||
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
|
||||
}
|
||||
|
||||
var network = sources.Where(s => s.Kind.IsNetwork()).ToList();
|
||||
var network = sources.Where(s => s.Kind.IsNetwork())
|
||||
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
|
||||
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
if (prefs.GroupNetworkPlaces && network.Count > 0)
|
||||
{
|
||||
var group = new NavNodeViewModel
|
||||
@@ -174,7 +179,7 @@ public sealed class NavigationTreeViewModel
|
||||
{
|
||||
var child = new NavNodeViewModel
|
||||
{
|
||||
Label = dir.Name,
|
||||
Label = dir.DisplayName ?? dir.Name,
|
||||
Path = dir.FullPath
|
||||
};
|
||||
AddPlaceholder(child);
|
||||
@@ -184,6 +189,78 @@ public sealed class NavigationTreeViewModel
|
||||
node.ChildrenLoaded = true;
|
||||
}
|
||||
|
||||
public async Task RefreshAfterChangesAsync(IEnumerable<string> directories)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in directories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw) || LocationRoots.IsVirtual(raw))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dir = PathRules.EnsureDirectoryTrailingSlashIfRoot(PathRules.FromExtended(raw).TrimEnd('\\'));
|
||||
if (!seen.Add(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var node = FindByPath(Roots, dir);
|
||||
if (node is null || !node.ChildrenLoaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await SyncChildrenAsync(node).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncChildrenAsync(NavNodeViewModel node)
|
||||
{
|
||||
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var listing = await _browse.ListAsync(node.Path).ConfigureAwait(true);
|
||||
var dirs = listing.Items
|
||||
.Where(i => i.IsDirectory)
|
||||
.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Take(200)
|
||||
.ToList();
|
||||
|
||||
var previous = node.Children.Where(c => !c.IsPlaceholder).ToList();
|
||||
var next = new List<NavNodeViewModel>(dirs.Count);
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var match = previous.FirstOrDefault(c => PathsEqual(c.Path, dir.FullPath));
|
||||
if (match is null)
|
||||
{
|
||||
match = new NavNodeViewModel
|
||||
{
|
||||
Label = dir.DisplayName ?? dir.Name,
|
||||
Path = dir.FullPath
|
||||
};
|
||||
AddPlaceholder(match);
|
||||
}
|
||||
else
|
||||
{
|
||||
match.Label = dir.DisplayName ?? dir.Name;
|
||||
match.Path = dir.FullPath;
|
||||
}
|
||||
|
||||
next.Add(match);
|
||||
}
|
||||
|
||||
node.Children.Clear();
|
||||
foreach (var child in next)
|
||||
{
|
||||
node.Children.Add(child);
|
||||
}
|
||||
|
||||
node.ChildrenLoaded = true;
|
||||
}
|
||||
|
||||
public async Task RevealPathAsync(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
|
||||
|
||||
@@ -86,6 +86,9 @@ internal static partial class NativeMethods
|
||||
public const uint ShgfiUseFileAttributes = 0x000000010;
|
||||
public const uint FileAttributeNormal = 0x00000080;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern nint GetForegroundWindow();
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int SHFileOperation(ref ShFileOpStruct lpFileOp);
|
||||
|
||||
@@ -113,8 +116,8 @@ internal static partial class NativeMethods
|
||||
{
|
||||
public nint hwnd;
|
||||
public uint wFunc;
|
||||
public string pFrom;
|
||||
public string pTo;
|
||||
public nint pFrom;
|
||||
public nint pTo;
|
||||
public ushort fFlags;
|
||||
public int fAnyOperationsAborted;
|
||||
public nint hNameMappings;
|
||||
|
||||
@@ -23,6 +23,9 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
||||
}
|
||||
|
||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error)
|
||||
=> Delete(paths, recycle: true, out error);
|
||||
|
||||
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (paths.Count == 0)
|
||||
@@ -30,28 +33,48 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
||||
return true;
|
||||
}
|
||||
|
||||
var joined = string.Join("\0", paths.Select(PathRules.FromExtended)) + "\0\0";
|
||||
var op = new NativeMethods.ShFileOpStruct
|
||||
var packed = string.Join("\0", paths.Select(PathRules.FromExtended)) + "\0\0";
|
||||
var pFrom = Marshal.StringToHGlobalUni(packed);
|
||||
try
|
||||
{
|
||||
hwnd = 0,
|
||||
wFunc = NativeMethods.FoDelete,
|
||||
pFrom = joined,
|
||||
pTo = null!,
|
||||
fFlags = (ushort)(NativeMethods.FofAllowUndo | NativeMethods.FofNoConfirmation | NativeMethods.FofNoErrorUi | NativeMethods.FofSilent),
|
||||
fAnyOperationsAborted = 0,
|
||||
hNameMappings = 0,
|
||||
lpszProgressTitle = null
|
||||
};
|
||||
var flags = NativeMethods.FofNoConfirmation | NativeMethods.FofNoErrorUi;
|
||||
if (recycle)
|
||||
{
|
||||
flags |= NativeMethods.FofAllowUndo;
|
||||
}
|
||||
|
||||
var rc = NativeMethods.SHFileOperation(ref op);
|
||||
if (rc != 0)
|
||||
{
|
||||
error = $"Recycle failed ({rc})";
|
||||
_logger.LogWarning("SHFileOperation delete returned {Code}", rc);
|
||||
return false;
|
||||
var op = new NativeMethods.ShFileOpStruct
|
||||
{
|
||||
hwnd = NativeMethods.GetForegroundWindow(),
|
||||
wFunc = (uint)NativeMethods.FoDelete,
|
||||
pFrom = pFrom,
|
||||
pTo = 0,
|
||||
fFlags = (ushort)flags,
|
||||
fAnyOperationsAborted = 0,
|
||||
hNameMappings = 0,
|
||||
lpszProgressTitle = null
|
||||
};
|
||||
|
||||
var rc = NativeMethods.SHFileOperation(ref op);
|
||||
if (op.fAnyOperationsAborted != 0)
|
||||
{
|
||||
error = "Cancelled";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rc != 0)
|
||||
{
|
||||
error = recycle ? $"Recycle failed ({rc})" : $"Delete failed ({rc})";
|
||||
_logger.LogWarning("SHFileOperation delete returned {Code} recycle={Recycle}", rc, recycle);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(pFrom);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
||||
@@ -126,6 +149,34 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("WScript.Shell");
|
||||
if (type is null)
|
||||
{
|
||||
error = "Shortcut service is unavailable.";
|
||||
return false;
|
||||
}
|
||||
|
||||
dynamic shell = Activator.CreateInstance(type)!;
|
||||
dynamic shortcut = shell.CreateShortcut(PathRules.FromExtended(shortcutPath));
|
||||
var target = PathRules.FromExtended(targetPath);
|
||||
shortcut.TargetPath = target;
|
||||
shortcut.WorkingDirectory = Directory.Exists(target) ? target : PathRules.Parent(target);
|
||||
shortcut.Save();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
_logger.LogDebug(ex, "CreateShortcut failed for {Target}", targetPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class BackgroundIo
|
||||
|
||||
Reference in New Issue
Block a user