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"
|
SelectedItemChanged="OnTreeSelected"
|
||||||
TreeViewItem.Expanded="OnTreeExpanded"
|
TreeViewItem.Expanded="OnTreeExpanded"
|
||||||
ContextMenuOpening="OnTreeContextOpening"
|
ContextMenuOpening="OnTreeContextOpening"
|
||||||
|
AllowDrop="True"
|
||||||
|
DragEnter="OnTreeDragOver"
|
||||||
|
DragOver="OnTreeDragOver"
|
||||||
|
Drop="OnTreeDrop"
|
||||||
HorizontalContentAlignment="Stretch">
|
HorizontalContentAlignment="Stretch">
|
||||||
<TreeView.ContextMenu>
|
<TreeView.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -244,6 +248,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -268,7 +275,7 @@
|
|||||||
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, 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="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"/>
|
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
@@ -294,6 +301,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -316,6 +326,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
GotFocus="OnPaneFocus"
|
GotFocus="OnPaneFocus"
|
||||||
@@ -360,6 +373,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -383,6 +399,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -405,6 +424,9 @@
|
|||||||
SelectionChanged="OnSelectionChanged"
|
SelectionChanged="OnSelectionChanged"
|
||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
DragEnter="OnListDragOver"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
GotFocus="OnPaneFocus"
|
GotFocus="OnPaneFocus"
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
private Point _dragStart;
|
private Point _dragStart;
|
||||||
private bool _dragPending;
|
private bool _dragPending;
|
||||||
|
private MouseButton _dragButton;
|
||||||
|
private FolderItemViewModel? _dragItem;
|
||||||
|
private bool _suppressItemContextMenu;
|
||||||
|
private bool _incomingRightDrag;
|
||||||
|
private bool _sourceRightDrag;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -132,6 +137,13 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnTreeContextOpening(object sender, ContextMenuEventArgs e)
|
private void OnTreeContextOpening(object sender, ContextMenuEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (_suppressItemContextMenu)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
_suppressItemContextMenu = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var node = FindTreeNode(e.OriginalSource as DependencyObject);
|
var node = FindTreeNode(e.OriginalSource as DependencyObject);
|
||||||
if (node is null || !node.CanRemove)
|
if (node is null || !node.CanRemove)
|
||||||
{
|
{
|
||||||
@@ -378,34 +390,85 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
|
_suppressItemContextMenu = false;
|
||||||
_dragStart = e.GetPosition(null);
|
_dragStart = e.GetPosition(null);
|
||||||
_dragPending = true;
|
_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)
|
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;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnListDrop(object sender, DragEventArgs e)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivatePaneFromList(list);
|
ActivatePaneFromList(list);
|
||||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
|
var dest = ResolveDropDirectory(list, e);
|
||||||
var move = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 || e.AllowedEffects == DragDropEffects.Move;
|
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||||
await Vm.DropAsync(files, Vm.ActivePane.CurrentPath, move).ConfigureAwait(true);
|
{
|
||||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
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)
|
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnPreviewMouseMove(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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -418,14 +481,219 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
_dragPending = false;
|
_dragPending = false;
|
||||||
var paths = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToArray();
|
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
||||||
if (paths.Length == 0)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = new DataObject(DataFormats.FileDrop, paths);
|
_sourceRightDrag = _dragButton == MouseButton.Right;
|
||||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move);
|
_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)
|
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 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)
|
private void OnCtxRename(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
||||||
@@ -576,7 +871,8 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
else if (e.Key == Key.Delete)
|
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)
|
else if (ctrl && e.Key == Key.T)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ public interface IShellFileOperations
|
|||||||
{
|
{
|
||||||
void Open(string path);
|
void Open(string path);
|
||||||
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
|
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 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);
|
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 int ReparseTag { get; init; }
|
||||||
public long? AllocatedSizeBytes { get; init; }
|
public long? AllocatedSizeBytes { get; init; }
|
||||||
public CloudPresence? Cloud { 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;
|
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] == ':';
|
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)
|
public static string EnsureDirectoryTrailingSlashIfRoot(string path)
|
||||||
{
|
{
|
||||||
var p = FromExtended(path);
|
var p = FromExtended(path);
|
||||||
|
|||||||
@@ -31,8 +31,36 @@ public sealed class FileOperationService
|
|||||||
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
||||||
|
|
||||||
public Task DeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
public Task DeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
|
||||||
=> _queue.EnqueueDeleteAsync(paths, cancellationToken);
|
=> _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)
|
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
|
await EnqueueAsync(new TransferJob
|
||||||
{
|
{
|
||||||
Op = TransferOp.Delete,
|
Op = TransferOp.Delete,
|
||||||
SourcePath = string.Join("|", paths),
|
SourcePath = string.Join("|", paths),
|
||||||
|
DestinationPath = permanent ? "permanent" : "recycle",
|
||||||
Status = TransferStatus.Queued,
|
Status = TransferStatus.Queued,
|
||||||
CreatedUtc = DateTimeOffset.UtcNow,
|
CreatedUtc = DateTimeOffset.UtcNow,
|
||||||
AdditionalSources = paths.ToList()
|
AdditionalSources = paths.ToList()
|
||||||
@@ -291,7 +292,8 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
var paths = job.AdditionalSources.Count > 0
|
var paths = job.AdditionalSources.Count > 0
|
||||||
? job.AdditionalSources
|
? job.AdditionalSources
|
||||||
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
|
: 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.Status = TransferStatus.Failed;
|
||||||
job.Error = error;
|
job.Error = error;
|
||||||
|
|||||||
@@ -188,7 +188,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
public Task UpAsync() => ActivePane.UpAsync();
|
public Task UpAsync() => ActivePane.UpAsync();
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public Task RefreshAsync() => ActivePane.RefreshAsync();
|
public async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||||
|
await Tree.RefreshAfterChangesAsync(AffectedDirectories(ActivePane.CurrentPath)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task GoAsync()
|
public async Task GoAsync()
|
||||||
@@ -275,10 +279,20 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public Task DeleteAsync()
|
public Task DeleteAsync() => DeleteSelectedAsync(permanent: false);
|
||||||
|
|
||||||
|
public Task DeleteSelectedAsync(bool permanent)
|
||||||
{
|
{
|
||||||
var paths = SelectedPaths();
|
var paths = SelectedPaths()
|
||||||
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
|
.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]
|
[RelayCommand]
|
||||||
@@ -303,7 +317,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
_ops.NewFolder(ActivePane.CurrentPath);
|
_ops.NewFolder(ActivePane.CurrentPath);
|
||||||
EnqueueReconcile(ActivePane.CurrentPath);
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
_ = ActivePane.RefreshAsync();
|
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RenameSelected(string newName)
|
public void RenameSelected(string newName)
|
||||||
@@ -316,7 +330,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
_ops.Rename(item.FullPath, newName);
|
_ops.Rename(item.FullPath, newName);
|
||||||
EnqueueReconcile(ActivePane.CurrentPath);
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
_ = ActivePane.RefreshAsync();
|
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -618,13 +632,10 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
foreach (var tab in Tabs)
|
foreach (var tab in Tabs)
|
||||||
{
|
{
|
||||||
foreach (var pane in new[] { tab.Left, tab.Right })
|
foreach (var pane in new[] { tab.Left, tab.Right })
|
||||||
{
|
|
||||||
if (LocationRoots.IsVirtual(pane.CurrentPath))
|
|
||||||
{
|
{
|
||||||
await pane.RefreshAsync().ConfigureAwait(true);
|
await pane.RefreshAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Footer = "Settings saved.";
|
Footer = "Settings saved.";
|
||||||
}
|
}
|
||||||
@@ -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;
|
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);
|
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
|
else
|
||||||
{
|
{
|
||||||
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
|
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))
|
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
|
foreach (var dir in AffectedDirectories(part))
|
||||||
if (!string.IsNullOrEmpty(dir))
|
|
||||||
{
|
{
|
||||||
dirs.Add(dir);
|
dirs.Add(dir);
|
||||||
}
|
}
|
||||||
@@ -706,7 +728,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
Add(job.SourcePath);
|
Add(job.SourcePath);
|
||||||
|
if (job.Op != TransferOp.Delete)
|
||||||
|
{
|
||||||
Add(job.DestinationPath);
|
Add(job.DestinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var extra in job.AdditionalSources)
|
foreach (var extra in job.AdditionalSources)
|
||||||
{
|
{
|
||||||
Add(extra);
|
Add(extra);
|
||||||
@@ -717,12 +743,54 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
EnqueueReconcile(dir);
|
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);
|
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||||
if (ActiveTab.IsSplit)
|
if (ActiveTab.IsSplit)
|
||||||
{
|
{
|
||||||
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
||||||
await other.RefreshAsync().ConfigureAwait(true);
|
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)
|
private void EnqueueReconcile(string path)
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ public sealed class NavigationTreeViewModel
|
|||||||
Roots.Add(thisPc);
|
Roots.Add(thisPc);
|
||||||
|
|
||||||
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
|
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)));
|
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)
|
if (prefs.GroupNetworkPlaces && network.Count > 0)
|
||||||
{
|
{
|
||||||
var group = new NavNodeViewModel
|
var group = new NavNodeViewModel
|
||||||
@@ -174,7 +179,7 @@ public sealed class NavigationTreeViewModel
|
|||||||
{
|
{
|
||||||
var child = new NavNodeViewModel
|
var child = new NavNodeViewModel
|
||||||
{
|
{
|
||||||
Label = dir.Name,
|
Label = dir.DisplayName ?? dir.Name,
|
||||||
Path = dir.FullPath
|
Path = dir.FullPath
|
||||||
};
|
};
|
||||||
AddPlaceholder(child);
|
AddPlaceholder(child);
|
||||||
@@ -184,6 +189,78 @@ public sealed class NavigationTreeViewModel
|
|||||||
node.ChildrenLoaded = true;
|
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)
|
public async Task RevealPathAsync(string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
|
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ internal static partial class NativeMethods
|
|||||||
public const uint ShgfiUseFileAttributes = 0x000000010;
|
public const uint ShgfiUseFileAttributes = 0x000000010;
|
||||||
public const uint FileAttributeNormal = 0x00000080;
|
public const uint FileAttributeNormal = 0x00000080;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern nint GetForegroundWindow();
|
||||||
|
|
||||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
public static extern int SHFileOperation(ref ShFileOpStruct lpFileOp);
|
public static extern int SHFileOperation(ref ShFileOpStruct lpFileOp);
|
||||||
|
|
||||||
@@ -113,8 +116,8 @@ internal static partial class NativeMethods
|
|||||||
{
|
{
|
||||||
public nint hwnd;
|
public nint hwnd;
|
||||||
public uint wFunc;
|
public uint wFunc;
|
||||||
public string pFrom;
|
public nint pFrom;
|
||||||
public string pTo;
|
public nint pTo;
|
||||||
public ushort fFlags;
|
public ushort fFlags;
|
||||||
public int fAnyOperationsAborted;
|
public int fAnyOperationsAborted;
|
||||||
public nint hNameMappings;
|
public nint hNameMappings;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error)
|
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;
|
error = null;
|
||||||
if (paths.Count == 0)
|
if (paths.Count == 0)
|
||||||
@@ -30,29 +33,49 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var joined = string.Join("\0", paths.Select(PathRules.FromExtended)) + "\0\0";
|
var packed = string.Join("\0", paths.Select(PathRules.FromExtended)) + "\0\0";
|
||||||
|
var pFrom = Marshal.StringToHGlobalUni(packed);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var flags = NativeMethods.FofNoConfirmation | NativeMethods.FofNoErrorUi;
|
||||||
|
if (recycle)
|
||||||
|
{
|
||||||
|
flags |= NativeMethods.FofAllowUndo;
|
||||||
|
}
|
||||||
|
|
||||||
var op = new NativeMethods.ShFileOpStruct
|
var op = new NativeMethods.ShFileOpStruct
|
||||||
{
|
{
|
||||||
hwnd = 0,
|
hwnd = NativeMethods.GetForegroundWindow(),
|
||||||
wFunc = NativeMethods.FoDelete,
|
wFunc = (uint)NativeMethods.FoDelete,
|
||||||
pFrom = joined,
|
pFrom = pFrom,
|
||||||
pTo = null!,
|
pTo = 0,
|
||||||
fFlags = (ushort)(NativeMethods.FofAllowUndo | NativeMethods.FofNoConfirmation | NativeMethods.FofNoErrorUi | NativeMethods.FofSilent),
|
fFlags = (ushort)flags,
|
||||||
fAnyOperationsAborted = 0,
|
fAnyOperationsAborted = 0,
|
||||||
hNameMappings = 0,
|
hNameMappings = 0,
|
||||||
lpszProgressTitle = null
|
lpszProgressTitle = null
|
||||||
};
|
};
|
||||||
|
|
||||||
var rc = NativeMethods.SHFileOperation(ref op);
|
var rc = NativeMethods.SHFileOperation(ref op);
|
||||||
|
if (op.fAnyOperationsAborted != 0)
|
||||||
|
{
|
||||||
|
error = "Cancelled";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (rc != 0)
|
if (rc != 0)
|
||||||
{
|
{
|
||||||
error = $"Recycle failed ({rc})";
|
error = recycle ? $"Recycle failed ({rc})" : $"Delete failed ({rc})";
|
||||||
_logger.LogWarning("SHFileOperation delete returned {Code}", rc);
|
_logger.LogWarning("SHFileOperation delete returned {Code} recycle={Recycle}", rc, recycle);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(pFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
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;
|
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
|
public static class BackgroundIo
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public class PathRulesTests
|
|||||||
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
|
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
|
||||||
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
|
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
|
||||||
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
|
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
|
||||||
|
Assert.True(PathRules.DriveLetterSortKey(@"D:\") < PathRules.DriveLetterSortKey(@"F:\"));
|
||||||
|
Assert.True(PathRules.DriveLetterSortKey(@"C:\") < PathRules.DriveLetterSortKey(@"\\media\movies"));
|
||||||
Assert.Equal(@"pack.zip\docs", PathRules.RelativeParent(@"pack.zip\docs\a.txt"));
|
Assert.Equal(@"pack.zip\docs", PathRules.RelativeParent(@"pack.zip\docs\a.txt"));
|
||||||
Assert.Equal("pack.zip", PathRules.RelativeParent(@"pack.zip\docs"));
|
Assert.Equal("pack.zip", PathRules.RelativeParent(@"pack.zip\docs"));
|
||||||
Assert.Equal("", PathRules.RelativeParent("pack.zip"));
|
Assert.Equal("", PathRules.RelativeParent("pack.zip"));
|
||||||
@@ -140,6 +142,56 @@ public class VolumeIdentityTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class LocationClassifierTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Classifies_known_protected_locations()
|
||||||
|
{
|
||||||
|
var svi = LocationClassifier.Classify(
|
||||||
|
@"C:\System Volume Information", "System Volume Information",
|
||||||
|
AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System, true);
|
||||||
|
Assert.True(svi.IsProtected);
|
||||||
|
Assert.True(svi.IsHidden);
|
||||||
|
Assert.True(svi.IsSystem);
|
||||||
|
Assert.False(svi.IsRecycleBin);
|
||||||
|
|
||||||
|
var recycle = LocationClassifier.Classify(
|
||||||
|
@"D:\$RECYCLE.BIN", "$RECYCLE.BIN",
|
||||||
|
AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System, true);
|
||||||
|
Assert.True(recycle.IsProtected);
|
||||||
|
Assert.True(recycle.IsRecycleBin);
|
||||||
|
|
||||||
|
var pagefile = LocationClassifier.Classify(
|
||||||
|
@"C:\pagefile.sys", "pagefile.sys", AttributeFlags.Hidden | AttributeFlags.System, false);
|
||||||
|
Assert.True(pagefile.IsProtected);
|
||||||
|
Assert.False(pagefile.IsRecycleBin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Does_not_treat_normal_hidden_user_files_as_protected()
|
||||||
|
{
|
||||||
|
var desktopIni = LocationClassifier.Classify(
|
||||||
|
@"C:\Users\Ada\Desktop\desktop.ini", "desktop.ini",
|
||||||
|
AttributeFlags.Hidden | AttributeFlags.System, false);
|
||||||
|
Assert.True(desktopIni.IsHidden);
|
||||||
|
Assert.True(desktopIni.IsSystem);
|
||||||
|
Assert.False(desktopIni.IsProtected);
|
||||||
|
|
||||||
|
var movies = LocationClassifier.Classify(
|
||||||
|
@"C:\Movies", "Movies", AttributeFlags.Directory, true);
|
||||||
|
Assert.False(movies.IsProtected);
|
||||||
|
Assert.False(movies.IsHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Access_denied_marks_protected()
|
||||||
|
{
|
||||||
|
var info = LocationClassifier.Classify(@"C:\locked", "locked", AttributeFlags.Directory, true, accessDenied: true);
|
||||||
|
Assert.True(info.AccessDenied);
|
||||||
|
Assert.True(info.IsProtected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class ExcludeEvaluatorTests
|
public class ExcludeEvaluatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -182,3 +234,36 @@ public class ReparsePolicyTests
|
|||||||
Assert.False(ReparsePolicy.ShouldRecurseIntoDirectory(item));
|
Assert.False(ReparsePolicy.ShouldRecurseIntoDirectory(item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class DragDropPolicyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Modifiers_match_windows_explorer()
|
||||||
|
{
|
||||||
|
Assert.Equal(DropAction.Move, DragDropPolicy.Resolve(false, false, false, sameVolume: true));
|
||||||
|
Assert.Equal(DropAction.Copy, DragDropPolicy.Resolve(false, false, false, sameVolume: false));
|
||||||
|
Assert.Equal(DropAction.Copy, DragDropPolicy.Resolve(control: true, shift: false, alt: false, sameVolume: true));
|
||||||
|
Assert.Equal(DropAction.Move, DragDropPolicy.Resolve(control: false, shift: true, alt: false, sameVolume: false));
|
||||||
|
Assert.Equal(DropAction.Link, DragDropPolicy.Resolve(control: false, shift: false, alt: true, sameVolume: true));
|
||||||
|
Assert.Equal(DropAction.Link, DragDropPolicy.Resolve(control: true, shift: true, alt: false, sameVolume: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_virtual_and_self_targets()
|
||||||
|
{
|
||||||
|
Assert.True(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], LocationRoots.ThisPc));
|
||||||
|
Assert.True(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"C:\Docs"));
|
||||||
|
Assert.True(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"C:\Docs\sub"));
|
||||||
|
Assert.False(DragDropPolicy.IsInvalidTarget([@"C:\Docs\file.txt"], @"C:\Docs"));
|
||||||
|
Assert.False(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"D:\Other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Same_volume_uses_drive_or_unc_share()
|
||||||
|
{
|
||||||
|
Assert.True(PathRules.IsSameVolume(@"C:\a", @"C:\b"));
|
||||||
|
Assert.False(PathRules.IsSameVolume(@"C:\a", @"D:\b"));
|
||||||
|
Assert.True(PathRules.IsSameVolume(@"\\media\movies\a", @"\\media\movies\b"));
|
||||||
|
Assert.False(PathRules.IsSameVolume(@"\\media\movies\a", @"\\media\tv\b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ file sealed class StubEnum : IFileSystemEnumerator
|
|||||||
file sealed class StubShell : IShellFileOperations
|
file sealed class StubShell : IShellFileOperations
|
||||||
{
|
{
|
||||||
public void Open(string path) { }
|
public void Open(string path) { }
|
||||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) { error = "n/a"; return false; }
|
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
|
||||||
|
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error) { error = "n/a"; return false; }
|
||||||
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
||||||
{ error = "n/a"; return false; }
|
{ error = "n/a"; return false; }
|
||||||
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
|
||||||
{ error = "n/a"; return false; }
|
{ error = "n/a"; return false; }
|
||||||
|
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
||||||
|
{ error = null; return true; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user