Improve file operations, layout memory, and drive status.

Add a sequential file-operations queue with pause, reorder, and optional auto-clear; persist window size and tree width; show free space; and clear leftover indexing status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:52:30 +02:00
parent d79605cde9
commit 9bf451932f
41 changed files with 2855 additions and 275 deletions

View File

@@ -1,9 +1,12 @@
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Domain;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
@@ -19,27 +22,48 @@ public partial class MainWindow : Window
private bool _suppressItemContextMenu;
private bool _incomingRightDrag;
private bool _sourceRightDrag;
private MainViewModel? _wiredVm;
private long _inlineRenameStarted;
private DispatcherTimer? _clickRenameTimer;
private FolderItemViewModel? _clickRenameItem;
private FolderItemViewModel? _listDropTarget;
private NavNodeViewModel? _treeDropTarget;
public MainWindow()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (DataContext is MainViewModel vm)
if (_wiredVm is not null)
{
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MainViewModel.Theme))
{
ApplyTheme(vm.Theme);
}
};
_wiredVm.InlineRenameRequested -= OnInlineRenameRequested;
_wiredVm.PropertyChanged -= OnViewModelPropertyChanged;
}
_wiredVm = DataContext as MainViewModel;
if (_wiredVm is not null)
{
_wiredVm.InlineRenameRequested += OnInlineRenameRequested;
_wiredVm.PropertyChanged += OnViewModelPropertyChanged;
RestoreLayout(_wiredVm);
}
};
Closing += (_, _) => PersistLayout();
}
private MainViewModel Vm => (MainViewModel)DataContext;
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.Theme) && sender is MainViewModel vm)
{
ApplyTheme(vm.Theme);
}
}
private void OnInlineRenameRequested(object? sender, string path)
=> Dispatcher.BeginInvoke(() => BeginInlineRenameForPath(path), DispatcherPriority.Loaded);
private void ApplyTheme(string theme)
{
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
@@ -75,9 +99,77 @@ public partial class MainWindow : Window
private void ToggleMaximized()
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
SyncMaxRestoreButton();
}
private void RestoreLayout(MainViewModel vm)
{
var prefs = vm.CurrentPreferences();
if (prefs.WindowWidth is > 0 && prefs.WindowHeight is > 0)
{
Width = Clamp(prefs.WindowWidth.Value, MinWidth, SystemParameters.VirtualScreenWidth);
Height = Clamp(prefs.WindowHeight.Value, MinHeight, SystemParameters.VirtualScreenHeight);
}
if (prefs.WindowLeft is not null && prefs.WindowTop is not null)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = prefs.WindowLeft.Value;
Top = prefs.WindowTop.Value;
if (!IsOnVirtualScreen())
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
if (prefs.WindowMaximized)
{
WindowState = WindowState.Maximized;
}
SyncMaxRestoreButton();
if (prefs.TreeWidth is >= 160)
{
var maxTree = Math.Max(160, ActualWidth > 0 ? ActualWidth - 240 : Width - 240);
TreeColumn.Width = new GridLength(Clamp(prefs.TreeWidth.Value, 160, maxTree));
}
}
private void PersistLayout()
{
if (DataContext is not MainViewModel vm)
{
return;
}
var bounds = WindowState == WindowState.Normal ? new Rect(Left, Top, Width, Height) : RestoreBounds;
if (bounds.Width <= 0 || bounds.Height <= 0)
{
bounds = new Rect(Left, Top, Width, Height);
}
var treeWidth = TreeColumn.ActualWidth > 0 ? TreeColumn.ActualWidth : TreeColumn.Width.Value;
vm.SaveLayout(bounds.Width, bounds.Height, bounds.Left, bounds.Top, WindowState == WindowState.Maximized, treeWidth);
}
private void SyncMaxRestoreButton()
=> MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
private bool IsOnVirtualScreen()
{
var virtualArea = new Rect(
SystemParameters.VirtualScreenLeft,
SystemParameters.VirtualScreenTop,
SystemParameters.VirtualScreenWidth,
SystemParameters.VirtualScreenHeight);
var window = new Rect(Left, Top, Math.Max(Width, MinWidth), Math.Max(Height, MinHeight));
window.Intersect(virtualArea);
return window.Width >= 80 && window.Height >= 80;
}
private static double Clamp(double value, double min, double max)
=> Math.Min(Math.Max(value, min), Math.Max(min, max));
private void OnPaneChromeMouseDown(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
@@ -342,7 +434,8 @@ public partial class MainWindow : Window
private async void OnItemDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is ListView { SelectedItem: FolderItemViewModel item })
CancelClickRename();
if (sender is ListView { SelectedItem: FolderItemViewModel item } && !item.IsRenaming)
{
ActivatePaneFromList((ListView)sender);
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
@@ -350,6 +443,82 @@ public partial class MainWindow : Window
}
}
private void OnColumnHeaderClick(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is Thumb || sender is not ListView list)
{
return;
}
var header = e.OriginalSource as GridViewColumnHeader
?? FindGridViewColumnHeader(e.OriginalSource as DependencyObject);
if (header is null || header.Role == GridViewColumnHeaderRole.Padding)
{
return;
}
var key = SortKeyFromHeader(header.Column?.Header);
if (key is null)
{
return;
}
ActivatePaneFromList(list);
Vm.ActivePane.SortBy(key);
UpdateSortGlyphs(list, Vm.ActivePane);
}
private static GridViewColumnHeader? FindGridViewColumnHeader(DependencyObject? origin)
{
while (origin is not null)
{
if (origin is GridViewColumnHeader header)
{
return header;
}
origin = origin is Visual
? VisualTreeHelper.GetParent(origin)
: LogicalTreeHelper.GetParent(origin);
}
return null;
}
private static void UpdateSortGlyphs(ListView list, ExplorerPaneViewModel pane)
{
if (list.View is not GridView grid)
{
return;
}
foreach (var column in grid.Columns)
{
var title = StripSortGlyph(column.Header?.ToString());
var key = SortKeyFromHeader(title);
column.Header = key is not null && string.Equals(pane.SortProperty, key, StringComparison.OrdinalIgnoreCase)
? title + (pane.SortDescending ? " ▼" : " ▲")
: title;
}
}
private static string? SortKeyFromHeader(object? header)
=> StripSortGlyph(header?.ToString()) switch
{
"Name" => "Name",
"Date modified" => "Modified",
"Type" => "Type",
"Size" => "Size",
"Free space" => "Free",
_ => null
};
private static string StripSortGlyph(string? header)
{
var text = header ?? "";
return text.Replace(" ▲", "", StringComparison.Ordinal).Replace(" ▼", "", StringComparison.Ordinal).Trim();
}
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView list)
@@ -358,6 +527,11 @@ public partial class MainWindow : Window
}
ActivatePaneFromList(list);
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
{
CancelClickRename();
}
Vm.ActivePane.SelectedItems.Clear();
foreach (FolderItemViewModel item in list.SelectedItems)
{
@@ -395,6 +569,7 @@ public partial class MainWindow : Window
_dragPending = true;
_dragButton = e.ChangedButton;
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
TryScheduleClickRename(sender as ListView, e);
}
private void OnListContextMenuOpening(object sender, ContextMenuEventArgs e)
@@ -403,6 +578,12 @@ public partial class MainWindow : Window
{
e.Handled = true;
_suppressItemContextMenu = false;
return;
}
if (sender is FrameworkElement { ContextMenu: { } menu })
{
menu.DataContext = DataContext;
}
}
@@ -410,6 +591,7 @@ public partial class MainWindow : Window
{
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
{
SetListDropTarget(null);
e.Effects = DragDropEffects.None;
e.Handled = true;
return;
@@ -418,6 +600,15 @@ public partial class MainWindow : Window
ActivatePaneFromList(list);
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
var dest = ResolveDropDirectory(list, e);
var hover = HitTestFolderItem(list, e.GetPosition(list));
var folderTarget = hover is { IsDirectory: true }
&& dest is not null
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
&& !DragDropPolicy.IsInvalidTarget(files, dest)
? hover
: null;
SetListDropTarget(folderTarget);
SetTreeDropTarget(null);
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
{
e.Effects = DragDropEffects.None;
@@ -439,6 +630,7 @@ public partial class MainWindow : Window
private async void OnListDrop(object sender, DragEventArgs e)
{
ClearDropTargets();
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
{
return;
@@ -463,6 +655,35 @@ public partial class MainWindow : Window
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
private void OnListDragLeave(object sender, DragEventArgs e)
{
if (sender is ListView list)
{
var pos = e.GetPosition(list);
if (pos.X >= 0 && pos.Y >= 0 && pos.X <= list.ActualWidth && pos.Y <= list.ActualHeight)
{
return;
}
}
SetListDropTarget(null);
}
protected override void OnPreviewMouseDown(MouseButtonEventArgs e)
{
if (!IsInsideInlineRenameBox(e.OriginalSource as DependencyObject))
{
CommitOpenInlineRename();
}
if (!IsClickOnItemName(e.OriginalSource as DependencyObject))
{
CancelClickRename();
}
base.OnPreviewMouseDown(e);
}
protected override void OnPreviewMouseMove(MouseEventArgs e)
{
base.OnPreviewMouseMove(e);
@@ -481,6 +702,7 @@ public partial class MainWindow : Window
}
_dragPending = false;
CancelClickRename();
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
IReadOnlyList<string> paths;
if (_dragItem is not null && (selected.Count == 0
@@ -504,6 +726,7 @@ public partial class MainWindow : Window
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
_sourceRightDrag = false;
_incomingRightDrag = false;
ClearDropTargets();
}
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
@@ -638,20 +861,27 @@ public partial class MainWindow : Window
{
if (!TryGetDropFiles(e, out var files))
{
SetTreeDropTarget(null);
e.Effects = DragDropEffects.None;
e.Handled = true;
return;
}
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
var dest = HitTestTreePath(e);
var node = HitTestTreeNode(e);
var dest = node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)
? null
: node.Path;
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
{
SetTreeDropTarget(null);
e.Effects = DragDropEffects.None;
e.Handled = true;
return;
}
SetListDropTarget(null);
SetTreeDropTarget(node);
e.Effects = _incomingRightDrag || _sourceRightDrag
? DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link
: ToEffects(ResolveDropAction(e, files, dest));
@@ -660,6 +890,7 @@ public partial class MainWindow : Window
private async void OnTreeDrop(object sender, DragEventArgs e)
{
ClearDropTargets();
if (!TryGetDropFiles(e, out var files))
{
return;
@@ -683,11 +914,20 @@ public partial class MainWindow : Window
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
private void OnTreeDragLeave(object sender, DragEventArgs e)
{
var pos = e.GetPosition(NavTree);
if (pos.X >= 0 && pos.Y >= 0 && pos.X <= NavTree.ActualWidth && pos.Y <= NavTree.ActualHeight)
{
return;
}
SetTreeDropTarget(null);
}
private string? HitTestTreePath(DragEventArgs e)
{
var origin = NavTree.InputHitTest(e.GetPosition(NavTree)) as DependencyObject
?? e.OriginalSource as DependencyObject;
var node = FindTreeNode(origin);
var node = HitTestTreeNode(e);
if (node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
{
return null;
@@ -696,6 +936,57 @@ public partial class MainWindow : Window
return node.Path;
}
private NavNodeViewModel? HitTestTreeNode(DragEventArgs e)
{
var origin = NavTree.InputHitTest(e.GetPosition(NavTree)) as DependencyObject
?? e.OriginalSource as DependencyObject;
return FindTreeNode(origin);
}
private void SetListDropTarget(FolderItemViewModel? item)
{
if (ReferenceEquals(_listDropTarget, item))
{
return;
}
if (_listDropTarget is not null)
{
_listDropTarget.IsDropTarget = false;
}
_listDropTarget = item;
if (item is not null)
{
item.IsDropTarget = true;
}
}
private void SetTreeDropTarget(NavNodeViewModel? node)
{
if (ReferenceEquals(_treeDropTarget, node))
{
return;
}
if (_treeDropTarget is not null)
{
_treeDropTarget.IsDropTarget = false;
}
_treeDropTarget = node;
if (node is not null)
{
node.IsDropTarget = true;
}
}
private void ClearDropTargets()
{
SetListDropTarget(null);
SetTreeDropTarget(null);
}
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is ListView { SelectedItem: FolderItemViewModel item })
@@ -760,6 +1051,10 @@ public partial class MainWindow : Window
private void OnCtxOpen(object sender, RoutedEventArgs e) => Vm.OpenSelectedCommand.Execute(null);
private void OnCtxNewFolder(object sender, RoutedEventArgs e) => Vm.NewFolderCommand.Execute(null);
private void OnCtxCopyPath(object sender, RoutedEventArgs e) => Vm.CopyPathCommand.Execute(null);
private async void OnCtxDelete(object sender, RoutedEventArgs e)
=> await DeleteSelectedAsync().ConfigureAwait(true);
@@ -790,16 +1085,211 @@ public partial class MainWindow : Window
private void OnCtxRename(object sender, RoutedEventArgs e)
{
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is not null)
{
BeginInlineRename(item);
}
}
private void BeginInlineRenameForPath(string path)
{
var item = Vm.ActivePane.Items.FirstOrDefault(i => NavigationTreeViewModel.PathsEqual(i.FullPath, path));
if (item is null)
{
return;
}
var name = PromptWindow.Ask(this, "Rename", "New name:", item.Name);
if (!string.IsNullOrWhiteSpace(name) && name != item.Name)
BeginInlineRename(item);
}
private void BeginInlineRename(FolderItemViewModel item)
{
foreach (var other in Vm.ActivePane.Items.Where(i => i.IsRenaming && i != item))
{
Vm.RenameSelected(name);
other.CancelRename();
}
var list = FindActiveFileList();
if (list is not null)
{
list.SelectedItem = item;
list.ScrollIntoView(item);
list.UpdateLayout();
}
CancelClickRename();
_inlineRenameStarted = Environment.TickCount64;
item.BeginRename();
}
private void TryScheduleClickRename(ListView? list, MouseButtonEventArgs e)
{
if (list is null
|| e.ChangedButton != MouseButton.Left
|| _dragItem is null
|| _dragItem.IsRenaming
|| Keyboard.Modifiers is not ModifierKeys.None
|| !IsClickOnItemName(e.OriginalSource as DependencyObject)
|| list.SelectedItems.Count != 1
|| !list.SelectedItems.Contains(_dragItem))
{
CancelClickRename();
return;
}
CancelClickRename();
_clickRenameItem = _dragItem;
_clickRenameTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(Math.Max(GetDoubleClickTime(), 1))
};
_clickRenameTimer.Tick += OnClickRenameTick;
_clickRenameTimer.Start();
}
private void OnClickRenameTick(object? sender, EventArgs e)
{
var item = _clickRenameItem;
CancelClickRename();
if (item is not null && Vm.ActivePane.Items.Contains(item) && !item.IsRenaming)
{
BeginInlineRename(item);
}
}
private void CancelClickRename()
{
if (_clickRenameTimer is not null)
{
_clickRenameTimer.Tick -= OnClickRenameTick;
_clickRenameTimer.Stop();
_clickRenameTimer = null;
}
_clickRenameItem = null;
}
private static bool IsClickOnItemName(DependencyObject? origin)
{
while (origin is not null)
{
if (origin is TextBox { Tag: "InlineRename" } || origin is FrameworkElement { Tag: "ItemName" })
{
return true;
}
origin = origin is Visual
? VisualTreeHelper.GetParent(origin)
: LogicalTreeHelper.GetParent(origin);
}
return false;
}
[DllImport("user32.dll")]
private static extern uint GetDoubleClickTime();
internal bool IsInlineRenameStarting
=> Environment.TickCount64 - _inlineRenameStarted < 300;
private void CommitOpenInlineRename()
{
if (DataContext is not MainViewModel)
{
return;
}
foreach (var pane in new[] { Vm.ActiveTab.Left, Vm.ActiveTab.Right })
{
foreach (var item in pane.Items.Where(i => i.IsRenaming).ToList())
{
TryCommitInlineRename(item);
}
}
}
private static bool IsInsideInlineRenameBox(DependencyObject? origin)
{
while (origin is not null)
{
if (origin is TextBox { Tag: "InlineRename" })
{
return true;
}
origin = origin is Visual
? VisualTreeHelper.GetParent(origin)
: LogicalTreeHelper.GetParent(origin);
}
return false;
}
public void TryCommitInlineRename(FolderItemViewModel item)
{
if (!item.IsRenaming)
{
return;
}
var newName = item.EditName.Trim();
if (string.IsNullOrWhiteSpace(newName) || newName.Equals(item.Item.Name, StringComparison.Ordinal))
{
item.CancelRename();
return;
}
if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
MessageBox.Show(
this,
"A file name can't contain any of the following characters:\n\\ / : * ? \" < > |",
"Rename",
MessageBoxButton.OK,
MessageBoxImage.Warning);
item.BeginRename();
item.EditName = newName;
return;
}
try
{
item.IsRenaming = false;
Vm.RenameItem(item, newName);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Rename", MessageBoxButton.OK, MessageBoxImage.Warning);
item.BeginRename();
item.EditName = newName;
}
}
private ListView? FindActiveFileList()
{
var items = Vm.ActivePane.Items;
return FindVisibleList(this, items);
}
private static ListView? FindVisibleList(DependencyObject root, object items)
{
var count = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
if (child is ListView { IsVisible: true } list && ReferenceEquals(list.ItemsSource, items))
{
return list;
}
var nested = FindVisibleList(child, items);
if (nested is not null)
{
return nested;
}
}
return null;
}
private async void OnOpenSettings(object sender, RoutedEventArgs e)
@@ -845,8 +1335,21 @@ public partial class MainWindow : Window
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.OriginalSource is TextBox { Tag: "InlineRename" })
{
return;
}
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
if (ctrl && shift && e.Key == Key.N)
{
Vm.NewFolderCommand.Execute(null);
e.Handled = true;
return;
}
if (e.Key == Key.F5)
{
await Vm.RefreshAsync().ConfigureAwait(true);