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:
2026-08-23 16:33:49 +02:00
parent 09a8cfafa3
commit d79605cde9
15 changed files with 879 additions and 58 deletions

View File

@@ -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"

View File

@@ -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)
{

View File

@@ -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);
}

View 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;
}
}

View File

@@ -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;
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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

View File

@@ -43,6 +43,8 @@ public class PathRulesTests
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
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", PathRules.RelativeParent(@"pack.zip\docs"));
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
{
[Fact]
@@ -182,3 +234,36 @@ public class ReparsePolicyTests
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"));
}
}

View File

@@ -36,9 +36,12 @@ file sealed class StubEnum : IFileSystemEnumerator
file sealed class StubShell : IShellFileOperations
{
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)
{ error = "n/a"; return false; }
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
{ error = "n/a"; return false; }
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
{ error = null; return true; }
}