Improve file operations, layout memory, and drive status.

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

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

View File

@@ -19,11 +19,43 @@
<local:ShellIconConverter x:Key="ShellIcon"/>
<local:ThumbnailConverter x:Key="Thumbnail"/>
<local:TransferActionTextConverter x:Key="TransferAction"/>
<Style x:Key="InlineRenameBox" TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Fg}"/>
<Setter Property="MinHeight" Value="22"/>
<Setter Property="Padding" Value="2,1"/>
<Setter Property="Margin" Value="0,1,8,1"/>
<Setter Property="MinWidth" Value="140"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<DataTemplate x:Key="NameWithIcon">
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Converter={StaticResource ShellIcon}}"/>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
<TextBlock Tag="ItemName" Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRenaming}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Style="{StaticResource InlineRenameBox}"
Tag="InlineRename"
Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}"
local:InlineRenameBehavior.Enable="True"
Visibility="{Binding IsRenaming, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
@@ -62,8 +94,25 @@
</Image.Style>
</Image>
</Grid>
<TextBlock Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}"/>
<TextBlock Tag="ItemName" Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRenaming}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Style="{StaticResource InlineRenameBox}"
Tag="InlineRename"
MinWidth="80"
Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}"
local:InlineRenameBehavior.Enable="True"
Visibility="{Binding IsRenaming, Converter={StaticResource BoolVis}}"/>
</StackPanel>
</DataTemplate>
@@ -237,6 +286,13 @@
<Setter Property="FontSize" Value="16"/>
<Setter Property="Margin" Value="0,0,4,0"/>
</Style>
<Style x:Key="QueueActionButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Padding" Value="8,2"/>
<Setter Property="MinWidth" Value="28"/>
<Setter Property="MinHeight" Value="24"/>
<Setter Property="Margin" Value="0,0,4,0"/>
<Setter Property="FontSize" Value="11"/>
</Style>
<Style TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
@@ -578,6 +634,8 @@
<ControlTemplate TargetType="ListViewItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="1"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<Grid>
@@ -605,6 +663,10 @@
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<DataTrigger Binding="{Binding IsDropTarget}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
</DataTrigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>
@@ -707,6 +769,8 @@
<Border x:Name="Bd"
Grid.Column="1"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="1"
Padding="{TemplateBinding Padding}"
CornerRadius="3"
SnapsToDevicePixels="True">
@@ -745,6 +809,10 @@
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
</MultiTrigger>
<DataTrigger Binding="{Binding IsDropTarget}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
</DataTrigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>

View File

@@ -40,6 +40,8 @@ public static class AppServices
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, RecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();

View File

@@ -72,7 +72,7 @@ public sealed class ThumbnailConverter : IValueConverter
public sealed class TransferActionTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is TransferStatus.Failed ? "Dismiss" : "Cancel";
=> value is TransferStatus.Failed or TransferStatus.Done or TransferStatus.Cancelled ? "Dismiss" : "Remove";
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();

View File

@@ -0,0 +1,121 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Explorer.Domain;
using Explorer.Presentation;
namespace Explorer.App;
public static class InlineRenameBehavior
{
public static readonly DependencyProperty EnableProperty = DependencyProperty.RegisterAttached(
"Enable",
typeof(bool),
typeof(InlineRenameBehavior),
new PropertyMetadata(false, OnEnableChanged));
public static bool GetEnable(DependencyObject obj) => (bool)obj.GetValue(EnableProperty);
public static void SetEnable(DependencyObject obj, bool value) => obj.SetValue(EnableProperty, value);
private static void OnEnableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not TextBox box)
{
return;
}
if (e.NewValue is true)
{
box.Loaded += OnLoaded;
box.IsVisibleChanged += OnVisibleChanged;
box.PreviewKeyDown += OnPreviewKeyDown;
box.LostFocus += OnLostFocus;
}
else
{
box.Loaded -= OnLoaded;
box.IsVisibleChanged -= OnVisibleChanged;
box.PreviewKeyDown -= OnPreviewKeyDown;
box.LostFocus -= OnLostFocus;
}
}
private static void OnLoaded(object sender, RoutedEventArgs e) => TryFocus(sender as TextBox);
private static void OnVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is TextBox { IsVisible: true } box)
{
TryFocus(box);
}
}
private static void TryFocus(TextBox? box)
{
if (box?.DataContext is not FolderItemViewModel { IsRenaming: true } item)
{
return;
}
box.Focus();
SelectName(box, item);
}
public static void SelectName(TextBox box, FolderItemViewModel item)
{
var name = box.Text ?? "";
if (item.IsDirectory)
{
box.SelectAll();
return;
}
var ext = NameNormalizer.Extension(name);
var length = string.IsNullOrEmpty(ext) ? name.Length : Math.Max(0, name.Length - ext.Length - 1);
box.Select(0, length);
}
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (sender is not TextBox box || box.DataContext is not FolderItemViewModel item)
{
return;
}
if (e.Key == Key.Enter)
{
e.Handled = true;
RequestCommit(box, item);
}
else if (e.Key == Key.Escape)
{
e.Handled = true;
item.CancelRename();
}
}
private static void OnLostFocus(object sender, RoutedEventArgs e)
{
if (sender is not TextBox box || box.DataContext is not FolderItemViewModel { IsRenaming: true } item)
{
return;
}
if (Window.GetWindow(box) is MainWindow { IsInlineRenameStarting: true })
{
box.Dispatcher.BeginInvoke(() => TryFocus(box), System.Windows.Threading.DispatcherPriority.Input);
return;
}
RequestCommit(box, item);
}
private static void RequestCommit(TextBox box, FolderItemViewModel item)
{
if (Window.GetWindow(box) is MainWindow window)
{
window.TryCommitInlineRename(item);
}
}
}

View File

@@ -139,28 +139,39 @@
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
<Grid>
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}" Margin="0,0,12,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,10,0">
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource FgMuted}"
Text="{Binding Op, StringFormat={}{0}:}"/>
<TextBlock VerticalAlignment="Center" FontSize="11" Margin="4,0,0,0" Text="{Binding Status}"/>
<Button Margin="6,0,0,0" Padding="6,2" FontSize="11"
Content="{Binding Status, Converter={StaticResource TransferAction}}"
Command="{Binding DataContext.CancelTransferCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="0,0,12,0"
Visibility="{Binding Transfers.HasJobs, Converter={StaticResource BoolVis}}">
<ProgressBar Width="88" Height="6" Minimum="0" Maximum="1" VerticalAlignment="Center"
Value="{Binding Transfers.OverallProgress}"
Visibility="{Binding Transfers.HasOverallProgress, Converter={StaticResource BoolVis}}"/>
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource Fg}" Margin="8,0,0,0"
Text="{Binding Transfers.Summary}" MaxWidth="280" TextTrimming="CharacterEllipsis"/>
<Button Style="{StaticResource QueueActionButton}" Content="Pause" Margin="8,0,0,0"
Command="{Binding Transfers.PauseAllCommand}"
Visibility="{Binding Transfers.CanPauseAll, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Resume"
Command="{Binding Transfers.ResumeAllCommand}"
Visibility="{Binding Transfers.CanResumeAll, Converter={StaticResource BoolVis}}"/>
<Button Command="{Binding Transfers.ToggleExpandedCommand}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource QueueActionButton}">
<Setter Property="Content" Value="File operations ▴"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Transfers.IsExpanded}" Value="False">
<Setter Property="Content" Value="File operations ▾"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
@@ -171,9 +182,103 @@
</Grid>
</Border>
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="10,8"
MaxHeight="280"
Visibility="{Binding Transfers.ShowPanel, Converter={StaticResource BoolVis}}">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource QueueActionButton}" Content="Pause all"
Command="{Binding Transfers.PauseAllCommand}"
Visibility="{Binding Transfers.CanPauseAll, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Resume all"
Command="{Binding Transfers.ResumeAllCommand}"
Visibility="{Binding Transfers.CanResumeAll, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Clear finished"
Command="{Binding Transfers.ClearFinishedCommand}"
Visibility="{Binding Transfers.HasFinishedJobs, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Hide" Margin="0"
Command="{Binding Transfers.ToggleExpandedCommand}"/>
</StackPanel>
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
Text="Copy, move, and delete run one at a time. Pause a queued step to skip it, or reorder with the arrows."
TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{DynamicResource Fill}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
CornerRadius="4" Padding="8,6" Margin="0,0,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center" Margin="0,0,8,0">
<Button Style="{StaticResource QueueActionButton}" Content="↑" Margin="0,0,0,2"
ToolTip="Move earlier"
Command="{Binding DataContext.Transfers.MoveUpCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
IsEnabled="{Binding CanMoveUp}"/>
<Button Style="{StaticResource QueueActionButton}" Content="↓" Margin="0"
ToolTip="Move later"
Command="{Binding DataContext.Transfers.MoveDownCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
IsEnabled="{Binding CanMoveDown}"/>
</StackPanel>
<StackPanel Grid.Column="1">
<DockPanel>
<TextBlock DockPanel.Dock="Right" FontSize="11" Foreground="{DynamicResource FgMuted}"
Text="{Binding BytesText}" Margin="8,0,0,0"/>
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<TextBlock FontSize="11" Foreground="{DynamicResource FgMuted}" Text="{Binding Subtitle}"
TextTrimming="CharacterEllipsis"/>
<DockPanel Margin="0,2,0,0">
<TextBlock DockPanel.Dock="Right" FontSize="11" Foreground="{DynamicResource Accent}"
Text="{Binding SpeedText}" Margin="8,0,0,0"/>
<TextBlock FontSize="11" Foreground="{DynamicResource Fg}" Text="{Binding StatusText}"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<ProgressBar Height="6" Minimum="0" Maximum="1" Margin="0,6,0,0"
Value="{Binding Progress}"
Visibility="{Binding HasProgress, Converter={StaticResource BoolVis}}"/>
<TextBlock FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,4,0,0"
Text="{Binding CurrentFile}"
Visibility="{Binding HasCurrentFile, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<Button Style="{StaticResource QueueActionButton}" Content="Pause"
Command="{Binding DataContext.Transfers.PauseCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanPause, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Resume"
Command="{Binding DataContext.Transfers.ResumeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanResume, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Margin="0"
Content="{Binding Status, Converter={StaticResource TransferAction}}"
Command="{Binding DataContext.Transfers.RemoveCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
IsEnabled="{Binding CanRemove}"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" MinWidth="160"/>
<ColumnDefinition x:Name="TreeColumn" Width="260" MinWidth="160"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
@@ -186,6 +291,7 @@
AllowDrop="True"
DragEnter="OnTreeDragOver"
DragOver="OnTreeDragOver"
DragLeave="OnTreeDragLeave"
Drop="OnTreeDrop"
HorizontalContentAlignment="Stretch">
<TreeView.ContextMenu>
@@ -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 @@
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
</ListView.View>
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator/>
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Paste" Command="{Binding DataContext.PasteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<Separator/>
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyPathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<Separator/>
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
Command="{Binding PinCloudCommand}"
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Free up space"
Command="{Binding DataContext.FreeUpCloudSpaceCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ShowCloudDehydrate, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
Command="{Binding FreeUpCloudSpaceCommand}"
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
</ContextMenu>
</ListView.ContextMenu>
</ListView>
@@ -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 @@
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
</ListView.View>
</ListView>
@@ -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 @@
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Free space" Width="100" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
</ListView.View>
</ListView>

View File

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

View File

@@ -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<string> paths, out bool cut)
{
paths = [];

View File

@@ -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 @@
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="OneDrive, Google Drive, and Nextcloud become children of a Cloud item. The two options are independent."/>
<TextBlock Text="Filesystem visibility" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,12"
Text="These options only change what Explorer Workbench shows and indexes. Windows settings and files are not modified."/>
<CheckBox x:Name="ShowHidden" Margin="0,0,0,6"
Content="Show hidden files"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,14" FontSize="12"
Text="Items marked Hidden by Windows. Default matches the current Explorer Workbench listing."/>
<CheckBox x:Name="ShowProtected" Margin="0,0,0,6"
Content="Show protected system locations"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="System Volume Information, Recycle Bin, Recovery, and similar locations. When a folder cannot be read, its size is shown as access denied — never as 0 bytes. Administrator rights are detected but never requested automatically."/>
<TextBlock Text="File operations queue" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="AutoClearQueue" Margin="0,0,0,6"
Content="Auto clear queue when done"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Finished copy, move, and delete steps are removed automatically. Failed items stay until you dismiss them."/>
<TextBlock Text="Indexing" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
Content="Include archive contents in the index"/>

View File

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

View File

@@ -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<FolderListing> 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<FileSystemItem>();
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<bool> 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<FileSystemItem> 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<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
{
var cache = new Dictionary<string, VolumeSpace>(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;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Explorer.Application;
public interface IElevatedScanService
{
bool IsElevated { get; }
bool CanRequestElevation { get; }
string ProtectedContentHint { get; }
}

View File

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

View File

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

View File

@@ -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<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
=> _store.Sources.GetAsync(id, cancellationToken);
private async Task<SourceStatus> 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<string> LoadRecents()
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");

View File

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

View File

@@ -68,6 +68,7 @@ public interface IScanJobStore
Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
Task<bool> HasActiveAsync(long sourceId, CancellationToken cancellationToken = default);
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default);

View File

@@ -3,5 +3,6 @@ namespace Explorer.Domain.Abstractions;
public interface IOsClipboard
{
void SetFiles(IReadOnlyList<string> paths, bool cut);
void SetText(string text);
bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut);
}

View File

@@ -21,6 +21,7 @@ public interface IVolumeService
{
IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes();
VolumeFingerprint? Probe(string path);
VolumeSpace GetSpace(string path);
bool IsPathReachable(string path);
}
@@ -87,8 +88,8 @@ public interface IShellFileOperations
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error);
bool CreateShortcut(string targetPath, string shortcutPath, out string? error);
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
}
public interface IIconService

View File

@@ -5,8 +5,6 @@ public static class DefaultExcludes
public static IReadOnlyList<ExcludeRule> 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" },

View File

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

View File

@@ -88,6 +88,7 @@ public enum TransferStatus
{
Queued,
Running,
Paused,
Cancelling,
Cancelled,
Failed,

View File

@@ -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<TransferQueue> _logger;
private readonly Channel<Work> _channel = Channel.CreateUnbounded<Work>();
private readonly List<TransferJob> _jobs = [];
private readonly object _gate = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly Func<bool> _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<TransferJob>? JobFinished;
@@ -29,8 +34,11 @@ public sealed class TransferQueue : BackgroundService
_enumerator = enumerator;
_store = store;
_logger = logger;
_pauseRequested = () => _haltPause;
}
public bool IsPaused => _queuePaused;
public IReadOnlyList<TransferJob> 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<long>(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<long>(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);
}

View File

@@ -12,19 +12,22 @@ public sealed class FilesystemScanner
private readonly StorageProviderRegistry _providers;
private readonly ILogger<FilesystemScanner> _logger;
private readonly ArchiveContentsIndexer? _archives;
private readonly UiPreferencesStore? _preferences;
public FilesystemScanner(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
ILogger<FilesystemScanner> 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<ScanJob> 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
{

View File

@@ -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<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
var archivesToExpand = new List<IndexEntry>();
var archivesToTomb = new List<string>();
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
{

View File

@@ -92,22 +92,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
IEnumerable<FileSystemItem> 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<FolderItemViewModel> OrderItems(
IEnumerable<FolderItemViewModel> 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<BreadcrumbSegment> BuildBreadcrumb(string path)
{

View File

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

View File

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

View File

@@ -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<NavNodeViewModel> 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<NavNodeViewModel> 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<NavNodeViewModel> nodes, string path)
{
foreach (var node in nodes)

View File

@@ -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<string, VolumeSpace>(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));
}

View File

@@ -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<long, (long Bytes, DateTime Utc)> _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<TransferJob> 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<TransferJobViewModel> 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<TransferJob> 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;
}

View File

@@ -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<bool> HasActiveAsync(long sourceId, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var count = await conn.ExecuteScalarAsync<long>("""
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("""

View File

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

View File

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

View File

@@ -77,18 +77,35 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
}
}
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? 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<long>? progress, CancellationToken cancellationToken, out string? error)
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? 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;

View File

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

View File

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

View File

@@ -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<SqliteIndexStore>.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<SourceManager>.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

View File

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

View File

@@ -4,6 +4,7 @@
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />

View File

@@ -38,9 +38,9 @@ file sealed class StubShell : IShellFileOperations
public void Open(string path) { }
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error) { error = "n/a"; return false; }
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
{ error = "n/a"; return false; }
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
{ error = "n/a"; return false; }
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
{ error = null; return true; }

View File

@@ -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<bool> 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<Harness> 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<SqliteIndexStore>.Instance);
await store.OpenAsync();
var shell = new GateShell();
var queue = new TransferQueue(shell, new DiskEnum(), store, NullLogger<TransferQueue>.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<FileSystemItem> EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _);
public IReadOnlyList<FileSystemItem> 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<string> Copied { get; } = [];
public Action<string>? OnCopy { get; set; }
public Func<bool>? PauseRequested { get; private set; }
public void Open(string path) { }
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
public bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error) { error = 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<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? 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<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null)
=> CopyFileWithProgress(source, destination, overwrite, progress, cancellationToken, out error, pauseRequested);
}