From d79605cde94bc508c05f0547b19023c080f12bb5 Mon Sep 17 00:00:00 2001 From: netquick Date: Sun, 23 Aug 2026 16:33:49 +0200 Subject: [PATCH] Make drag-and-drop follow Windows Explorer and refresh the tree after folder operations. Co-authored-by: Cursor --- src/Explorer.App/MainWindow.xaml | 24 +- src/Explorer.App/MainWindow.xaml.cs | 320 +++++++++++++++++- src/Explorer.Domain/Abstractions/Platform.cs | 2 + src/Explorer.Domain/DragDropPolicy.cs | 57 ++++ src/Explorer.Domain/Entities.cs | 3 + src/Explorer.Domain/LocationInfo.cs | 88 +++++ src/Explorer.Domain/PathRules.cs | 36 ++ .../FileOperationService.cs | 32 +- src/Explorer.FileOperations/TransferQueue.cs | 6 +- .../ViewModels/MainViewModel.cs | 100 +++++- .../ViewModels/NavigationTreeViewModel.cs | 83 ++++- src/Explorer.Windows/NativeMethods.cs | 7 +- .../WindowsShellFileOperations.cs | 89 +++-- tests/Explorer.Domain.Tests/DomainTests.cs | 85 +++++ .../FileOperationTests.cs | 5 +- 15 files changed, 879 insertions(+), 58 deletions(-) create mode 100644 src/Explorer.Domain/DragDropPolicy.cs create mode 100644 src/Explorer.Domain/LocationInfo.cs diff --git a/src/Explorer.App/MainWindow.xaml b/src/Explorer.App/MainWindow.xaml index 993ea14..4f3efe3 100644 --- a/src/Explorer.App/MainWindow.xaml +++ b/src/Explorer.App/MainWindow.xaml @@ -183,6 +183,10 @@ SelectedItemChanged="OnTreeSelected" TreeViewItem.Expanded="OnTreeExpanded" ContextMenuOpening="OnTreeContextOpening" + AllowDrop="True" + DragEnter="OnTreeDragOver" + DragOver="OnTreeDragOver" + Drop="OnTreeDrop" HorizontalContentAlignment="Stretch"> @@ -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 @@ - + @@ -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" diff --git a/src/Explorer.App/MainWindow.xaml.cs b/src/Explorer.App/MainWindow.xaml.cs index 8b99c2d..4925ab0 100644 --- a/src/Explorer.App/MainWindow.xaml.cs +++ b/src/Explorer.App/MainWindow.xaml.cs @@ -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 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 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 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 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) { diff --git a/src/Explorer.Domain/Abstractions/Platform.cs b/src/Explorer.Domain/Abstractions/Platform.cs index 6162e09..5995797 100644 --- a/src/Explorer.Domain/Abstractions/Platform.cs +++ b/src/Explorer.Domain/Abstractions/Platform.cs @@ -85,6 +85,8 @@ public interface IShellFileOperations { void Open(string path); bool DeleteToRecycleBin(IReadOnlyList paths, out string? error); + bool Delete(IReadOnlyList paths, bool recycle, out string? error); + bool CreateShortcut(string targetPath, string shortcutPath, out string? error); bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error); bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error); } diff --git a/src/Explorer.Domain/DragDropPolicy.cs b/src/Explorer.Domain/DragDropPolicy.cs new file mode 100644 index 0000000..c6f9356 --- /dev/null +++ b/src/Explorer.Domain/DragDropPolicy.cs @@ -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 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; + } +} diff --git a/src/Explorer.Domain/Entities.cs b/src/Explorer.Domain/Entities.cs index c42edd4..d39ec63 100644 --- a/src/Explorer.Domain/Entities.cs +++ b/src/Explorer.Domain/Entities.cs @@ -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; } diff --git a/src/Explorer.Domain/LocationInfo.cs b/src/Explorer.Domain/LocationInfo.cs new file mode 100644 index 0000000..b83a51a --- /dev/null +++ b/src/Explorer.Domain/LocationInfo.cs @@ -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 ProtectedNames = new(StringComparer.OrdinalIgnoreCase) + { + "System Volume Information", + "$RECYCLE.BIN", + "Recycle.Bin", + "Recycler", + "Recovery", + "Config.Msi", + "$WinREAgent", + "$WINDOWS.~BT", + "$WINDOWS.~WS" + }; + + private static readonly HashSet 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); + } +} diff --git a/src/Explorer.Domain/PathRules.cs b/src/Explorer.Domain/PathRules.cs index f4c8a3e..4710ecd 100644 --- a/src/Explorer.Domain/PathRules.cs +++ b/src/Explorer.Domain/PathRules.cs @@ -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); diff --git a/src/Explorer.FileOperations/FileOperationService.cs b/src/Explorer.FileOperations/FileOperationService.cs index 1479292..38b6d27 100644 --- a/src/Explorer.FileOperations/FileOperationService.cs +++ b/src/Explorer.FileOperations/FileOperationService.cs @@ -31,8 +31,36 @@ public sealed class FileOperationService public Task MoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) => _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken); - public Task DeleteAsync(IReadOnlyList paths, CancellationToken cancellationToken = default) - => _queue.EnqueueDeleteAsync(paths, cancellationToken); + public Task DeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default) + => _queue.EnqueueDeleteAsync(paths, permanent, cancellationToken); + + public Task CreateShortcutsAsync(IReadOnlyList 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) { diff --git a/src/Explorer.FileOperations/TransferQueue.cs b/src/Explorer.FileOperations/TransferQueue.cs index b8ea680..c36a79b 100644 --- a/src/Explorer.FileOperations/TransferQueue.cs +++ b/src/Explorer.FileOperations/TransferQueue.cs @@ -71,12 +71,13 @@ public sealed class TransferQueue : BackgroundService } } - public async Task EnqueueDeleteAsync(IReadOnlyList paths, CancellationToken cancellationToken = default) + public async Task EnqueueDeleteAsync(IReadOnlyList 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; diff --git a/src/Explorer.Presentation/ViewModels/MainViewModel.cs b/src/Explorer.Presentation/ViewModels/MainViewModel.cs index 6af28d5..f8a3731 100644 --- a/src/Explorer.Presentation/ViewModels/MainViewModel.cs +++ b/src/Explorer.Presentation/ViewModels/MainViewModel.cs @@ -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 files, string targetDirectory, bool move) + public async Task DropAsync(IReadOnlyList 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 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) diff --git a/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs b/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs index 6642286..7b1e434 100644 --- a/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs +++ b/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs @@ -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 directories) + { + var seen = new HashSet(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(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) diff --git a/src/Explorer.Windows/NativeMethods.cs b/src/Explorer.Windows/NativeMethods.cs index 506ad35..0cd9590 100644 --- a/src/Explorer.Windows/NativeMethods.cs +++ b/src/Explorer.Windows/NativeMethods.cs @@ -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; diff --git a/src/Explorer.Windows/WindowsShellFileOperations.cs b/src/Explorer.Windows/WindowsShellFileOperations.cs index 4fc3384..d324245 100644 --- a/src/Explorer.Windows/WindowsShellFileOperations.cs +++ b/src/Explorer.Windows/WindowsShellFileOperations.cs @@ -23,6 +23,9 @@ public sealed class WindowsShellFileOperations : IShellFileOperations } public bool DeleteToRecycleBin(IReadOnlyList paths, out string? error) + => Delete(paths, recycle: true, out error); + + public bool Delete(IReadOnlyList 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? 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 diff --git a/tests/Explorer.Domain.Tests/DomainTests.cs b/tests/Explorer.Domain.Tests/DomainTests.cs index 7428e81..8d99d44 100644 --- a/tests/Explorer.Domain.Tests/DomainTests.cs +++ b/tests/Explorer.Domain.Tests/DomainTests.cs @@ -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")); + } +} diff --git a/tests/Explorer.FileOperations.Tests/FileOperationTests.cs b/tests/Explorer.FileOperations.Tests/FileOperationTests.cs index e4d367a..3d73325 100644 --- a/tests/Explorer.FileOperations.Tests/FileOperationTests.cs +++ b/tests/Explorer.FileOperations.Tests/FileOperationTests.cs @@ -36,9 +36,12 @@ file sealed class StubEnum : IFileSystemEnumerator file sealed class StubShell : IShellFileOperations { public void Open(string path) { } - public bool DeleteToRecycleBin(IReadOnlyList paths, out string? error) { error = "n/a"; return false; } + public bool DeleteToRecycleBin(IReadOnlyList paths, out string? error) => Delete(paths, true, out error); + public bool Delete(IReadOnlyList paths, bool recycle, out string? error) { error = "n/a"; return false; } public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error) { error = "n/a"; return false; } public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? 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; } }