diff --git a/src/Explorer.App/App.xaml b/src/Explorer.App/App.xaml
index 0a31961..586257c 100644
--- a/src/Explorer.App/App.xaml
+++ b/src/Explorer.App/App.xaml
@@ -19,11 +19,43 @@
+
-
+
+
+
+
+
+
+
@@ -62,8 +94,25 @@
-
+
+
+
+
+
+
@@ -237,6 +286,13 @@
+
+
+
+
@@ -171,9 +182,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -186,6 +291,7 @@
AllowDrop="True"
DragEnter="OnTreeDragOver"
DragOver="OnTreeDragOver"
+ DragLeave="OnTreeDragLeave"
Drop="OnTreeDrop"
HorizontalContentAlignment="Stretch">
@@ -250,7 +356,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
@@ -266,33 +374,34 @@
+
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
+ Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
+
+ Command="{Binding PinCloudCommand}"
+ Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
+ Command="{Binding FreeUpCloudSpaceCommand}"
+ Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
@@ -303,7 +412,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
@@ -328,7 +439,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
@@ -375,7 +488,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
@@ -391,6 +506,7 @@
+
@@ -401,7 +517,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
@@ -426,7 +544,9 @@
PreviewMouseLeftButtonDown="OnListMouseDown"
PreviewMouseRightButtonDown="OnListMouseDown"
ContextMenuOpening="OnListContextMenuOpening"
+ GridViewColumnHeader.Click="OnColumnHeaderClick"
DragEnter="OnListDragOver"
+ DragLeave="OnListDragLeave"
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
@@ -506,6 +626,7 @@
+
diff --git a/src/Explorer.App/MainWindow.xaml.cs b/src/Explorer.App/MainWindow.xaml.cs
index 4925ab0..5ac68a8 100644
--- a/src/Explorer.App/MainWindow.xaml.cs
+++ b/src/Explorer.App/MainWindow.xaml.cs
@@ -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 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);
diff --git a/src/Explorer.App/Services/WpfClipboard.cs b/src/Explorer.App/Services/WpfClipboard.cs
index f6a884d..3b5c970 100644
--- a/src/Explorer.App/Services/WpfClipboard.cs
+++ b/src/Explorer.App/Services/WpfClipboard.cs
@@ -21,6 +21,9 @@ public sealed class WpfClipboard : IOsClipboard
Clipboard.SetDataObject(data, copy: true);
}
+ public void SetText(string text)
+ => Clipboard.SetText(text);
+
public bool TryGetFiles(out IReadOnlyList paths, out bool cut)
{
paths = [];
diff --git a/src/Explorer.App/SettingsWindow.xaml b/src/Explorer.App/SettingsWindow.xaml
index 296eab2..5561a67 100644
--- a/src/Explorer.App/SettingsWindow.xaml
+++ b/src/Explorer.App/SettingsWindow.xaml
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
- Height="620" Width="560"
- MinHeight="520" MinWidth="480"
+ Height="720" Width="560"
+ MinHeight="560" MinWidth="480"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
@@ -38,6 +38,24 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Explorer.App/SettingsWindow.xaml.cs b/src/Explorer.App/SettingsWindow.xaml.cs
index 29c9b3f..05c4346 100644
--- a/src/Explorer.App/SettingsWindow.xaml.cs
+++ b/src/Explorer.App/SettingsWindow.xaml.cs
@@ -20,6 +20,9 @@ public partial class SettingsWindow : Window
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents;
+ ShowHidden.IsChecked = prefs.ShowHiddenFiles;
+ ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
+ AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
@@ -34,11 +37,16 @@ public partial class SettingsWindow : Window
private async void OnOk(object sender, RoutedEventArgs e)
{
- var prefs = new UiPreferences(
- ThemeLight.IsChecked == true ? "Light" : "Dark",
- GroupNetwork.IsChecked == true,
- GroupCloud.IsChecked == true,
- IndexArchives.IsChecked == true);
+ var prefs = _vm.CurrentPreferences() with
+ {
+ Theme = ThemeLight.IsChecked == true ? "Light" : "Dark",
+ GroupNetworkPlaces = GroupNetwork.IsChecked == true,
+ GroupCloudPlaces = GroupCloud.IsChecked == true,
+ IndexArchiveContents = IndexArchives.IsChecked == true,
+ ShowHiddenFiles = ShowHidden.IsChecked == true,
+ ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
+ AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true
+ };
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
DialogResult = true;
Close();
diff --git a/src/Explorer.Application/BrowseService.cs b/src/Explorer.Application/BrowseService.cs
index 906790a..3138850 100644
--- a/src/Explorer.Application/BrowseService.cs
+++ b/src/Explorer.Application/BrowseService.cs
@@ -12,6 +12,8 @@ public sealed class BrowseService
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
+ private readonly IElevatedScanService? _elevation;
+ private readonly IRecycleBinCatalog? _recycle;
public BrowseService(
IFileSystemEnumerator enumerator,
@@ -20,7 +22,9 @@ public sealed class BrowseService
SourceManager sources,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
- UiPreferencesStore preferences)
+ UiPreferencesStore preferences,
+ IElevatedScanService? elevation = null,
+ IRecycleBinCatalog? recycle = null)
{
_enumerator = enumerator;
_volumes = volumes;
@@ -29,6 +33,8 @@ public sealed class BrowseService
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
+ _elevation = elevation;
+ _recycle = recycle;
}
public async Task ListThisPcAsync(CancellationToken cancellationToken = default)
@@ -50,12 +56,15 @@ public sealed class BrowseService
.Select(place =>
{
var exists = Directory.Exists(place.Path);
+ var space = exists ? _volumes.GetSpace(place.Path) : default;
return new FileSystemItem
{
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
- Attributes = AttributeFlags.Directory
+ Attributes = AttributeFlags.Directory,
+ FreeSpaceBytes = space.FreeBytes,
+ CapacityBytes = space.CapacityBytes
};
})
.ToList();
@@ -70,7 +79,7 @@ public sealed class BrowseService
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
var items = new List();
foreach (var source in sources.Where(include)
- .OrderBy(s => s.Kind)
+ .OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
IndexEntry? root = null;
@@ -79,6 +88,9 @@ public sealed class BrowseService
root = await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
+ var space = source.LastRootPath is null
+ ? default
+ : _volumes.GetSpace(source.LastRootPath);
items.Add(new FileSystemItem
{
FullPath = source.LastRootPath ?? source.DisplayName,
@@ -87,7 +99,9 @@ public sealed class BrowseService
: source.DisplayName,
IsDirectory = true,
SizeBytes = root?.AggregateSize ?? 0,
- Attributes = AttributeFlags.Directory
+ Attributes = AttributeFlags.Directory,
+ FreeSpaceBytes = space.FreeBytes,
+ CapacityBytes = space.CapacityBytes ?? source.CapacityBytes
});
}
@@ -106,6 +120,11 @@ public sealed class BrowseService
}
}
+ if (IsRecycleBinPath(path))
+ {
+ return ListRecycleBin(path);
+ }
+
var reachable = _volumes.IsPathReachable(path);
if (reachable)
@@ -117,7 +136,7 @@ public sealed class BrowseService
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
}
- return new FolderListing { Path = path, IsOffline = false, Items = listing, Error = error };
+ return FinishListing(path, AttachVolumeSpace(listing), isOffline: false, error);
}
if (source is { IsIndexed: true })
@@ -151,7 +170,7 @@ public sealed class BrowseService
: null
})
.ToList();
- return new FolderListing { Path = path, IsOffline = true, Items = items };
+ return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
}
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
@@ -204,7 +223,7 @@ public sealed class BrowseService
var hint = isArchiveFile && items.Count == 0
? "Archive contents appear after the next scan."
: null;
- return new FolderListing { Path = path, IsOffline = false, Items = items, Error = hint };
+ return new FolderListing { Path = path, IsOffline = false, Items = AttachVolumeSpace(items), Error = hint };
}
private async Task IsUnderArchiveAsync(long sourceId, string pathRel, CancellationToken cancellationToken)
@@ -262,8 +281,147 @@ public sealed class BrowseService
FileId = i.FileId,
ReparseTag = i.ReparseTag,
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
- Cloud = i.Cloud
+ Cloud = i.Cloud,
+ Location = i.Location,
+ SizeKnowledge = i.SizeKnowledge,
+ DisplayName = i.DisplayName,
+ FreeSpaceBytes = i.FreeSpaceBytes,
+ CapacityBytes = i.CapacityBytes
};
}).ToList();
}
+
+ private FolderListing FinishListing(string path, IReadOnlyList items, bool isOffline, string? error)
+ {
+ var prefs = _preferences.Load();
+ var annotated = items.Select(i => Annotate(i, sizeFromIndex: i.SizeBytes > 0, prefs)).ToList();
+ var visible = annotated.Where(i => LocationVisibility.ShouldShow(i.Location, prefs)).ToList();
+ if (visible.Any(i => i.Location.AccessDenied) && _elevation is { IsElevated: false })
+ {
+ error = string.IsNullOrEmpty(error)
+ ? _elevation.ProtectedContentHint
+ : error + " " + _elevation.ProtectedContentHint;
+ }
+
+ return new FolderListing { Path = path, IsOffline = isOffline, Items = visible, Error = error };
+ }
+
+ private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences)
+ {
+ var looksRestricted = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
+ var accessDenied = preferences.ShowProtectedSystemLocations
+ && item.IsDirectory
+ && (looksRestricted.IsProtected || looksRestricted.IsRecycleBin)
+ && IsAccessDenied(item.FullPath);
+ var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory, accessDenied);
+ var knowledge = LocationVisibility.ResolveSizeKnowledge(location, item.IsDirectory, item.SizeBytes, sizeFromIndex);
+ return new FileSystemItem
+ {
+ FullPath = item.FullPath,
+ Name = item.Name,
+ IsDirectory = item.IsDirectory,
+ SizeBytes = item.SizeBytes,
+ CreatedUtc = item.CreatedUtc,
+ ModifiedUtc = item.ModifiedUtc,
+ Attributes = item.Attributes,
+ FileId = item.FileId,
+ ReparseTag = item.ReparseTag,
+ AllocatedSizeBytes = item.AllocatedSizeBytes,
+ Cloud = item.Cloud,
+ Location = location,
+ SizeKnowledge = knowledge,
+ DisplayName = location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
+ FreeSpaceBytes = item.FreeSpaceBytes,
+ CapacityBytes = item.CapacityBytes
+ };
+ }
+
+ private IReadOnlyList AttachVolumeSpace(IReadOnlyList items)
+ {
+ var cache = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ return items.Select(item =>
+ {
+ var key = SpaceKey(item.FullPath);
+ if (!cache.TryGetValue(key, out var space))
+ {
+ space = _volumes.GetSpace(item.FullPath);
+ cache[key] = space;
+ }
+
+ if (space.FreeBytes is null && space.CapacityBytes is null)
+ {
+ return item;
+ }
+
+ return new FileSystemItem
+ {
+ FullPath = item.FullPath,
+ Name = item.Name,
+ IsDirectory = item.IsDirectory,
+ SizeBytes = item.SizeBytes,
+ CreatedUtc = item.CreatedUtc,
+ ModifiedUtc = item.ModifiedUtc,
+ Attributes = item.Attributes,
+ FileId = item.FileId,
+ ReparseTag = item.ReparseTag,
+ AllocatedSizeBytes = item.AllocatedSizeBytes,
+ Cloud = item.Cloud,
+ Location = item.Location,
+ SizeKnowledge = item.SizeKnowledge,
+ DisplayName = item.DisplayName,
+ FreeSpaceBytes = space.FreeBytes,
+ CapacityBytes = space.CapacityBytes
+ };
+ }).ToList();
+ }
+
+ private static string SpaceKey(string path)
+ {
+ if (PathRules.IsUnc(path))
+ {
+ return PathRules.CanonicalUncRoot(path);
+ }
+
+ var root = Path.GetPathRoot(path);
+ return string.IsNullOrWhiteSpace(root) ? path : root;
+ }
+
+ private FolderListing ListRecycleBin(string path)
+ {
+ var summary = _recycle?.TrySummarize(path);
+ var hint = summary is null
+ ? "Recycle Bin contents are managed by Windows."
+ : $"{summary.ItemCount} deleted items · {summary.UsedBytes} bytes used";
+ if (_elevation is { IsElevated: false })
+ {
+ hint += " " + _elevation.ProtectedContentHint;
+ }
+
+ return new FolderListing { Path = path, IsOffline = false, Items = [], Error = hint };
+ }
+
+ private static bool IsRecycleBinPath(string path)
+ => LocationClassifier.IsRecycleBinName(PathRules.GetFileName(path));
+
+ private static bool IsAccessDenied(string path)
+ {
+ try
+ {
+ using var enumerator = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
+ enumerator.MoveNext();
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return true;
+ }
+ catch (System.Security.SecurityException)
+ {
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
}
diff --git a/src/Explorer.Application/IElevatedScanService.cs b/src/Explorer.Application/IElevatedScanService.cs
new file mode 100644
index 0000000..ef9c2ae
--- /dev/null
+++ b/src/Explorer.Application/IElevatedScanService.cs
@@ -0,0 +1,8 @@
+namespace Explorer.Application;
+
+public interface IElevatedScanService
+{
+ bool IsElevated { get; }
+ bool CanRequestElevation { get; }
+ string ProtectedContentHint { get; }
+}
diff --git a/src/Explorer.Application/IRecycleBinCatalog.cs b/src/Explorer.Application/IRecycleBinCatalog.cs
new file mode 100644
index 0000000..f2bf6e5
--- /dev/null
+++ b/src/Explorer.Application/IRecycleBinCatalog.cs
@@ -0,0 +1,13 @@
+namespace Explorer.Application;
+
+public sealed record RecycleBinSummary(int ItemCount, long UsedBytes);
+
+public interface IRecycleBinCatalog
+{
+ RecycleBinSummary? TrySummarize(string recycleBinPath);
+}
+
+public sealed class RecycleBinCatalog : IRecycleBinCatalog
+{
+ public RecycleBinSummary? TrySummarize(string recycleBinPath) => null;
+}
diff --git a/src/Explorer.Application/LocationVisibility.cs b/src/Explorer.Application/LocationVisibility.cs
new file mode 100644
index 0000000..ca0ac08
--- /dev/null
+++ b/src/Explorer.Application/LocationVisibility.cs
@@ -0,0 +1,46 @@
+using Explorer.Domain;
+
+namespace Explorer.Application;
+
+public static class LocationVisibility
+{
+ public static bool ShouldShow(LocationInfo info, UiPreferences preferences)
+ {
+ if (info.IsProtected)
+ {
+ return preferences.ShowProtectedSystemLocations;
+ }
+
+ if (info.IsHidden)
+ {
+ return preferences.ShowHiddenFiles;
+ }
+
+ return true;
+ }
+
+ public static SizeKnowledge ResolveSizeKnowledge(LocationInfo info, bool isDirectory, long sizeBytes, bool sizeFromIndex)
+ {
+ if (info.AccessDenied && sizeBytes <= 0)
+ {
+ return SizeKnowledge.Unknown;
+ }
+
+ if (info.AccessDenied && sizeBytes > 0)
+ {
+ return SizeKnowledge.Partial;
+ }
+
+ if (info.IsProtected && isDirectory && sizeBytes <= 0)
+ {
+ return SizeKnowledge.Unknown;
+ }
+
+ if (isDirectory && !sizeFromIndex && sizeBytes <= 0)
+ {
+ return SizeKnowledge.Unknown;
+ }
+
+ return SizeKnowledge.Calculated;
+ }
+}
diff --git a/src/Explorer.Application/SourceManager.cs b/src/Explorer.Application/SourceManager.cs
index 67ba66d..b3d7ff0 100644
--- a/src/Explorer.Application/SourceManager.cs
+++ b/src/Explorer.Application/SourceManager.cs
@@ -56,7 +56,7 @@ public sealed class SourceManager
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
source.Kind = fp.Kind;
source.LastSeenUtc = _clock.UtcNow;
- source.Status = source.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online;
+ source.Status = await ResolveReachableStatusAsync(source, cancellationToken).ConfigureAwait(false);
source.LastError = null;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
if (source.IsIndexed)
@@ -251,7 +251,7 @@ public sealed class SourceManager
{
existing.LastSeenUtc = _clock.UtcNow;
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
- ? (existing.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online)
+ ? await ResolveReachableStatusAsync(existing, cancellationToken).ConfigureAwait(false)
: SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
return existing;
@@ -346,6 +346,20 @@ public sealed class SourceManager
}
}
+ public Task GetAsync(long id, CancellationToken cancellationToken = default)
+ => _store.Sources.GetAsync(id, cancellationToken);
+
+ private async Task ResolveReachableStatusAsync(Source source, CancellationToken cancellationToken)
+ {
+ if (source.Status == SourceStatus.Scanning
+ && await _store.ScanJobs.HasActiveAsync(source.Id, cancellationToken).ConfigureAwait(false))
+ {
+ return SourceStatus.Scanning;
+ }
+
+ return SourceStatus.Online;
+ }
+
private IReadOnlyList LoadRecents()
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");
diff --git a/src/Explorer.Application/UiPreferencesStore.cs b/src/Explorer.Application/UiPreferencesStore.cs
index ec27763..a061988 100644
--- a/src/Explorer.Application/UiPreferencesStore.cs
+++ b/src/Explorer.Application/UiPreferencesStore.cs
@@ -6,9 +6,18 @@ public sealed record UiPreferences(
string Theme,
bool GroupNetworkPlaces,
bool GroupCloudPlaces,
- bool IndexArchiveContents = false)
+ bool IndexArchiveContents = false,
+ bool ShowHiddenFiles = true,
+ bool ShowProtectedSystemLocations = false,
+ bool AutoClearQueueWhenDone = false,
+ double? WindowWidth = null,
+ double? WindowHeight = null,
+ double? WindowLeft = null,
+ double? WindowTop = null,
+ bool WindowMaximized = false,
+ double? TreeWidth = null)
{
- public static UiPreferences Default { get; } = new("Dark", false, false, false);
+ public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
public sealed class UiPreferencesStore
@@ -47,7 +56,11 @@ public sealed class UiPreferencesStore
"theme=" + NormalizeTheme(preferences.Theme),
"group-network=" + (preferences.GroupNetworkPlaces ? "true" : "false"),
"group-cloud=" + (preferences.GroupCloudPlaces ? "true" : "false"),
- "index-archives=" + (preferences.IndexArchiveContents ? "true" : "false")
+ "index-archives=" + (preferences.IndexArchiveContents ? "true" : "false"),
+ "show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
+ "show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
+ "auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
+ .. LayoutLines(preferences)
]);
}
catch
@@ -62,6 +75,15 @@ public sealed class UiPreferencesStore
var groupNetwork = false;
var groupCloud = false;
var indexArchives = false;
+ var showHidden = true;
+ var showProtected = false;
+ var autoClearQueue = false;
+ double? windowWidth = null;
+ double? windowHeight = null;
+ double? windowLeft = null;
+ double? windowTop = null;
+ var windowMaximized = false;
+ double? treeWidth = null;
foreach (var raw in lines)
{
var line = raw.Trim();
@@ -94,11 +116,90 @@ public sealed class UiPreferencesStore
{
indexArchives = IsTrue(value);
}
+ else if (key.Equals("show-hidden", StringComparison.OrdinalIgnoreCase))
+ {
+ showHidden = IsTrue(value);
+ }
+ else if (key.Equals("show-protected", StringComparison.OrdinalIgnoreCase))
+ {
+ showProtected = IsTrue(value);
+ }
+ else if (key.Equals("auto-clear-queue", StringComparison.OrdinalIgnoreCase))
+ {
+ autoClearQueue = IsTrue(value);
+ }
+ else if (key.Equals("window-width", StringComparison.OrdinalIgnoreCase))
+ {
+ windowWidth = ParseDouble(value);
+ }
+ else if (key.Equals("window-height", StringComparison.OrdinalIgnoreCase))
+ {
+ windowHeight = ParseDouble(value);
+ }
+ else if (key.Equals("window-left", StringComparison.OrdinalIgnoreCase))
+ {
+ windowLeft = ParseDouble(value);
+ }
+ else if (key.Equals("window-top", StringComparison.OrdinalIgnoreCase))
+ {
+ windowTop = ParseDouble(value);
+ }
+ else if (key.Equals("window-maximized", StringComparison.OrdinalIgnoreCase))
+ {
+ windowMaximized = IsTrue(value);
+ }
+ else if (key.Equals("tree-width", StringComparison.OrdinalIgnoreCase))
+ {
+ treeWidth = ParseDouble(value);
+ }
}
- return new UiPreferences(theme, groupNetwork, groupCloud, indexArchives);
+ return new UiPreferences(
+ theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
+ windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth);
}
+ private static IEnumerable LayoutLines(UiPreferences preferences)
+ {
+ if (preferences.WindowWidth is { } width)
+ {
+ yield return "window-width=" + Format(width);
+ }
+
+ if (preferences.WindowHeight is { } height)
+ {
+ yield return "window-height=" + Format(height);
+ }
+
+ if (preferences.WindowLeft is { } left)
+ {
+ yield return "window-left=" + Format(left);
+ }
+
+ if (preferences.WindowTop is { } top)
+ {
+ yield return "window-top=" + Format(top);
+ }
+
+ if (preferences.WindowMaximized)
+ {
+ yield return "window-maximized=true";
+ }
+
+ if (preferences.TreeWidth is { } tree)
+ {
+ yield return "tree-width=" + Format(tree);
+ }
+ }
+
+ private static string Format(double value) => value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
+
+ private static double? ParseDouble(string value)
+ => double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed)
+ && double.IsFinite(parsed)
+ ? parsed
+ : null;
+
public static string NormalizeTheme(string? theme)
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
diff --git a/src/Explorer.Domain/Abstractions/IIndexStore.cs b/src/Explorer.Domain/Abstractions/IIndexStore.cs
index 6961dea..0240162 100644
--- a/src/Explorer.Domain/Abstractions/IIndexStore.cs
+++ b/src/Explorer.Domain/Abstractions/IIndexStore.cs
@@ -68,6 +68,7 @@ public interface IScanJobStore
Task InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
+ Task HasActiveAsync(long sourceId, CancellationToken cancellationToken = default);
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
Task GetAsync(long id, CancellationToken cancellationToken = default);
Task> GetRecentAsync(int take, CancellationToken cancellationToken = default);
diff --git a/src/Explorer.Domain/Abstractions/IOsClipboard.cs b/src/Explorer.Domain/Abstractions/IOsClipboard.cs
index b7ee000..0358065 100644
--- a/src/Explorer.Domain/Abstractions/IOsClipboard.cs
+++ b/src/Explorer.Domain/Abstractions/IOsClipboard.cs
@@ -3,5 +3,6 @@ namespace Explorer.Domain.Abstractions;
public interface IOsClipboard
{
void SetFiles(IReadOnlyList paths, bool cut);
+ void SetText(string text);
bool TryGetFiles(out IReadOnlyList paths, out bool cut);
}
diff --git a/src/Explorer.Domain/Abstractions/Platform.cs b/src/Explorer.Domain/Abstractions/Platform.cs
index 5995797..54d9d04 100644
--- a/src/Explorer.Domain/Abstractions/Platform.cs
+++ b/src/Explorer.Domain/Abstractions/Platform.cs
@@ -21,6 +21,7 @@ public interface IVolumeService
{
IReadOnlyList EnumerateOnlineVolumes();
VolumeFingerprint? Probe(string path);
+ VolumeSpace GetSpace(string path);
bool IsPathReachable(string path);
}
@@ -87,8 +88,8 @@ public interface IShellFileOperations
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);
+ bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null);
+ bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null);
}
public interface IIconService
diff --git a/src/Explorer.Domain/DefaultExcludes.cs b/src/Explorer.Domain/DefaultExcludes.cs
index fb010a0..5c7c21b 100644
--- a/src/Explorer.Domain/DefaultExcludes.cs
+++ b/src/Explorer.Domain/DefaultExcludes.cs
@@ -5,8 +5,6 @@ public static class DefaultExcludes
public static IReadOnlyList Create() =>
[
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\Windows", Scope = "global" },
- new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\System Volume Information", Scope = "global" },
- new() { Kind = ExcludeKind.Glob, Pattern = "$Recycle.Bin", Scope = "global" },
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\ProgramData\Microsoft", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = ".git", Scope = "global" },
diff --git a/src/Explorer.Domain/Entities.cs b/src/Explorer.Domain/Entities.cs
index d39ec63..4a35288 100644
--- a/src/Explorer.Domain/Entities.cs
+++ b/src/Explorer.Domain/Entities.cs
@@ -31,11 +31,14 @@ public sealed class VolumeFingerprint
public string? Filesystem { get; init; }
public string? Label { get; init; }
public long? CapacityBytes { get; init; }
+ public long? FreeBytes { get; init; }
public string? DeviceInstanceId { get; init; }
public required string RootPath { get; init; }
public string? DisplayName { get; init; }
}
+public readonly record struct VolumeSpace(long? CapacityBytes, long? FreeBytes);
+
public sealed class IndexEntry
{
public long Id { get; set; }
@@ -113,7 +116,11 @@ public sealed class TransferJob
public TransferStatus Status { get; set; }
public long? BytesTotal { get; set; }
public long BytesDone { get; set; }
+ public long FilesDone { get; set; }
+ public long FilesTotal { get; set; }
+ public string? CurrentPath { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
+ public DateTimeOffset? StartedUtc { get; set; }
public string? Error { get; set; }
public IReadOnlyList AdditionalSources { get; init; } = [];
}
@@ -134,6 +141,8 @@ public sealed class FileSystemItem
public LocationInfo Location { get; init; } = LocationInfo.None;
public SizeKnowledge SizeKnowledge { get; init; } = SizeKnowledge.Calculated;
public string? DisplayName { get; init; }
+ public long? FreeSpaceBytes { get; init; }
+ public long? CapacityBytes { get; init; }
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
}
diff --git a/src/Explorer.Domain/Enums.cs b/src/Explorer.Domain/Enums.cs
index 8badeef..29ecbb5 100644
--- a/src/Explorer.Domain/Enums.cs
+++ b/src/Explorer.Domain/Enums.cs
@@ -88,6 +88,7 @@ public enum TransferStatus
{
Queued,
Running,
+ Paused,
Cancelling,
Cancelled,
Failed,
diff --git a/src/Explorer.FileOperations/TransferQueue.cs b/src/Explorer.FileOperations/TransferQueue.cs
index c36a79b..ec61673 100644
--- a/src/Explorer.FileOperations/TransferQueue.cs
+++ b/src/Explorer.FileOperations/TransferQueue.cs
@@ -1,4 +1,3 @@
-using System.Threading.Channels;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
@@ -12,9 +11,15 @@ public sealed class TransferQueue : BackgroundService
private readonly IFileSystemEnumerator _enumerator;
private readonly IIndexStore _store;
private readonly ILogger _logger;
- private readonly Channel _channel = Channel.CreateUnbounded();
private readonly List _jobs = [];
private readonly object _gate = new();
+ private readonly SemaphoreSlim _signal = new(0);
+ private readonly Func _pauseRequested;
+ private volatile bool _queuePaused;
+ private volatile bool _haltPause;
+ private long? _holdJobId;
+ private CancellationTokenSource? _runningCts;
+ private DateTime _lastChangedUtc = DateTime.MinValue;
public event EventHandler? Changed;
public event EventHandler? JobFinished;
@@ -29,8 +34,11 @@ public sealed class TransferQueue : BackgroundService
_enumerator = enumerator;
_store = store;
_logger = logger;
+ _pauseRequested = () => _haltPause;
}
+ public bool IsPaused => _queuePaused;
+
public IReadOnlyList Snapshot()
{
lock (_gate)
@@ -84,19 +92,106 @@ public sealed class TransferQueue : BackgroundService
}, cancellationToken).ConfigureAwait(false);
}
- public void Cancel(long jobId)
+ public void PauseAll()
+ {
+ _queuePaused = true;
+ _haltPause = true;
+ RaiseChanged();
+ }
+
+ public void ResumeAll()
+ {
+ _queuePaused = false;
+ _haltPause = false;
+ _holdJobId = null;
+ lock (_gate)
+ {
+ foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Paused
+ && (j.StartedUtc is not null || j.CurrentPath is not null)))
+ {
+ job.Status = TransferStatus.Queued;
+ }
+ }
+
+ Pulse();
+ RaiseChanged();
+ }
+
+ public void Pause(long jobId)
{
lock (_gate)
{
- var job = _jobs.FirstOrDefault(j => j.Id == jobId);
+ var job = Find(jobId);
if (job is null)
{
return;
}
- if (job.Status is TransferStatus.Queued or TransferStatus.Running)
+ if (job.Status == TransferStatus.Queued)
+ {
+ job.Status = TransferStatus.Paused;
+ }
+ else if (job.Status == TransferStatus.Running)
+ {
+ _haltPause = true;
+ _holdJobId = jobId;
+ }
+ }
+
+ RaiseChanged();
+ }
+
+ public void Resume(long jobId)
+ {
+ lock (_gate)
+ {
+ var job = Find(jobId);
+ if (job is null || job.Status != TransferStatus.Paused)
+ {
+ return;
+ }
+
+ job.Status = TransferStatus.Queued;
+ }
+
+ _queuePaused = false;
+ _haltPause = false;
+ if (_holdJobId == jobId)
+ {
+ _holdJobId = null;
+ }
+
+ Pulse();
+ RaiseChanged();
+ }
+
+ public void Cancel(long jobId)
+ {
+ CancellationTokenSource? running = null;
+ lock (_gate)
+ {
+ var job = Find(jobId);
+ if (job is null)
+ {
+ return;
+ }
+
+ if (job.Status is TransferStatus.Queued or TransferStatus.Paused)
+ {
+ job.Status = TransferStatus.Cancelled;
+ _jobs.Remove(job);
+ if (_holdJobId == jobId)
+ {
+ _holdJobId = null;
+ }
+
+ Pulse();
+ }
+ else if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelling;
+ _haltPause = false;
+ running = _runningCts;
}
else
{
@@ -104,17 +199,67 @@ public sealed class TransferQueue : BackgroundService
}
}
- Changed?.Invoke(this, EventArgs.Empty);
+ running?.Cancel();
+ RaiseChanged();
}
public void Dismiss(long jobId)
{
lock (_gate)
{
- _jobs.RemoveAll(j => j.Id == jobId);
+ _jobs.RemoveAll(j => j.Id == jobId && j.Status is TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed);
}
- Changed?.Invoke(this, EventArgs.Empty);
+ RaiseChanged();
+ }
+
+ public void ClearFinished()
+ {
+ var removed = false;
+ lock (_gate)
+ {
+ removed = _jobs.RemoveAll(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled) > 0;
+ }
+
+ if (removed)
+ {
+ RaiseChanged();
+ }
+ }
+
+ public bool MoveUp(long jobId) => Move(jobId, -1);
+
+ public bool MoveDown(long jobId) => Move(jobId, 1);
+
+ public bool Move(long jobId, int delta)
+ {
+ if (delta == 0)
+ {
+ return false;
+ }
+
+ lock (_gate)
+ {
+ var index = _jobs.FindIndex(j => j.Id == jobId);
+ var target = index + delta;
+ if (index < 0 || target < 0 || target >= _jobs.Count)
+ {
+ return false;
+ }
+
+ var job = _jobs[index];
+ if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
+ {
+ return false;
+ }
+
+ _jobs.RemoveAt(index);
+ _jobs.Insert(target, job);
+ }
+
+ Pulse();
+ RaiseChanged();
+ return true;
}
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
@@ -122,76 +267,128 @@ public sealed class TransferQueue : BackgroundService
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
lock (_gate)
{
- _jobs.Insert(0, job);
+ _jobs.Add(job);
}
- _channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
- Changed?.Invoke(this, EventArgs.Empty);
+ Pulse();
+ RaiseChanged();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
- await foreach (var work in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
+ while (!stoppingToken.IsCancellationRequested)
{
- var job = work.Job;
- if (job.Status == TransferStatus.Cancelling)
+ TransferJob? job;
+ lock (_gate)
{
- job.Status = TransferStatus.Cancelled;
- await Persist(job).ConfigureAwait(false);
+ job = CanStartNext() ? NextRunnable() : null;
+ }
+
+ if (job is null)
+ {
+ try
+ {
+ await _signal.WaitAsync(stoppingToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
continue;
}
- job.Status = TransferStatus.Running;
- Changed?.Invoke(this, EventArgs.Empty);
- try
- {
- switch (job.Op)
- {
- case TransferOp.Copy:
- await CopyOrMove(job, move: false, stoppingToken).ConfigureAwait(false);
- break;
- case TransferOp.Move:
- await CopyOrMove(job, move: true, stoppingToken).ConfigureAwait(false);
- break;
- case TransferOp.Delete:
- Delete(job);
- break;
- }
-
- if (job.Status == TransferStatus.Cancelling)
- {
- job.Status = TransferStatus.Cancelled;
- }
- else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
- {
- job.Status = TransferStatus.Failed;
- }
- else if (job.Status != TransferStatus.Failed)
- {
- job.Status = TransferStatus.Done;
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
- job.Status = TransferStatus.Failed;
- job.Error = ex.Message;
- }
-
- await Persist(job).ConfigureAwait(false);
- if (job.Status is TransferStatus.Done or TransferStatus.Cancelled)
- {
- lock (_gate)
- {
- _jobs.Remove(job);
- }
- }
-
- Changed?.Invoke(this, EventArgs.Empty);
- JobFinished?.Invoke(this, job);
+ await RunAsync(job, stoppingToken).ConfigureAwait(false);
}
}
+ private bool CanStartNext()
+ {
+ if (_queuePaused)
+ {
+ return false;
+ }
+
+ return _holdJobId is not { } id
+ || !_jobs.Any(j => j.Id == id && j.Status is TransferStatus.Paused or TransferStatus.Running or TransferStatus.Cancelling);
+ }
+
+ private TransferJob? NextRunnable()
+ => _jobs.FirstOrDefault(j => j.Status == TransferStatus.Queued);
+
+ private async Task RunAsync(TransferJob job, CancellationToken stoppingToken)
+ {
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
+ _runningCts = linked;
+ _haltPause = false;
+ job.Status = TransferStatus.Running;
+ job.StartedUtc ??= DateTimeOffset.UtcNow;
+ job.Error = null;
+ RaiseChanged();
+ try
+ {
+ switch (job.Op)
+ {
+ case TransferOp.Copy:
+ await CopyOrMove(job, move: false, linked.Token).ConfigureAwait(false);
+ break;
+ case TransferOp.Move:
+ await CopyOrMove(job, move: true, linked.Token).ConfigureAwait(false);
+ break;
+ case TransferOp.Delete:
+ Delete(job);
+ break;
+ }
+
+ if (job.Status == TransferStatus.Paused)
+ {
+ await Persist(job).ConfigureAwait(false);
+ RaiseChanged();
+ return;
+ }
+
+ if (job.Status == TransferStatus.Cancelling)
+ {
+ job.Status = TransferStatus.Cancelled;
+ }
+ else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
+ {
+ job.Status = TransferStatus.Failed;
+ }
+ else if (job.Status != TransferStatus.Failed)
+ {
+ job.Status = TransferStatus.Done;
+ job.CurrentPath = null;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ job.Status = TransferStatus.Cancelled;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
+ job.Status = TransferStatus.Failed;
+ job.Error = ex.Message;
+ }
+ finally
+ {
+ if (ReferenceEquals(_runningCts, linked))
+ {
+ _runningCts = null;
+ }
+ }
+
+ if (job.Status == TransferStatus.Paused)
+ {
+ return;
+ }
+
+ await Persist(job).ConfigureAwait(false);
+ RaiseChanged();
+ JobFinished?.Invoke(this, job);
+ }
+
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
{
var src = job.SourcePath;
@@ -207,7 +404,7 @@ public sealed class TransferQueue : BackgroundService
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
- if (move && job.Status != TransferStatus.Failed && job.Status != TransferStatus.Cancelling)
+ if (move && job.Status is not TransferStatus.Failed and not TransferStatus.Cancelling and not TransferStatus.Paused)
{
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
}
@@ -215,36 +412,36 @@ public sealed class TransferQueue : BackgroundService
return;
}
- job.BytesTotal = item.SizeBytes;
- var progress = new Progress(b =>
+ job.FilesTotal = Math.Max(job.FilesTotal, 1);
+ job.CurrentPath = src;
+ var resume = File.Exists(PathRules.ToExtended(dst));
+ if (!TransferFile(src, dst, move, resume, job, committed: 0, stoppingToken, out var error))
{
- job.BytesDone = b;
- Changed?.Invoke(this, EventArgs.Empty);
- });
- var ok = move
- ? _shell.MoveFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out var error)
- : _shell.CopyFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out error);
- if (!ok)
- {
- job.Status = error == "Cancelled" ? TransferStatus.Cancelling : TransferStatus.Failed;
- job.Error = error;
- }
- else
- {
- job.BytesDone = job.BytesTotal ?? job.BytesDone;
+ ApplyHalt(job, src, error);
+ return;
}
+
+ job.BytesDone = job.BytesTotal ?? job.BytesDone;
+ job.FilesDone = 1;
+ job.CurrentPath = null;
+ await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
+ job.FilesDone = 0;
+ job.FilesTotal = 0;
+ job.BytesDone = 0;
+ job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
- if (job.Status == TransferStatus.Cancelling)
+ if (job.Status == TransferStatus.Cancelling || _haltPause)
{
+ ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
return;
}
@@ -263,30 +460,82 @@ public sealed class TransferQueue : BackgroundService
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
+ continue;
}
- else
- {
- job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
- var ok = move
- ? _shell.MoveFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out var err)
- : _shell.CopyFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out err);
- if (ok)
- {
- job.BytesDone += child.SizeBytes;
- }
- else if (err != "Cancelled")
- {
- job.Error = err;
- }
- Changed?.Invoke(this, EventArgs.Empty);
+ job.FilesTotal++;
+ job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
+ var destExists = File.Exists(PathRules.ToExtended(childDest));
+ var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
+ if (destExists && destLen == child.SizeBytes)
+ {
+ job.BytesDone += child.SizeBytes;
+ job.FilesDone++;
+ RaiseChanged(throttled: true);
+ continue;
}
+
+ job.CurrentPath = child.FullPath;
+ var committed = job.BytesDone;
+ if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, stoppingToken, out var err))
+ {
+ ApplyHalt(job, child.FullPath, err);
+ return;
+ }
+
+ job.BytesDone = committed + child.SizeBytes;
+ job.FilesDone++;
+ RaiseChanged(throttled: true);
}
}
+ job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
+ private bool TransferFile(
+ string src,
+ string dst,
+ bool move,
+ bool resumePartial,
+ TransferJob job,
+ long committed,
+ CancellationToken stoppingToken,
+ out string? error)
+ {
+ var progress = new Progress(b =>
+ {
+ job.BytesDone = committed + b;
+ RaiseChanged(throttled: true);
+ });
+ var ok = move
+ ? _shell.MoveFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested)
+ : _shell.CopyFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested);
+ return ok;
+ }
+
+ private void ApplyHalt(TransferJob job, string? path, string? error)
+ {
+ job.CurrentPath = path;
+ if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
+ {
+ job.Status = TransferStatus.Cancelling;
+ job.Error = error == "Cancelled" ? null : error;
+ return;
+ }
+
+ if (_haltPause || error == "Paused")
+ {
+ job.Status = TransferStatus.Paused;
+ job.Error = null;
+ _haltPause = false;
+ return;
+ }
+
+ job.Status = TransferStatus.Failed;
+ job.Error = error;
+ }
+
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
@@ -300,7 +549,31 @@ public sealed class TransferQueue : BackgroundService
}
}
- private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
+ private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId);
- private readonly record struct Work(TransferJob Job, CancellationTokenSource Cts);
+ private void Pulse()
+ {
+ if (_signal.CurrentCount == 0)
+ {
+ _signal.Release();
+ }
+ }
+
+ private void RaiseChanged(bool throttled = false)
+ {
+ if (throttled)
+ {
+ var now = DateTime.UtcNow;
+ if ((now - _lastChangedUtc).TotalMilliseconds < 80)
+ {
+ return;
+ }
+
+ _lastChangedUtc = now;
+ }
+
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
}
diff --git a/src/Explorer.Indexing/FilesystemScanner.cs b/src/Explorer.Indexing/FilesystemScanner.cs
index 390de42..4bc9875 100644
--- a/src/Explorer.Indexing/FilesystemScanner.cs
+++ b/src/Explorer.Indexing/FilesystemScanner.cs
@@ -12,19 +12,22 @@ public sealed class FilesystemScanner
private readonly StorageProviderRegistry _providers;
private readonly ILogger _logger;
private readonly ArchiveContentsIndexer? _archives;
+ private readonly UiPreferencesStore? _preferences;
public FilesystemScanner(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
ILogger logger,
- ArchiveContentsIndexer? archives = null)
+ ArchiveContentsIndexer? archives = null,
+ UiPreferencesStore? preferences = null)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
_logger = logger;
_archives = archives;
+ _preferences = preferences;
}
public async Task ScanAsync(
@@ -56,7 +59,8 @@ public sealed class FilesystemScanner
cancellationToken.ThrowIfCancellationRequested();
var excludes = await _store.Excludes.GetAllAsync(cancellationToken).ConfigureAwait(false);
- var evaluator = new ExcludeEvaluator(excludes);
+ var prefs = _preferences?.Load() ?? UiPreferences.Default;
+ var evaluator = new ExcludeEvaluator(excludes, skipHidden: !prefs.ShowHiddenFiles, skipSystem: false);
var root = source.LastRootPath ?? throw new InvalidOperationException("Source has no root path.");
var startRel = folderPathRel ?? "";
var startPath = PathRules.Combine(root, startRel);
@@ -128,13 +132,23 @@ public sealed class FilesystemScanner
continue;
}
+ var location = LocationClassifier.Classify(child.FullPath, child.Name, child.Attributes, child.IsDirectory);
+ if (!LocationVisibility.ShouldShow(location, prefs))
+ {
+ continue;
+ }
+
var rel = PathRules.MakeRelative(root, child.FullPath);
var entry = CreateEntry(source, frame.Id, child, rel, DateTimeOffset.UtcNow, generation);
pending.Add(entry);
if (child.IsDirectory)
{
job.DirsSeen++;
- if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
+ if (location.IsRecycleBin)
+ {
+ frame.Dirs++;
+ }
+ else if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
{
childDirs.Add(new Frame
{
diff --git a/src/Explorer.Indexing/FolderReconciler.cs b/src/Explorer.Indexing/FolderReconciler.cs
index 6d65bc8..128139e 100644
--- a/src/Explorer.Indexing/FolderReconciler.cs
+++ b/src/Explorer.Indexing/FolderReconciler.cs
@@ -10,17 +10,20 @@ public sealed class FolderReconciler
private readonly IFileSystemEnumerator _enumerator;
private readonly StorageProviderRegistry _providers;
private readonly ArchiveContentsIndexer? _archives;
+ private readonly UiPreferencesStore? _preferences;
public FolderReconciler(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
- ArchiveContentsIndexer? archives = null)
+ ArchiveContentsIndexer? archives = null,
+ UiPreferencesStore? preferences = null)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
_archives = archives;
+ _preferences = preferences;
}
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
@@ -56,7 +59,7 @@ public sealed class FolderReconciler
return;
}
- if (!Directory.Exists(full))
+ if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
{
return;
}
@@ -69,11 +72,18 @@ public sealed class FolderReconciler
var liveNames = new HashSet(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
var archivesToExpand = new List();
var archivesToTomb = new List();
+ var prefs = _preferences?.Load() ?? UiPreferences.Default;
await _store.RunWriteAsync(async s =>
{
foreach (var item in live)
{
+ var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
+ if (!LocationVisibility.ShouldShow(location, prefs) || location.IsRecycleBin)
+ {
+ continue;
+ }
+
var rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
var entry = new IndexEntry
{
diff --git a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
index 982f147..cf9c1c0 100644
--- a/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
@@ -92,22 +92,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
- IEnumerable ordered = listing.Items;
- ordered = SortProperty switch
- {
- "Size" => SortDescending ? ordered.OrderByDescending(i => i.SizeBytes) : ordered.OrderBy(i => i.SizeBytes),
- "Modified" => SortDescending ? ordered.OrderByDescending(i => i.ModifiedUtc) : ordered.OrderBy(i => i.ModifiedUtc),
- "Type" => SortDescending ? ordered.OrderByDescending(i => i.IsDirectory) : ordered.OrderBy(i => i.IsDirectory),
- _ => SortDescending
- ? ordered.OrderByDescending(i => i.IsDirectory).ThenByDescending(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
- : ordered.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
- };
-
- foreach (var item in ordered)
+ foreach (var item in listing.Items)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
}
+ ApplyCurrentSort();
+
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
@@ -141,6 +132,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
}
+
+ ApplyCurrentSort();
}
[RelayCommand]
@@ -218,8 +211,56 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
}
- partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
- partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
+ public void SortBy(string property)
+ {
+ if (string.Equals(SortProperty, property, StringComparison.OrdinalIgnoreCase))
+ {
+ SortDescending = !SortDescending;
+ }
+ else
+ {
+ SortProperty = property;
+ SortDescending = property is "Size" or "Free" or "Modified";
+ }
+
+ ApplyCurrentSort();
+ }
+
+ public void ApplyCurrentSort()
+ {
+ var ordered = OrderItems(Items, SortProperty, SortDescending).ToList();
+ Items.Clear();
+ foreach (var item in ordered)
+ {
+ Items.Add(item);
+ }
+ }
+
+ public static IEnumerable OrderItems(
+ IEnumerable items,
+ string property,
+ bool descending)
+ {
+ var names = StringComparer.CurrentCultureIgnoreCase;
+ return property switch
+ {
+ "Size" => descending
+ ? items.OrderByDescending(i => i.Item.SizeBytes).ThenBy(i => i.Name, names)
+ : items.OrderBy(i => i.Item.SizeBytes).ThenBy(i => i.Name, names),
+ "Free" => descending
+ ? items.OrderByDescending(i => i.Item.FreeSpaceBytes ?? -1).ThenBy(i => i.Name, names)
+ : items.OrderBy(i => i.Item.FreeSpaceBytes ?? long.MaxValue).ThenBy(i => i.Name, names),
+ "Modified" => descending
+ ? items.OrderByDescending(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names)
+ : items.OrderBy(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names),
+ "Type" => descending
+ ? items.OrderByDescending(i => i.TypeLabel, names).ThenByDescending(i => i.Name, names)
+ : items.OrderBy(i => i.TypeLabel, names).ThenBy(i => i.Name, names),
+ _ => descending
+ ? items.OrderBy(i => i.IsDirectory).ThenByDescending(i => i.Name, names)
+ : items.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, names)
+ };
+ }
private static IReadOnlyList BuildBreadcrumb(string path)
{
diff --git a/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs b/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
index fb3429a..c0fb381 100644
--- a/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
@@ -6,22 +6,53 @@ namespace Explorer.Presentation;
public sealed partial class FolderItemViewModel : ObservableObject
{
[ObservableProperty] private bool _isSelected;
+ [ObservableProperty] private bool _isRenaming;
+ [ObservableProperty] private bool _isDropTarget;
+ [ObservableProperty] private string _editName = "";
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
{
Item = item;
SizeFromIndex = sizeFromIndex;
+ _editName = item.Name;
+ }
+
+ public void BeginRename()
+ {
+ EditName = Item.Name;
+ IsRenaming = true;
+ }
+
+ public void CancelRename()
+ {
+ IsRenaming = false;
+ EditName = Item.Name;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
- public string Name => Item.Name;
+ public string Name => Item.DisplayName ?? Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
- public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
- public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
- ? ""
- : Formatters.Size(Item.SizeBytes);
+ public string TypeLabel => Item.Location.IsRecycleBin
+ ? "Recycle Bin"
+ : Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
+ public string SizeLabel => Item.SizeKnowledge switch
+ {
+ SizeKnowledge.Unknown when Item.Location.AccessDenied => "Access denied",
+ SizeKnowledge.Unknown => "",
+ SizeKnowledge.Partial when Item.SizeBytes > 0 => $"{Formatters.Size(Item.SizeBytes)} (partial)",
+ SizeKnowledge.Partial => "Access denied",
+ _ when Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0 => "",
+ _ => Formatters.Size(Item.SizeBytes)
+ };
+ public string FreeSpaceLabel => Item.FreeSpaceBytes is long free ? Formatters.Size(free) : "";
+ public string FreeSpaceTooltip
+ => Item.FreeSpaceBytes is long free && Item.CapacityBytes is long total && total > 0
+ ? $"{Formatters.Size(free)} free of {Formatters.Size(total)}"
+ : FreeSpaceLabel;
+ public string StatusGlyph => Item.Location.IsRecycleBin ? "🗑" : Item.Location.IsProtected || Item.Location.AccessDenied ? "⚠" : "";
+ public bool HasStatusGlyph => StatusGlyph.Length > 0;
public string ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
@@ -44,6 +75,11 @@ public sealed partial class FolderItemViewModel : ObservableObject
: $"{CloudStatus} · Size {logical} · On disk {Formatters.Size(disk)}";
}
+ if (Item.Location.AccessDenied)
+ {
+ return string.IsNullOrEmpty(CloudStatus) ? "Access denied" : $"{CloudStatus} · Access denied";
+ }
+
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
}
}
diff --git a/src/Explorer.Presentation/ViewModels/MainViewModel.cs b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
index f8a3731..d45a3c8 100644
--- a/src/Explorer.Presentation/ViewModels/MainViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/MainViewModel.cs
@@ -50,7 +50,8 @@ public sealed partial class MainViewModel : ObservableObject
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
- UiPreferencesStore preferences)
+ UiPreferencesStore preferences,
+ IVolumeService volumes)
{
_browse = browse;
_ops = ops;
@@ -64,10 +65,10 @@ public sealed partial class MainViewModel : ObservableObject
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
- Search = new SearchViewModel(search, sources);
+ Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
- Transfers = new TransferQueueViewModel(transfers);
+ Transfers = new TransferQueueViewModel(transfers, preferences);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
@@ -87,13 +88,22 @@ public sealed partial class MainViewModel : ObservableObject
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
+ void Apply()
+ {
+ Footer = text;
+ if (p.Status is ScanJobStatus.Done or ScanJobStatus.Failed or ScanJobStatus.Cancelled)
+ {
+ _ = Tree.ApplySourceStateAsync(p.SourceId);
+ }
+ }
+
if (_ui is { } ctx)
{
- ctx.Post(_ => Footer = text, null);
+ ctx.Post(_ => Apply(), null);
}
else
{
- Footer = text;
+ Apply();
}
};
}
@@ -304,32 +314,52 @@ public sealed partial class MainViewModel : ObservableObject
paths = [ActivePane.CurrentPath];
}
- CopyPathsToClipboard(paths);
+ CopyPathText(paths);
}
+ public event EventHandler? InlineRenameRequested;
+
[RelayCommand]
- public void NewFolder()
+ public async Task NewFolder()
+ {
+ var created = await CreateNewFolderAsync().ConfigureAwait(true);
+ if (created is not null)
+ {
+ InlineRenameRequested?.Invoke(this, created);
+ }
+ }
+
+ public async Task CreateNewFolderAsync()
{
if (LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
{
- return;
+ Footer = LocationRoots.IsVirtual(ActivePane.CurrentPath)
+ ? "Open a folder to create a new folder."
+ : "This location is offline.";
+ return null;
}
- _ops.NewFolder(ActivePane.CurrentPath);
+ var created = _ops.NewFolder(ActivePane.CurrentPath);
EnqueueReconcile(ActivePane.CurrentPath);
- _ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
+ Footer = $"Created {PathRules.GetFileName(created)}.";
+ await RefreshFolderViewsAsync(ActivePane.CurrentPath).ConfigureAwait(true);
+ return created;
}
public void RenameSelected(string newName)
{
var item = ActivePane.SelectedItems.FirstOrDefault();
- if (item is null)
+ if (item is not null)
{
- return;
+ RenameItem(item, newName);
}
+ }
+ public void RenameItem(FolderItemViewModel item, string newName)
+ {
_ops.Rename(item.FullPath, newName);
EnqueueReconcile(ActivePane.CurrentPath);
+ Footer = $"Renamed to {newName}.";
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
}
@@ -539,7 +569,7 @@ public sealed partial class MainViewModel : ObservableObject
{
if (Analysis.SelectedPath is { } path)
{
- CopyPathsToClipboard([path]);
+ CopyPathText([path]);
}
}
@@ -623,11 +653,27 @@ public sealed partial class MainViewModel : ObservableObject
return stored with { Theme = UiPreferencesStore.NormalizeTheme(Theme) };
}
+ public void SaveLayout(double width, double height, double left, double top, bool maximized, double treeWidth)
+ {
+ var stored = _preferences.Load();
+ _preferences.Save(stored with
+ {
+ Theme = UiPreferencesStore.NormalizeTheme(Theme),
+ WindowWidth = width,
+ WindowHeight = height,
+ WindowLeft = left,
+ WindowTop = top,
+ WindowMaximized = maximized,
+ TreeWidth = treeWidth
+ });
+ }
+
public async Task ApplyPreferencesAsync(UiPreferences preferences)
{
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };
_preferences.Save(normalized);
Theme = normalized.Theme;
+ Transfers.ApplyPreferences();
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
foreach (var tab in Tabs)
{
@@ -849,6 +895,23 @@ public sealed partial class MainViewModel : ObservableObject
Clipboard.SetFiles(paths, _clipboardIsCut);
}
+ private void CopyPathText(IReadOnlyList paths)
+ {
+ if (paths.Count == 0)
+ {
+ return;
+ }
+
+ Clipboard.SetText(string.Join(Environment.NewLine, paths.Select(QuotePath)));
+ Footer = paths.Count == 1 ? "Path copied." : $"{paths.Count} paths copied.";
+ }
+
+ private static string QuotePath(string path)
+ {
+ var normalized = PathRules.FromExtended(path);
+ return normalized.StartsWith('"') ? normalized : $"\"{normalized}\"";
+ }
+
partial void OnActiveTabChanged(ExplorerTabViewModel value)
{
PathText = value.ActivePane.CurrentPath;
diff --git a/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs b/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
index 7b1e434..06bd574 100644
--- a/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
@@ -15,6 +15,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _isOffline;
[ObservableProperty] private bool _childrenLoaded;
+ [ObservableProperty] private bool _isDropTarget;
public ObservableCollection Children { get; } = [];
public string Glyph { get; init; } = "\uE8B7";
@@ -321,6 +322,37 @@ public sealed class NavigationTreeViewModel
}
}
+ public async Task ApplySourceStateAsync(long sourceId, CancellationToken cancellationToken = default)
+ {
+ var source = await _sources.GetAsync(sourceId, cancellationToken).ConfigureAwait(true);
+ if (source is null)
+ {
+ return;
+ }
+
+ var node = FindBySourceId(Roots, sourceId)
+ ?? (source.LastRootPath is null ? null : FindByPath(Roots, source.LastRootPath));
+ if (node is null)
+ {
+ return;
+ }
+
+ node.Status = FormatStatus(source);
+ node.IsOffline = source.Status == SourceStatus.Offline;
+ }
+
+ public static string FormatStatus(Source source)
+ => source.Status switch
+ {
+ SourceStatus.Offline => source.LastSeenUtc is null
+ ? "Offline"
+ : $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
+ SourceStatus.Scanning => "Indexing",
+ SourceStatus.Stale => "May be out of date",
+ SourceStatus.Error => "Error",
+ _ => source.IsIndexed ? "Indexed" : ""
+ };
+
public static bool PathsEqual(string a, string b)
{
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
@@ -362,16 +394,7 @@ public sealed class NavigationTreeViewModel
{
Label = source.DisplayName,
Path = source.LastRootPath ?? source.DisplayName,
- Status = source.Status switch
- {
- SourceStatus.Offline => source.LastSeenUtc is null
- ? "Offline"
- : $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
- SourceStatus.Scanning => "Indexing",
- SourceStatus.Stale => "May be out of date",
- SourceStatus.Error => "Error",
- _ => source.IsIndexed ? "Indexed" : ""
- },
+ Status = FormatStatus(source),
IsOffline = source.Status == SourceStatus.Offline,
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2",
SourceId = source.Id,
@@ -489,6 +512,30 @@ public sealed class NavigationTreeViewModel
return null;
}
+ private static NavNodeViewModel? FindBySourceId(IEnumerable nodes, long sourceId)
+ {
+ foreach (var node in nodes)
+ {
+ if (node.IsPlaceholder)
+ {
+ continue;
+ }
+
+ if (node.SourceId == sourceId)
+ {
+ return node;
+ }
+
+ var child = FindBySourceId(node.Children, sourceId);
+ if (child is not null)
+ {
+ return child;
+ }
+ }
+
+ return null;
+ }
+
private static NavNodeViewModel? FindByPath(IEnumerable nodes, string path)
{
foreach (var node in nodes)
diff --git a/src/Explorer.Presentation/ViewModels/SearchViewModel.cs b/src/Explorer.Presentation/ViewModels/SearchViewModel.cs
index ddbdb5e..3fefc97 100644
--- a/src/Explorer.Presentation/ViewModels/SearchViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/SearchViewModel.cs
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
+using Explorer.Domain.Abstractions;
using Explorer.Search;
namespace Explorer.Presentation.ViewModels;
@@ -13,6 +14,7 @@ public sealed partial class SearchViewModel : ObservableObject
{
private readonly SearchService _search;
private readonly SourceManager _sources;
+ private readonly IVolumeService _volumes;
private CancellationTokenSource? _runCts;
[ObservableProperty] private string _text = "";
@@ -26,10 +28,11 @@ public sealed partial class SearchViewModel : ObservableObject
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
- public SearchViewModel(SearchService search, SourceManager sources)
+ public SearchViewModel(SearchService search, SourceManager sources, IVolumeService volumes)
{
_search = search;
_sources = sources;
+ _volumes = volumes;
Results = [];
}
@@ -111,12 +114,19 @@ public sealed partial class SearchViewModel : ObservableObject
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
var byId = sources.ToDictionary(s => s.Id);
+ var spaceCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
byId.TryGetValue(entry.SourceId, out var src);
var full = src?.LastRootPath is null
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
: PathRules.Combine(src.LastRootPath, entry.PathRel);
+ var spaceKey = PathRules.IsUnc(full) ? PathRules.CanonicalUncRoot(full) : Path.GetPathRoot(full) ?? full;
+ if (!spaceCache.TryGetValue(spaceKey, out var space))
+ {
+ space = _volumes.GetSpace(full);
+ spaceCache[spaceKey] = space;
+ }
Results.Add(new FolderItemViewModel(new FileSystemItem
{
FullPath = full,
@@ -127,7 +137,9 @@ public sealed partial class SearchViewModel : ObservableObject
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
- ReparseTag = entry.ReparseTag
+ ReparseTag = entry.ReparseTag,
+ FreeSpaceBytes = space.FreeBytes,
+ CapacityBytes = space.CapacityBytes
}, entry.IsDirectory));
}
diff --git a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
index 5e5d99a..39b4606 100644
--- a/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
+++ b/src/Explorer.Presentation/ViewModels/TransferQueueViewModel.cs
@@ -1,39 +1,243 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
+public sealed partial class TransferJobViewModel : ObservableObject
+{
+ [ObservableProperty] private TransferStatus _status;
+ [ObservableProperty] private string _title = "";
+ [ObservableProperty] private string _subtitle = "";
+ [ObservableProperty] private string _statusText = "";
+ [ObservableProperty] private string _bytesText = "";
+ [ObservableProperty] private string _speedText = "";
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasCurrentFile))]
+ private string _currentFile = "";
+ [ObservableProperty] private double _progress;
+ [ObservableProperty] private bool _hasProgress;
+ [ObservableProperty] private bool _canPause;
+ [ObservableProperty] private bool _canResume;
+ [ObservableProperty] private bool _canRemove;
+ [ObservableProperty] private bool _canMoveUp;
+ [ObservableProperty] private bool _canMoveDown;
+ [ObservableProperty] private bool _isActive;
+ [ObservableProperty] private bool _isFailed;
+
+ public long Id { get; }
+ public TransferJob Job { get; }
+ public bool HasCurrentFile => !string.IsNullOrWhiteSpace(CurrentFile);
+
+ public TransferJobViewModel(TransferJob job)
+ {
+ Id = job.Id;
+ Job = job;
+ Apply(job, canMoveUp: false, canMoveDown: false, speed: null);
+ }
+
+ public void Apply(TransferJob job, bool canMoveUp, bool canMoveDown, string? speed)
+ {
+ Status = job.Status;
+ Title = DisplayName(job);
+ Subtitle = DestinationText(job);
+ StatusText = StatusLabel(job);
+ BytesText = BytesLabel(job);
+ SpeedText = job.Status == TransferStatus.Running ? speed ?? "" : "";
+ CurrentFile = job.Status is TransferStatus.Running or TransferStatus.Paused
+ ? FileName(job.CurrentPath)
+ : "";
+ Progress = job.BytesTotal is > 0 ? Math.Clamp(job.BytesDone / (double)job.BytesTotal.Value, 0, 1) : 0;
+ HasProgress = job.BytesTotal is > 0 && job.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
+ CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
+ CanResume = job.Status == TransferStatus.Paused;
+ CanRemove = job.Status is not TransferStatus.Cancelling;
+ CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
+ CanMoveDown = canMoveDown && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
+ IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
+ IsFailed = job.Status == TransferStatus.Failed;
+ }
+
+ private static string DisplayName(TransferJob job)
+ {
+ if (job.Op == TransferOp.Delete)
+ {
+ var count = job.AdditionalSources.Count > 0
+ ? job.AdditionalSources.Count
+ : job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries).Length;
+ return count <= 1 ? FileName(job.SourcePath) : $"{count} items";
+ }
+
+ return FileName(job.SourcePath);
+ }
+
+ private static string DestinationText(TransferJob job)
+ => job.Op switch
+ {
+ TransferOp.Copy => $"Copy to {FolderName(job.DestinationPath)}",
+ TransferOp.Move => $"Move to {FolderName(job.DestinationPath)}",
+ TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
+ ? "Delete permanently"
+ : "Move to Recycle Bin",
+ _ => job.Op.ToString()
+ };
+
+ private static string StatusLabel(TransferJob job)
+ => job.Status switch
+ {
+ TransferStatus.Queued => "Queued",
+ TransferStatus.Running => job.FilesTotal > 0
+ ? $"{OpWord(job.Op)} {job.FilesDone:N0} of {job.FilesTotal:N0}"
+ : "Working…",
+ TransferStatus.Paused => "Paused",
+ TransferStatus.Cancelling => "Cancelling…",
+ TransferStatus.Cancelled => "Cancelled",
+ TransferStatus.Failed => string.IsNullOrWhiteSpace(job.Error) ? "Failed" : job.Error,
+ TransferStatus.Done => "Done",
+ _ => job.Status.ToString()
+ };
+
+ private static string BytesLabel(TransferJob job)
+ {
+ if (job.BytesTotal is > 0)
+ {
+ return $"{FormatBytes(job.BytesDone)} of {FormatBytes(job.BytesTotal.Value)}";
+ }
+
+ return job.BytesDone > 0 ? FormatBytes(job.BytesDone) : "";
+ }
+
+ internal static string FileName(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return "";
+ }
+
+ var name = PathRules.GetFileName(path.Split('|')[0]);
+ return string.IsNullOrWhiteSpace(name) ? path : name;
+ }
+
+ private static string FolderName(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return "";
+ }
+
+ var folder = PathRules.Parent(path);
+ return string.IsNullOrWhiteSpace(folder) ? path : folder;
+ }
+
+ internal static string FormatBytes(long bytes)
+ {
+ string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
+ double value = Math.Max(0, bytes);
+ var unit = 0;
+ while (value >= 1024 && unit < units.Length - 1)
+ {
+ value /= 1024;
+ unit++;
+ }
+
+ return unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
+ }
+
+ private static string OpWord(TransferOp op)
+ => op switch
+ {
+ TransferOp.Copy => "Copying",
+ TransferOp.Move => "Moving",
+ TransferOp.Delete => "Deleting",
+ _ => "Working"
+ };
+}
+
public sealed partial class TransferQueueViewModel : ObservableObject
{
private readonly TransferQueue _queue;
+ private readonly UiPreferencesStore _preferences;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
+ private readonly Dictionary _speed = [];
- public TransferQueueViewModel(TransferQueue queue)
+ private bool _holdCollapsed;
+
+ [ObservableProperty] private bool _isExpanded;
+ [ObservableProperty] private bool _showPanel;
+ [ObservableProperty] private bool _isQueuePaused;
+ [ObservableProperty] private bool _hasJobs;
+ [ObservableProperty] private bool _hasActiveJobs;
+ [ObservableProperty] private bool _hasFinishedJobs;
+ [ObservableProperty] private bool _canPauseAll;
+ [ObservableProperty] private bool _canResumeAll;
+ [ObservableProperty] private string _summary = "";
+ [ObservableProperty] private double _overallProgress;
+ [ObservableProperty] private bool _hasOverallProgress;
+
+ public TransferQueueViewModel(TransferQueue queue, UiPreferencesStore preferences)
{
_queue = queue;
+ _preferences = preferences;
Jobs = [];
- _queue.Changed += (_, _) =>
- {
- if (_ui is { } ctx)
- {
- ctx.Post(_ => Reload(), null);
- }
- else
- {
- Reload();
- }
- };
+ _queue.Changed += (_, _) => Dispatch(Reload);
Reload();
}
- public ObservableCollection Jobs { get; }
- public bool HasJobs => Jobs.Count > 0;
-
- public void Cancel(TransferJob job)
+ public void ApplyPreferences()
{
- if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling)
+ if (_preferences.Load().AutoClearQueueWhenDone)
+ {
+ _queue.ClearFinished();
+ }
+
+ Reload();
+ }
+
+ public ObservableCollection Jobs { get; }
+
+ [RelayCommand]
+ public void ToggleExpanded()
+ {
+ IsExpanded = !IsExpanded;
+ _holdCollapsed = !IsExpanded;
+ }
+
+ [RelayCommand]
+ public void PauseAll() => _queue.PauseAll();
+
+ [RelayCommand]
+ public void ResumeAll() => _queue.ResumeAll();
+
+ [RelayCommand]
+ public void Pause(TransferJobViewModel? job)
+ {
+ if (job is not null)
+ {
+ _queue.Pause(job.Id);
+ }
+ }
+
+ [RelayCommand]
+ public void Resume(TransferJobViewModel? job)
+ {
+ if (job is not null)
+ {
+ _queue.Resume(job.Id);
+ }
+ }
+
+ [RelayCommand]
+ public void Remove(TransferJobViewModel? job)
+ {
+ if (job is null)
+ {
+ return;
+ }
+
+ if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -43,18 +247,184 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
- private void Reload()
+ [RelayCommand]
+ public void MoveUp(TransferJobViewModel? job)
{
- Jobs.Clear();
- foreach (var job in _queue.Snapshot().Where(IsVisible))
+ if (job is not null)
{
- Jobs.Add(job);
+ _queue.MoveUp(job.Id);
}
-
- OnPropertyChanged(nameof(HasJobs));
}
+ [RelayCommand]
+ public void MoveDown(TransferJobViewModel? job)
+ {
+ if (job is not null)
+ {
+ _queue.MoveDown(job.Id);
+ }
+ }
+
+ [RelayCommand]
+ public void ClearFinished() => _queue.ClearFinished();
+
+ public void Cancel(TransferJob job)
+ {
+ if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
+ {
+ _queue.Cancel(job.Id);
+ }
+ else
+ {
+ _queue.Dismiss(job.Id);
+ }
+ }
+
+ private void Dispatch(Action action)
+ {
+ if (_ui is { } ctx)
+ {
+ ctx.Post(_ => action(), null);
+ }
+ else
+ {
+ action();
+ }
+ }
+
+ private void Reload()
+ {
+ if (_preferences.Load().AutoClearQueueWhenDone)
+ {
+ _queue.ClearFinished();
+ }
+
+ var snapshot = _queue.Snapshot();
+ var visible = snapshot.Where(IsVisible).ToList();
+ var ids = visible.Select(j => j.Id).ToHashSet();
+
+ for (var i = Jobs.Count - 1; i >= 0; i--)
+ {
+ if (!ids.Contains(Jobs[i].Id))
+ {
+ _speed.Remove(Jobs[i].Id);
+ Jobs.RemoveAt(i);
+ }
+ }
+
+ for (var i = 0; i < visible.Count; i++)
+ {
+ var job = visible[i];
+ var existing = Jobs.FirstOrDefault(j => j.Id == job.Id);
+ var canMoveUp = i > 0 && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
+ var canMoveDown = i < visible.Count - 1 && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
+ var speed = SpeedText(job);
+ if (existing is null)
+ {
+ var vm = new TransferJobViewModel(job);
+ vm.Apply(job, canMoveUp, canMoveDown, speed);
+ Jobs.Insert(i, vm);
+ }
+ else
+ {
+ existing.Apply(job, canMoveUp, canMoveDown, speed);
+ var current = Jobs.IndexOf(existing);
+ if (current != i && current >= 0)
+ {
+ Jobs.Move(current, i);
+ }
+ }
+ }
+
+ var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling).ToList();
+ var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused)
+ ?? active.FirstOrDefault();
+ HasJobs = Jobs.Count > 0;
+ HasActiveJobs = active.Count > 0;
+ HasFinishedJobs = snapshot.Any(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled);
+ IsQueuePaused = _queue.IsPaused || active.Any(j => j.Status == TransferStatus.Paused);
+ CanPauseAll = active.Any(j => j.Status is TransferStatus.Queued or TransferStatus.Running);
+ CanResumeAll = _queue.IsPaused || active.Any(j => j.Status == TransferStatus.Paused);
+ HasOverallProgress = currentJob?.BytesTotal is > 0;
+ OverallProgress = currentJob?.BytesTotal is > 0
+ ? Math.Clamp(currentJob.BytesDone / (double)currentJob.BytesTotal.Value, 0, 1)
+ : 0;
+ Summary = BuildSummary(active, currentJob);
+ if (HasActiveJobs && !_holdCollapsed)
+ {
+ IsExpanded = true;
+ }
+ else if (!HasActiveJobs)
+ {
+ _holdCollapsed = false;
+ }
+
+ ShowPanel = IsExpanded && HasJobs;
+ }
+
+ partial void OnIsExpandedChanged(bool value) => ShowPanel = value && HasJobs;
+
+ private string? SpeedText(TransferJob job)
+ {
+ if (job.Status != TransferStatus.Running)
+ {
+ _speed.Remove(job.Id);
+ return null;
+ }
+
+ var now = DateTime.UtcNow;
+ if (_speed.TryGetValue(job.Id, out var prev))
+ {
+ var seconds = (now - prev.Utc).TotalSeconds;
+ if (seconds >= 0.4 && job.BytesDone >= prev.Bytes)
+ {
+ var rate = (job.BytesDone - prev.Bytes) / seconds;
+ _speed[job.Id] = (job.BytesDone, now);
+ return rate > 0 ? $"{TransferJobViewModel.FormatBytes((long)rate)}/s" : null;
+ }
+
+ return null;
+ }
+
+ _speed[job.Id] = (job.BytesDone, now);
+ return null;
+ }
+
+ private static string BuildSummary(IReadOnlyList active, TransferJob? current)
+ {
+ if (active.Count == 0)
+ {
+ return "";
+ }
+
+ var waiting = active.Count(j => j.Status == TransferStatus.Queued);
+ var name = current is null ? $"{active.Count} transfers" : TransferJobViewModel.FileName(current.SourcePath);
+ if (current?.Status == TransferStatus.Paused)
+ {
+ return waiting > 0 ? $"Paused · {name} · {waiting} waiting" : $"Paused · {name}";
+ }
+
+ if (current?.Status == TransferStatus.Running)
+ {
+ return waiting > 0 ? $"{OpVerb(current.Op)} {name} · {waiting} waiting" : $"{OpVerb(current.Op)} {name}";
+ }
+
+ return waiting == active.Count
+ ? $"{active.Count} queued"
+ : $"{active.Count} transfers";
+ }
+
+ private static string OpVerb(TransferOp op)
+ => op switch
+ {
+ TransferOp.Copy => "Copying",
+ TransferOp.Move => "Moving",
+ TransferOp.Delete => "Deleting",
+ _ => op.ToString()
+ };
+
private static bool IsVisible(TransferJob job)
- => job.Status is TransferStatus.Queued or TransferStatus.Running
- or TransferStatus.Cancelling or TransferStatus.Failed;
+ => job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
+ or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
+ or TransferStatus.Cancelled;
}
diff --git a/src/Explorer.Storage.Sqlite/MiscStores.cs b/src/Explorer.Storage.Sqlite/MiscStores.cs
index 66a1525..bc23481 100644
--- a/src/Explorer.Storage.Sqlite/MiscStores.cs
+++ b/src/Explorer.Storage.Sqlite/MiscStores.cs
@@ -60,6 +60,11 @@ internal sealed class ExcludeStore : IExcludeStore
{
await AddAsync(rule, cancellationToken).ConfigureAwait(false);
}
+
+ await _store.WriteAsync(conn => conn.ExecuteAsync("""
+ DELETE FROM excludes
+ WHERE pattern IN ('C:\System Volume Information', '$Recycle.Bin')
+ """), cancellationToken).ConfigureAwait(false);
}, cancellationToken);
}
@@ -88,10 +93,28 @@ internal sealed class ScanJobStore : IScanJobStore
""", Args(job)), cancellationToken);
public Task InterruptRunningAsync(CancellationToken cancellationToken = default)
- => _store.WriteAsync(conn => conn.ExecuteAsync("""
- UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
- WHERE status IN ('Running','Queued')
- """, new { utc = DateTimeOffset.UtcNow.ToString("O") }), cancellationToken);
+ => _store.WriteAsync(async conn =>
+ {
+ var utc = DateTimeOffset.UtcNow.ToString("O");
+ await conn.ExecuteAsync("""
+ UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
+ WHERE status IN ('Running','Queued')
+ """, new { utc }).ConfigureAwait(false);
+ await conn.ExecuteAsync("""
+ UPDATE sources SET status = CASE WHEN last_indexed_utc IS NOT NULL THEN 'Stale' ELSE 'Online' END
+ WHERE status = 'Scanning'
+ """).ConfigureAwait(false);
+ }, cancellationToken);
+
+ public async Task HasActiveAsync(long sourceId, CancellationToken cancellationToken = default)
+ {
+ await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
+ var count = await conn.ExecuteScalarAsync("""
+ SELECT COUNT(*) FROM scan_jobs
+ WHERE source_id = @sourceId AND status IN ('Running','Queued')
+ """, new { sourceId }).ConfigureAwait(false);
+ return count > 0;
+ }
public Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
diff --git a/src/Explorer.Windows/NativeMethods.cs b/src/Explorer.Windows/NativeMethods.cs
index 0cd9590..5fa212b 100644
--- a/src/Explorer.Windows/NativeMethods.cs
+++ b/src/Explorer.Windows/NativeMethods.cs
@@ -34,6 +34,8 @@ internal static partial class NativeMethods
public const uint MoveFileWriteThrough = 0x0008;
public const int ProgressContinue = 0;
public const int ProgressCancel = 1;
+ public const int ProgressStop = 2;
+ public const int ErrorRequestAborted = 1235;
public const uint ProcessModeBackgroundBegin = 0x00100000;
public const uint ProcessModeBackgroundEnd = 0x00200000;
@@ -56,6 +58,10 @@ internal static partial class NativeMethods
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetVolumeInformation(string lpRootPathName, [Out] char[]? lpVolumeNameBuffer, uint nVolumeNameSize, out uint lpVolumeSerialNumber, out uint lpMaximumComponentLength, out uint lpFileSystemFlags, [Out] char[]? lpFileSystemNameBuffer, uint nFileSystemNameSize);
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetDiskFreeSpaceEx(string lpDirectoryName, out ulong lpFreeBytesAvailableToCaller, out ulong lpTotalNumberOfBytes, out ulong lpTotalNumberOfFreeBytes);
+
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetFileInformationByHandle(nint hFile, out ByHandleFileInformation lpFileInformation);
diff --git a/src/Explorer.Windows/WindowsElevatedScanService.cs b/src/Explorer.Windows/WindowsElevatedScanService.cs
new file mode 100644
index 0000000..f9375d4
--- /dev/null
+++ b/src/Explorer.Windows/WindowsElevatedScanService.cs
@@ -0,0 +1,27 @@
+using System.Security.Principal;
+using Explorer.Application;
+
+namespace Explorer.Windows;
+
+public sealed class WindowsElevatedScanService : IElevatedScanService
+{
+ public bool IsElevated
+ {
+ get
+ {
+ try
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
+ public bool CanRequestElevation => false;
+
+ public string ProtectedContentHint => "Protected content requires administrator privileges.";
+}
diff --git a/src/Explorer.Windows/WindowsShellFileOperations.cs b/src/Explorer.Windows/WindowsShellFileOperations.cs
index d324245..26502ab 100644
--- a/src/Explorer.Windows/WindowsShellFileOperations.cs
+++ b/src/Explorer.Windows/WindowsShellFileOperations.cs
@@ -77,18 +77,35 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
}
}
- public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error)
+ public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
{
error = null;
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
var cancel = 0;
+ var paused = false;
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
{
progress?.Report(transferred);
- return cancellationToken.IsCancellationRequested ? NativeMethods.ProgressCancel : NativeMethods.ProgressContinue;
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return NativeMethods.ProgressCancel;
+ }
+
+ if (pauseRequested?.Invoke() == true)
+ {
+ paused = true;
+ return NativeMethods.ProgressStop;
+ }
+
+ return NativeMethods.ProgressContinue;
};
- var flags = overwrite ? 0u : NativeMethods.CopyFileFailIfExists;
+ var flags = NativeMethods.CopyFileRestartable;
+ if (!overwrite)
+ {
+ flags |= NativeMethods.CopyFileFailIfExists;
+ }
+
var ok = NativeMethods.CopyFileEx(
PathRules.ToExtended(source),
PathRules.ToExtended(destination),
@@ -99,7 +116,13 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
if (!ok)
{
var code = Marshal.GetLastWin32Error();
- if (cancellationToken.IsCancellationRequested || code == 1235)
+ if (paused)
+ {
+ error = "Paused";
+ return false;
+ }
+
+ if (cancellationToken.IsCancellationRequested || code == NativeMethods.ErrorRequestAborted)
{
error = "Cancelled";
return false;
@@ -112,14 +135,26 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
return true;
}
- public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error)
+ public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
{
error = null;
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
+ var paused = false;
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
{
progress?.Report(transferred);
- return cancellationToken.IsCancellationRequested ? NativeMethods.ProgressCancel : NativeMethods.ProgressContinue;
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return NativeMethods.ProgressCancel;
+ }
+
+ if (pauseRequested?.Invoke() == true)
+ {
+ paused = true;
+ return NativeMethods.ProgressStop;
+ }
+
+ return NativeMethods.ProgressContinue;
};
var flags = NativeMethods.MoveFileCopyAllowed | NativeMethods.MoveFileWriteThrough;
@@ -137,7 +172,13 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
if (!ok)
{
var code = Marshal.GetLastWin32Error();
- if (cancellationToken.IsCancellationRequested)
+ if (paused)
+ {
+ error = "Paused";
+ return false;
+ }
+
+ if (cancellationToken.IsCancellationRequested || code == NativeMethods.ErrorRequestAborted)
{
error = "Cancelled";
return false;
diff --git a/src/Explorer.Windows/WindowsVolumeService.cs b/src/Explorer.Windows/WindowsVolumeService.cs
index f7c0777..c242491 100644
--- a/src/Explorer.Windows/WindowsVolumeService.cs
+++ b/src/Explorer.Windows/WindowsVolumeService.cs
@@ -46,12 +46,15 @@ public sealed class WindowsVolumeService : IVolumeService
if (PathRules.IsUnc(path))
{
var root = PathRules.CanonicalUncRoot(path);
+ var uncSpace = QuerySpace(root);
return new VolumeFingerprint
{
Kind = SourceKind.Smb,
RootPath = root,
DisplayName = root,
- Filesystem = "SMB"
+ Filesystem = "SMB",
+ CapacityBytes = uncSpace.CapacityBytes,
+ FreeBytes = uncSpace.FreeBytes
};
}
@@ -83,14 +86,15 @@ public sealed class WindowsVolumeService : IVolumeService
uint serial = 0;
string? fs = null;
string? label = null;
- long? capacity = null;
+ var space = QuerySpace(rootPath);
+ long? capacity = space.CapacityBytes;
try
{
if (drive is { IsReady: true })
{
fs = drive.DriveFormat;
label = drive.VolumeLabel;
- capacity = drive.TotalSize;
+ capacity ??= drive.TotalSize;
}
}
catch (IOException)
@@ -165,6 +169,7 @@ public sealed class WindowsVolumeService : IVolumeService
Filesystem = fs,
Label = label,
CapacityBytes = capacity,
+ FreeBytes = space.FreeBytes,
RootPath = rootPath,
DisplayName = display
};
@@ -176,6 +181,8 @@ public sealed class WindowsVolumeService : IVolumeService
}
}
+ public VolumeSpace GetSpace(string path) => QuerySpace(path);
+
public bool IsPathReachable(string path)
{
try
@@ -189,6 +196,44 @@ public sealed class WindowsVolumeService : IVolumeService
}
}
+ private static VolumeSpace QuerySpace(string path)
+ {
+ try
+ {
+ if (NativeMethods.GetDiskFreeSpaceEx(
+ PathRules.ToExtended(path),
+ out var free,
+ out var total,
+ out _))
+ {
+ return new VolumeSpace((long)total, (long)free);
+ }
+ }
+ catch
+ {
+ // fall through
+ }
+
+ try
+ {
+ var root = Path.GetPathRoot(path);
+ if (!string.IsNullOrEmpty(root))
+ {
+ var drive = new DriveInfo(root);
+ if (drive.IsReady)
+ {
+ return new VolumeSpace(drive.TotalSize, drive.AvailableFreeSpace);
+ }
+ }
+ }
+ catch
+ {
+ // optional
+ }
+
+ return default;
+ }
+
private static string FormatSize(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
diff --git a/tests/Explorer.Application.Tests/BrowseServiceTests.cs b/tests/Explorer.Application.Tests/BrowseServiceTests.cs
index 4cc19aa..ead7473 100644
--- a/tests/Explorer.Application.Tests/BrowseServiceTests.cs
+++ b/tests/Explorer.Application.Tests/BrowseServiceTests.cs
@@ -30,6 +30,8 @@ public class BrowseServiceTests
var listing = await browse.ListThisPcAsync();
var item = Assert.Single(listing.Items);
Assert.Equal(1_073_741_824, item.SizeBytes);
+ Assert.Equal(250_000_000, item.FreeSpaceBytes);
+ Assert.Equal(1_000_000_000, item.CapacityBytes);
Assert.True(item.IsDirectory);
}
@@ -65,6 +67,7 @@ public class BrowseServiceTests
var listing = await browse.ListAsync(@"C:\");
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
Assert.Equal(200, movies.SizeBytes);
+ Assert.Equal(250_000_000, movies.FreeSpaceBytes);
var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt");
Assert.Equal(10, loose.SizeBytes);
}
@@ -83,7 +86,9 @@ public class BrowseServiceTests
Kind = SourceKind.NtfsLocal,
RootPath = root,
DisplayName = display,
- VolumeSerial = 1
+ VolumeSerial = 1,
+ CapacityBytes = 1_000_000_000,
+ FreeBytes = 250_000_000
}
]
};
@@ -128,6 +133,11 @@ file sealed class BrowseVolumes : IVolumeService
=> Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
public bool IsPathReachable(string path)
=> Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
+ public VolumeSpace GetSpace(string path)
+ {
+ var fp = Probe(path);
+ return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
+ }
}
file sealed class BrowseEnv : IAppEnvironment
diff --git a/tests/Explorer.Application.Tests/SourceManagerTests.cs b/tests/Explorer.Application.Tests/SourceManagerTests.cs
index 08f668e..fba96b8 100644
--- a/tests/Explorer.Application.Tests/SourceManagerTests.cs
+++ b/tests/Explorer.Application.Tests/SourceManagerTests.cs
@@ -136,6 +136,39 @@ public class SourceManagerTests
Assert.Null(await store.Entries.GetRootAsync(source.Id));
}
+ [Fact]
+ public async Task Stale_scanning_status_clears_when_no_job_is_running()
+ {
+ var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
+ await using var store = new SqliteIndexStore(db, NullLogger.Instance);
+ await store.OpenAsync();
+ var volumes = new FakeVolumes
+ {
+ Online =
+ [
+ new VolumeFingerprint
+ {
+ Kind = SourceKind.NtfsLocal,
+ RootPath = @"D:\",
+ DisplayName = "Games (D:)",
+ VolumeSerial = 42,
+ CapacityBytes = 1000
+ }
+ ]
+ };
+ var env = new FakeEnv(Path.GetDirectoryName(db)!);
+ var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance);
+ await mgr.InitializeAsync();
+ var source = (await store.Sources.GetAllAsync()).Single();
+ await store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null);
+ await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, 1);
+
+ await mgr.RefreshOnlineStateAsync();
+ source = (await store.Sources.GetAllAsync()).Single();
+ Assert.Equal(SourceStatus.Online, source.Status);
+ Assert.True(source.IsIndexed);
+ }
+
[Fact]
public async Task Forget_removes_unc_from_recents()
{
@@ -166,6 +199,11 @@ file sealed class FakeVolumes : IVolumeService
|| v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase));
}
public bool IsPathReachable(string path) => Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
+ public VolumeSpace GetSpace(string path)
+ {
+ var fp = Probe(path);
+ return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
+ }
}
file sealed class FakeEnv : IAppEnvironment
diff --git a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
index fdd52ef..26c33b7 100644
--- a/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
+++ b/tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
@@ -1,4 +1,5 @@
using Explorer.Application;
+using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application.Tests;
@@ -13,12 +14,14 @@ public class UiPreferencesStoreTests
"theme=Light",
"group-network=true",
"group-cloud=false",
- "index-archives=true"
+ "index-archives=true",
+ "auto-clear-queue=true"
]);
Assert.Equal("Light", prefs.Theme);
Assert.True(prefs.GroupNetworkPlaces);
Assert.False(prefs.GroupCloudPlaces);
Assert.True(prefs.IndexArchiveContents);
+ Assert.True(prefs.AutoClearQueueWhenDone);
}
[Fact]
@@ -29,6 +32,30 @@ public class UiPreferencesStoreTests
Assert.False(prefs.GroupNetworkPlaces);
Assert.False(prefs.GroupCloudPlaces);
Assert.False(prefs.IndexArchiveContents);
+ Assert.True(prefs.ShowHiddenFiles);
+ Assert.False(prefs.ShowProtectedSystemLocations);
+ Assert.False(prefs.AutoClearQueueWhenDone);
+ }
+
+ [Fact]
+ public void Parse_reads_window_layout()
+ {
+ var prefs = UiPreferencesStore.Parse(
+ [
+ "theme=Dark",
+ "window-width=1440.5",
+ "window-height=900",
+ "window-left=12",
+ "window-top=24",
+ "window-maximized=true",
+ "tree-width=320"
+ ]);
+ Assert.Equal(1440.5, prefs.WindowWidth);
+ Assert.Equal(900, prefs.WindowHeight);
+ Assert.Equal(12, prefs.WindowLeft);
+ Assert.Equal(24, prefs.WindowTop);
+ Assert.True(prefs.WindowMaximized);
+ Assert.Equal(320, prefs.TreeWidth);
}
[Fact]
@@ -38,12 +65,18 @@ public class UiPreferencesStoreTests
try
{
var store = new UiPreferencesStore(new PrefsEnv(dir));
- store.Save(new UiPreferences("Light", true, false, true));
+ store.Save(new UiPreferences("Light", true, false, true, AutoClearQueueWhenDone: true, WindowWidth: 1100, WindowHeight: 720, TreeWidth: 300));
var loaded = store.Load();
Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces);
Assert.False(loaded.GroupCloudPlaces);
Assert.True(loaded.IndexArchiveContents);
+ Assert.True(loaded.ShowHiddenFiles);
+ Assert.False(loaded.ShowProtectedSystemLocations);
+ Assert.True(loaded.AutoClearQueueWhenDone);
+ Assert.Equal(1100, loaded.WindowWidth);
+ Assert.Equal(720, loaded.WindowHeight);
+ Assert.Equal(300, loaded.TreeWidth);
}
finally
{
@@ -52,6 +85,28 @@ public class UiPreferencesStoreTests
}
}
+public class LocationVisibilityTests
+{
+ [Fact]
+ public void Hides_protected_when_setting_is_off()
+ {
+ var prefs = UiPreferences.Default;
+ var svi = LocationClassifier.Classify(
+ @"C:\System Volume Information", "System Volume Information",
+ AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System, true);
+ Assert.False(LocationVisibility.ShouldShow(svi, prefs));
+ Assert.True(LocationVisibility.ShouldShow(svi, prefs with { ShowProtectedSystemLocations = true }));
+ }
+
+ [Fact]
+ public void Unknown_size_when_access_denied_and_zero()
+ {
+ var info = new LocationInfo(true, true, true, true, false);
+ Assert.Equal(SizeKnowledge.Unknown, LocationVisibility.ResolveSizeKnowledge(info, true, 0, true));
+ Assert.Equal(SizeKnowledge.Partial, LocationVisibility.ResolveSizeKnowledge(info, true, 1000, true));
+ }
+}
+
file sealed class PrefsEnv : IAppEnvironment
{
public PrefsEnv(string dir)
diff --git a/tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj b/tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj
index 03d3f9f..05088ca 100644
--- a/tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj
+++ b/tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj
@@ -4,6 +4,7 @@
true
+
diff --git a/tests/Explorer.FileOperations.Tests/FileOperationTests.cs b/tests/Explorer.FileOperations.Tests/FileOperationTests.cs
index 3d73325..ce040e9 100644
--- a/tests/Explorer.FileOperations.Tests/FileOperationTests.cs
+++ b/tests/Explorer.FileOperations.Tests/FileOperationTests.cs
@@ -38,9 +38,9 @@ file sealed class StubShell : IShellFileOperations
public void Open(string path) { }
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)
+ public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
{ error = "n/a"; return false; }
- public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error)
+ public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
{ error = "n/a"; return false; }
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
{ error = null; return true; }
diff --git a/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
new file mode 100644
index 0000000..3fafdca
--- /dev/null
+++ b/tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
@@ -0,0 +1,273 @@
+using Explorer.Domain;
+using Explorer.Domain.Abstractions;
+using Explorer.FileOperations;
+using Explorer.Storage.Sqlite;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Explorer.FileOperations.Tests;
+
+public class TransferQueueTests
+{
+ [Fact]
+ public async Task Copies_run_one_after_another()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ ctx.Shell.OnCopy = src =>
+ {
+ if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase))
+ {
+ firstStarted.TrySetResult();
+ releaseFirst.Task.GetAwaiter().GetResult();
+ }
+ else
+ {
+ secondStarted.TrySetResult();
+ }
+ };
+
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ Assert.False(secondStarted.Task.IsCompleted);
+ Assert.Equal(1, ctx.Shell.CopyCount);
+
+ releaseFirst.TrySetResult();
+ await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
+ Assert.Equal(new[] { ctx.File("a.txt"), ctx.File("b.txt") }, ctx.Shell.Copied);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Pause_keeps_later_jobs_from_starting()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var awaitingPause = true;
+ ctx.Shell.OnCopy = src =>
+ {
+ if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase) && awaitingPause)
+ {
+ firstStarted.TrySetResult();
+ WaitUntil(() => ctx.Shell.PauseRequested?.Invoke() == true, TimeSpan.FromSeconds(3))
+ .GetAwaiter().GetResult();
+ awaitingPause = false;
+ }
+ };
+
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ ctx.Queue.PauseAll();
+ await WaitUntil(() => ctx.Queue.Snapshot()[0].Status == TransferStatus.Paused);
+ Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
+
+ ctx.Queue.ResumeAll();
+ await WaitUntil(() => ctx.Queue.Snapshot().All(j => j.Status == TransferStatus.Done));
+ Assert.Contains(ctx.Shell.Copied, p => p.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase));
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Remove_drops_a_queued_step()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ ctx.Queue.PauseAll();
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ var jobs = ctx.Queue.Snapshot();
+ Assert.Equal(2, jobs.Count);
+ ctx.Queue.Cancel(jobs[1].Id);
+ ctx.Queue.ResumeAll();
+ await WaitUntil(() => ctx.Queue.Snapshot().Any(j => j.Status == TransferStatus.Done));
+ Assert.Equal(new[] { ctx.File("a.txt") }, ctx.Shell.Copied);
+ Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.SourcePath.EndsWith("b.txt", StringComparison.OrdinalIgnoreCase)
+ && j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Done);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Cancel_stops_the_running_copy()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ ctx.Shell.OnCopy = src =>
+ {
+ if (src.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase))
+ {
+ firstStarted.TrySetResult();
+ releaseFirst.Task.GetAwaiter().GetResult();
+ }
+ };
+
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ var running = ctx.Queue.Snapshot().Single(j => j.SourcePath.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase));
+ ctx.Queue.Cancel(running.Id);
+ releaseFirst.TrySetResult();
+ await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt")));
+ Assert.DoesNotContain(ctx.Shell.Copied, p => p.EndsWith("a.txt", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(ctx.Queue.Snapshot(), j => j.Id == running.Id && j.Status == TransferStatus.Done);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Pause_queued_job_skips_it()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ ctx.Queue.PauseAll();
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ var jobs = ctx.Queue.Snapshot();
+ ctx.Queue.Pause(jobs[0].Id);
+ ctx.Queue.ResumeAll();
+ await WaitUntil(() => ctx.Shell.Copied.Contains(ctx.File("b.txt")));
+ Assert.Equal(new[] { ctx.File("b.txt") }, ctx.Shell.Copied);
+ Assert.Equal(TransferStatus.Paused, ctx.Queue.Snapshot().First(j => j.Id == jobs[0].Id).Status);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Reorder_changes_which_job_runs_first()
+ {
+ await using var ctx = await Harness.CreateAsync();
+ ctx.Queue.PauseAll();
+ await ctx.Queue.StartAsync(CancellationToken.None);
+ await ctx.Queue.EnqueueCopyAsync([ctx.File("a.txt"), ctx.File("b.txt")], ctx.Dest);
+ var jobs = ctx.Queue.Snapshot();
+ Assert.True(ctx.Queue.MoveUp(jobs[1].Id));
+ ctx.Queue.ResumeAll();
+ await WaitUntil(() => ctx.Queue.Snapshot().Count(j => j.Status == TransferStatus.Done) == 2);
+ Assert.Equal(new[] { ctx.File("b.txt"), ctx.File("a.txt") }, ctx.Shell.Copied);
+ await ctx.Queue.StopAsync(CancellationToken.None);
+ }
+
+ private static async Task WaitUntil(Func condition, TimeSpan? timeout = null)
+ {
+ var limit = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(4));
+ while (!condition())
+ {
+ if (DateTime.UtcNow > limit)
+ {
+ throw new TimeoutException("Condition was not met.");
+ }
+
+ await Task.Delay(20);
+ }
+ }
+
+ private sealed class Harness : IAsyncDisposable
+ {
+ public required TransferQueue Queue { get; init; }
+ public required GateShell Shell { get; init; }
+ public required SqliteIndexStore Store { get; init; }
+ public required string Dest { get; init; }
+ public required string Root { get; init; }
+
+ public string File(string name) => Path.Combine(Root, name);
+
+ public static async Task CreateAsync()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ var dest = Path.Combine(root, "dest");
+ Directory.CreateDirectory(dest);
+ System.IO.File.WriteAllText(Path.Combine(root, "a.txt"), "a");
+ System.IO.File.WriteAllText(Path.Combine(root, "b.txt"), "b");
+ var db = Path.Combine(root, "index.db");
+ var store = new SqliteIndexStore(db, NullLogger.Instance);
+ await store.OpenAsync();
+ var shell = new GateShell();
+ var queue = new TransferQueue(shell, new DiskEnum(), store, NullLogger.Instance);
+ return new Harness
+ {
+ Queue = queue,
+ Shell = shell,
+ Store = store,
+ Dest = dest,
+ Root = root
+ };
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Store.DisposeAsync();
+ try { Directory.Delete(Root, true); } catch { /* ignore */ }
+ }
+ }
+}
+
+internal sealed class DiskEnum : IFileSystemEnumerator
+{
+ public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _);
+
+ public IReadOnlyList EnumerateChildrenSafe(string directoryPath, out string? error)
+ {
+ error = null;
+ return Directory.Exists(directoryPath)
+ ? Directory.GetFileSystemEntries(directoryPath).Select(GetRequired).ToList()
+ : [];
+ }
+
+ public FileSystemItem? GetItem(string path)
+ => System.IO.File.Exists(path) || Directory.Exists(path) ? GetRequired(path) : null;
+
+ private static FileSystemItem GetRequired(string path)
+ {
+ var isDir = Directory.Exists(path);
+ return new FileSystemItem
+ {
+ FullPath = path,
+ Name = Path.GetFileName(path),
+ IsDirectory = isDir,
+ SizeBytes = isDir ? 0 : new FileInfo(path).Length
+ };
+ }
+}
+
+internal sealed class GateShell : IShellFileOperations
+{
+ public int CopyCount { get; private set; }
+ public List Copied { get; } = [];
+ public Action? OnCopy { get; set; }
+ public Func? PauseRequested { get; private set; }
+
+ public void Open(string path) { }
+ public bool DeleteToRecycleBin(IReadOnlyList paths, out string? error) => Delete(paths, true, out error);
+ public bool Delete(IReadOnlyList paths, bool recycle, out string? error) { error = null; return true; }
+ public bool CreateShortcut(string targetPath, string shortcutPath, out string? error) { error = null; return true; }
+
+ public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
+ {
+ CopyCount++;
+ PauseRequested = pauseRequested;
+ progress?.Report(1);
+ OnCopy?.Invoke(source);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ error = "Cancelled";
+ return false;
+ }
+
+ if (pauseRequested?.Invoke() == true)
+ {
+ error = "Paused";
+ return false;
+ }
+
+ Copied.Add(source);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ System.IO.File.Copy(source, destination, overwrite);
+ progress?.Report(new FileInfo(source).Length);
+ error = null;
+ return true;
+ }
+
+ public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress? progress, CancellationToken cancellationToken, out string? error, Func? pauseRequested = null)
+ => CopyFileWithProgress(source, destination, overwrite, progress, cancellationToken, out error, pauseRequested);
+}