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

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