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:
@@ -19,11 +19,43 @@
|
|||||||
<local:ShellIconConverter x:Key="ShellIcon"/>
|
<local:ShellIconConverter x:Key="ShellIcon"/>
|
||||||
<local:ThumbnailConverter x:Key="Thumbnail"/>
|
<local:ThumbnailConverter x:Key="Thumbnail"/>
|
||||||
<local:TransferActionTextConverter x:Key="TransferAction"/>
|
<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">
|
<DataTemplate x:Key="NameWithIcon">
|
||||||
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
|
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
|
||||||
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
|
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
Source="{Binding Converter={StaticResource ShellIcon}}"/>
|
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"
|
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
||||||
Foreground="{DynamicResource FgMuted}"
|
Foreground="{DynamicResource FgMuted}"
|
||||||
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
|
||||||
@@ -62,8 +94,25 @@
|
|||||||
</Image.Style>
|
</Image.Style>
|
||||||
</Image>
|
</Image>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
|
<TextBlock Tag="ItemName" Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
|
||||||
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}"/>
|
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>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|
||||||
@@ -237,6 +286,13 @@
|
|||||||
<Setter Property="FontSize" Value="16"/>
|
<Setter Property="FontSize" Value="16"/>
|
||||||
<Setter Property="Margin" Value="0,0,4,0"/>
|
<Setter Property="Margin" Value="0,0,4,0"/>
|
||||||
</Style>
|
</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">
|
<Style TargetType="TextBox">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
@@ -578,6 +634,8 @@
|
|||||||
<ControlTemplate TargetType="ListViewItem">
|
<ControlTemplate TargetType="ListViewItem">
|
||||||
<Border x:Name="Bd"
|
<Border x:Name="Bd"
|
||||||
Background="{TemplateBinding Background}"
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="1"
|
||||||
Padding="{TemplateBinding Padding}"
|
Padding="{TemplateBinding Padding}"
|
||||||
SnapsToDevicePixels="True">
|
SnapsToDevicePixels="True">
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -605,6 +663,10 @@
|
|||||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
</Trigger>
|
</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">
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
@@ -707,6 +769,8 @@
|
|||||||
<Border x:Name="Bd"
|
<Border x:Name="Bd"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Background="{TemplateBinding Background}"
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="1"
|
||||||
Padding="{TemplateBinding Padding}"
|
Padding="{TemplateBinding Padding}"
|
||||||
CornerRadius="3"
|
CornerRadius="3"
|
||||||
SnapsToDevicePixels="True">
|
SnapsToDevicePixels="True">
|
||||||
@@ -745,6 +809,10 @@
|
|||||||
</MultiTrigger.Conditions>
|
</MultiTrigger.Conditions>
|
||||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
|
||||||
</MultiTrigger>
|
</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">
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public static class AppServices
|
|||||||
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
||||||
services.AddSingleton<StorageProviderRegistry>();
|
services.AddSingleton<StorageProviderRegistry>();
|
||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
|
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
|
||||||
|
services.AddSingleton<IRecycleBinCatalog, RecycleBinCatalog>();
|
||||||
services.AddSingleton<SourceManager>();
|
services.AddSingleton<SourceManager>();
|
||||||
services.AddSingleton<PathHistoryStore>();
|
services.AddSingleton<PathHistoryStore>();
|
||||||
services.AddSingleton<CloudPlaceStore>();
|
services.AddSingleton<CloudPlaceStore>();
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public sealed class ThumbnailConverter : IValueConverter
|
|||||||
public sealed class TransferActionTextConverter : IValueConverter
|
public sealed class TransferActionTextConverter : IValueConverter
|
||||||
{
|
{
|
||||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
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)
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|||||||
121
src/Explorer.App/InlineRenameBehavior.cs
Normal file
121
src/Explorer.App/InlineRenameBehavior.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,28 +139,39 @@
|
|||||||
|
|
||||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
|
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
<Grid.ColumnDefinitions>
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
<ColumnDefinition Width="*"/>
|
||||||
<ItemsControl ItemsSource="{Binding Transfers.Jobs}" Margin="0,0,12,0">
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ItemsControl.ItemsPanel>
|
</Grid.ColumnDefinitions>
|
||||||
<ItemsPanelTemplate>
|
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
|
||||||
<StackPanel Orientation="Horizontal"/>
|
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
|
||||||
</ItemsPanelTemplate>
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
</ItemsControl.ItemsPanel>
|
<StackPanel Orientation="Horizontal" Margin="0,0,12,0"
|
||||||
<ItemsControl.ItemTemplate>
|
Visibility="{Binding Transfers.HasJobs, Converter={StaticResource BoolVis}}">
|
||||||
<DataTemplate>
|
<ProgressBar Width="88" Height="6" Minimum="0" Maximum="1" VerticalAlignment="Center"
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,10,0">
|
Value="{Binding Transfers.OverallProgress}"
|
||||||
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource FgMuted}"
|
Visibility="{Binding Transfers.HasOverallProgress, Converter={StaticResource BoolVis}}"/>
|
||||||
Text="{Binding Op, StringFormat={}{0}:}"/>
|
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource Fg}" Margin="8,0,0,0"
|
||||||
<TextBlock VerticalAlignment="Center" FontSize="11" Margin="4,0,0,0" Text="{Binding Status}"/>
|
Text="{Binding Transfers.Summary}" MaxWidth="280" TextTrimming="CharacterEllipsis"/>
|
||||||
<Button Margin="6,0,0,0" Padding="6,2" FontSize="11"
|
<Button Style="{StaticResource QueueActionButton}" Content="Pause" Margin="8,0,0,0"
|
||||||
Content="{Binding Status, Converter={StaticResource TransferAction}}"
|
Command="{Binding Transfers.PauseAllCommand}"
|
||||||
Command="{Binding DataContext.CancelTransferCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Visibility="{Binding Transfers.CanPauseAll, Converter={StaticResource BoolVis}}"/>
|
||||||
CommandParameter="{Binding}"/>
|
<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>
|
</StackPanel>
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
|
<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="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
|
||||||
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
|
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
|
||||||
@@ -171,9 +182,103 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</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>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="260" MinWidth="160"/>
|
<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 x:Name="TreeColumn" Width="260" MinWidth="160"/>
|
||||||
<ColumnDefinition Width="6"/>
|
<ColumnDefinition Width="6"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
@@ -186,6 +291,7 @@
|
|||||||
AllowDrop="True"
|
AllowDrop="True"
|
||||||
DragEnter="OnTreeDragOver"
|
DragEnter="OnTreeDragOver"
|
||||||
DragOver="OnTreeDragOver"
|
DragOver="OnTreeDragOver"
|
||||||
|
DragLeave="OnTreeDragLeave"
|
||||||
Drop="OnTreeDrop"
|
Drop="OnTreeDrop"
|
||||||
HorizontalContentAlignment="Stretch">
|
HorizontalContentAlignment="Stretch">
|
||||||
<TreeView.ContextMenu>
|
<TreeView.ContextMenu>
|
||||||
@@ -250,7 +356,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -266,33 +374,34 @@
|
|||||||
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
<ListView.ContextMenu>
|
<ListView.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="Open" Click="OnCtxOpen"/>
|
<MenuItem Header="Open" Click="OnCtxOpen"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
|
||||||
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
|
||||||
<MenuItem Header="Paste" Command="{Binding DataContext.PasteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
|
||||||
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
|
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
|
||||||
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
|
||||||
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyPathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
|
||||||
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
|
||||||
<Separator Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||||
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
|
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
|
||||||
Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||||
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||||
<MenuItem Header="Always keep on this device"
|
<MenuItem Header="Always keep on this device"
|
||||||
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding PinCloudCommand}"
|
||||||
Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||||
<MenuItem Header="Free up space"
|
<MenuItem Header="Free up space"
|
||||||
Command="{Binding DataContext.FreeUpCloudSpaceCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding FreeUpCloudSpaceCommand}"
|
||||||
Visibility="{Binding DataContext.ShowCloudDehydrate, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</ListView.ContextMenu>
|
</ListView.ContextMenu>
|
||||||
</ListView>
|
</ListView>
|
||||||
@@ -303,7 +412,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -328,7 +439,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
GotFocus="OnPaneFocus"
|
GotFocus="OnPaneFocus"
|
||||||
@@ -375,7 +488,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -391,6 +506,7 @@
|
|||||||
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
</ListView>
|
</ListView>
|
||||||
@@ -401,7 +517,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
VirtualizingPanel.IsVirtualizing="True"
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
@@ -426,7 +544,9 @@
|
|||||||
PreviewMouseLeftButtonDown="OnListMouseDown"
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
PreviewMouseRightButtonDown="OnListMouseDown"
|
PreviewMouseRightButtonDown="OnListMouseDown"
|
||||||
ContextMenuOpening="OnListContextMenuOpening"
|
ContextMenuOpening="OnListContextMenuOpening"
|
||||||
|
GridViewColumnHeader.Click="OnColumnHeaderClick"
|
||||||
DragEnter="OnListDragOver"
|
DragEnter="OnListDragOver"
|
||||||
|
DragLeave="OnListDragLeave"
|
||||||
Drop="OnListDrop"
|
Drop="OnListDrop"
|
||||||
DragOver="OnListDragOver"
|
DragOver="OnListDragOver"
|
||||||
GotFocus="OnPaneFocus"
|
GotFocus="OnPaneFocus"
|
||||||
@@ -506,6 +626,7 @@
|
|||||||
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
|
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
|
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
|
||||||
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
|
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
<GridViewColumn Header="Free space" Width="100" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||||
</GridView>
|
</GridView>
|
||||||
</ListView.View>
|
</ListView.View>
|
||||||
</ListView>
|
</ListView>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Controls.Primitives;
|
using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Presentation;
|
using Explorer.Presentation;
|
||||||
using Explorer.Presentation.ViewModels;
|
using Explorer.Presentation.ViewModels;
|
||||||
@@ -19,27 +22,48 @@ public partial class MainWindow : Window
|
|||||||
private bool _suppressItemContextMenu;
|
private bool _suppressItemContextMenu;
|
||||||
private bool _incomingRightDrag;
|
private bool _incomingRightDrag;
|
||||||
private bool _sourceRightDrag;
|
private bool _sourceRightDrag;
|
||||||
|
private MainViewModel? _wiredVm;
|
||||||
|
private long _inlineRenameStarted;
|
||||||
|
private DispatcherTimer? _clickRenameTimer;
|
||||||
|
private FolderItemViewModel? _clickRenameItem;
|
||||||
|
private FolderItemViewModel? _listDropTarget;
|
||||||
|
private NavNodeViewModel? _treeDropTarget;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
if (DataContext is MainViewModel vm)
|
if (_wiredVm is not null)
|
||||||
{
|
{
|
||||||
vm.PropertyChanged += (_, e) =>
|
_wiredVm.InlineRenameRequested -= OnInlineRenameRequested;
|
||||||
|
_wiredVm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wiredVm = DataContext as MainViewModel;
|
||||||
|
if (_wiredVm is not null)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(MainViewModel.Theme))
|
_wiredVm.InlineRenameRequested += OnInlineRenameRequested;
|
||||||
{
|
_wiredVm.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
ApplyTheme(vm.Theme);
|
RestoreLayout(_wiredVm);
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Closing += (_, _) => PersistLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private MainViewModel Vm => (MainViewModel)DataContext;
|
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)
|
private void ApplyTheme(string theme)
|
||||||
{
|
{
|
||||||
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
|
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
|
||||||
@@ -75,9 +99,77 @@ public partial class MainWindow : Window
|
|||||||
private void ToggleMaximized()
|
private void ToggleMaximized()
|
||||||
{
|
{
|
||||||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
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)
|
private void OnPaneChromeMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
|
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
|
||||||
@@ -342,7 +434,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void OnItemDoubleClick(object sender, MouseButtonEventArgs e)
|
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);
|
ActivatePaneFromList((ListView)sender);
|
||||||
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
|
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)
|
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not ListView list)
|
if (sender is not ListView list)
|
||||||
@@ -358,6 +527,11 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
ActivatePaneFromList(list);
|
ActivatePaneFromList(list);
|
||||||
|
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
|
||||||
|
{
|
||||||
|
CancelClickRename();
|
||||||
|
}
|
||||||
|
|
||||||
Vm.ActivePane.SelectedItems.Clear();
|
Vm.ActivePane.SelectedItems.Clear();
|
||||||
foreach (FolderItemViewModel item in list.SelectedItems)
|
foreach (FolderItemViewModel item in list.SelectedItems)
|
||||||
{
|
{
|
||||||
@@ -395,6 +569,7 @@ public partial class MainWindow : Window
|
|||||||
_dragPending = true;
|
_dragPending = true;
|
||||||
_dragButton = e.ChangedButton;
|
_dragButton = e.ChangedButton;
|
||||||
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
||||||
|
TryScheduleClickRename(sender as ListView, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnListContextMenuOpening(object sender, ContextMenuEventArgs e)
|
private void OnListContextMenuOpening(object sender, ContextMenuEventArgs e)
|
||||||
@@ -403,6 +578,12 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
_suppressItemContextMenu = false;
|
_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)
|
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
|
||||||
{
|
{
|
||||||
|
SetListDropTarget(null);
|
||||||
e.Effects = DragDropEffects.None;
|
e.Effects = DragDropEffects.None;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
@@ -418,6 +600,15 @@ public partial class MainWindow : Window
|
|||||||
ActivatePaneFromList(list);
|
ActivatePaneFromList(list);
|
||||||
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||||
var dest = ResolveDropDirectory(list, e);
|
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))
|
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||||
{
|
{
|
||||||
e.Effects = DragDropEffects.None;
|
e.Effects = DragDropEffects.None;
|
||||||
@@ -439,6 +630,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void OnListDrop(object sender, DragEventArgs e)
|
private async void OnListDrop(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
|
ClearDropTargets();
|
||||||
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
|
if (!TryGetDropFiles(e, out var files) || sender is not ListView list)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -463,6 +655,35 @@ public partial class MainWindow : Window
|
|||||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
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)
|
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnPreviewMouseMove(e);
|
base.OnPreviewMouseMove(e);
|
||||||
@@ -481,6 +702,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
_dragPending = false;
|
_dragPending = false;
|
||||||
|
CancelClickRename();
|
||||||
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
||||||
IReadOnlyList<string> paths;
|
IReadOnlyList<string> paths;
|
||||||
if (_dragItem is not null && (selected.Count == 0
|
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);
|
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
||||||
_sourceRightDrag = false;
|
_sourceRightDrag = false;
|
||||||
_incomingRightDrag = false;
|
_incomingRightDrag = false;
|
||||||
|
ClearDropTargets();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
||||||
@@ -638,20 +861,27 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (!TryGetDropFiles(e, out var files))
|
if (!TryGetDropFiles(e, out var files))
|
||||||
{
|
{
|
||||||
|
SetTreeDropTarget(null);
|
||||||
e.Effects = DragDropEffects.None;
|
e.Effects = DragDropEffects.None;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
_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))
|
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||||
{
|
{
|
||||||
|
SetTreeDropTarget(null);
|
||||||
e.Effects = DragDropEffects.None;
|
e.Effects = DragDropEffects.None;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetListDropTarget(null);
|
||||||
|
SetTreeDropTarget(node);
|
||||||
e.Effects = _incomingRightDrag || _sourceRightDrag
|
e.Effects = _incomingRightDrag || _sourceRightDrag
|
||||||
? DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link
|
? DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link
|
||||||
: ToEffects(ResolveDropAction(e, files, dest));
|
: ToEffects(ResolveDropAction(e, files, dest));
|
||||||
@@ -660,6 +890,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private async void OnTreeDrop(object sender, DragEventArgs e)
|
private async void OnTreeDrop(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
|
ClearDropTargets();
|
||||||
if (!TryGetDropFiles(e, out var files))
|
if (!TryGetDropFiles(e, out var files))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -683,11 +914,20 @@ public partial class MainWindow : Window
|
|||||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
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)
|
private string? HitTestTreePath(DragEventArgs e)
|
||||||
{
|
{
|
||||||
var origin = NavTree.InputHitTest(e.GetPosition(NavTree)) as DependencyObject
|
var node = HitTestTreeNode(e);
|
||||||
?? e.OriginalSource as DependencyObject;
|
|
||||||
var node = FindTreeNode(origin);
|
|
||||||
if (node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
if (node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -696,6 +936,57 @@ public partial class MainWindow : Window
|
|||||||
return node.Path;
|
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)
|
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is ListView { SelectedItem: FolderItemViewModel item })
|
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 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)
|
private async void OnCtxDelete(object sender, RoutedEventArgs e)
|
||||||
=> await DeleteSelectedAsync().ConfigureAwait(true);
|
=> await DeleteSelectedAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -790,16 +1085,211 @@ public partial class MainWindow : Window
|
|||||||
private void OnCtxRename(object sender, RoutedEventArgs e)
|
private void OnCtxRename(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
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)
|
if (item is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = PromptWindow.Ask(this, "Rename", "New name:", item.Name);
|
BeginInlineRename(item);
|
||||||
if (!string.IsNullOrWhiteSpace(name) && name != item.Name)
|
|
||||||
{
|
|
||||||
Vm.RenameSelected(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BeginInlineRename(FolderItemViewModel item)
|
||||||
|
{
|
||||||
|
foreach (var other in Vm.ActivePane.Items.Where(i => i.IsRenaming && i != item))
|
||||||
|
{
|
||||||
|
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)
|
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)
|
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (e.OriginalSource is TextBox { Tag: "InlineRename" })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||||
|
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||||
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
|
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)
|
if (e.Key == Key.F5)
|
||||||
{
|
{
|
||||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public sealed class WpfClipboard : IOsClipboard
|
|||||||
Clipboard.SetDataObject(data, copy: true);
|
Clipboard.SetDataObject(data, copy: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetText(string text)
|
||||||
|
=> Clipboard.SetText(text);
|
||||||
|
|
||||||
public bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut)
|
public bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut)
|
||||||
{
|
{
|
||||||
paths = [];
|
paths = [];
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Settings"
|
Title="Settings"
|
||||||
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||||
Height="620" Width="560"
|
Height="720" Width="560"
|
||||||
MinHeight="520" MinWidth="480"
|
MinHeight="560" MinWidth="480"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
|
||||||
ResizeMode="NoResize">
|
ResizeMode="NoResize">
|
||||||
@@ -38,6 +38,24 @@
|
|||||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
<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."/>
|
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"/>
|
<TextBlock Text="Indexing" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||||
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
|
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
|
||||||
Content="Include archive contents in the index"/>
|
Content="Include archive contents in the index"/>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public partial class SettingsWindow : Window
|
|||||||
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
|
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
|
||||||
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
|
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
|
||||||
IndexArchives.IsChecked = prefs.IndexArchiveContents;
|
IndexArchives.IsChecked = prefs.IndexArchiveContents;
|
||||||
|
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
|
||||||
|
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
|
||||||
|
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnThemeChanged(object sender, RoutedEventArgs e)
|
private void OnThemeChanged(object sender, RoutedEventArgs e)
|
||||||
@@ -34,11 +37,16 @@ public partial class SettingsWindow : Window
|
|||||||
|
|
||||||
private async void OnOk(object sender, RoutedEventArgs e)
|
private async void OnOk(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var prefs = new UiPreferences(
|
var prefs = _vm.CurrentPreferences() with
|
||||||
ThemeLight.IsChecked == true ? "Light" : "Dark",
|
{
|
||||||
GroupNetwork.IsChecked == true,
|
Theme = ThemeLight.IsChecked == true ? "Light" : "Dark",
|
||||||
GroupCloud.IsChecked == true,
|
GroupNetworkPlaces = GroupNetwork.IsChecked == true,
|
||||||
IndexArchives.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);
|
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public sealed class BrowseService
|
|||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly CloudPlaceStore _cloudPlaces;
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
private readonly UiPreferencesStore _preferences;
|
private readonly UiPreferencesStore _preferences;
|
||||||
|
private readonly IElevatedScanService? _elevation;
|
||||||
|
private readonly IRecycleBinCatalog? _recycle;
|
||||||
|
|
||||||
public BrowseService(
|
public BrowseService(
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
@@ -20,7 +22,9 @@ public sealed class BrowseService
|
|||||||
SourceManager sources,
|
SourceManager sources,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
CloudPlaceStore cloudPlaces,
|
CloudPlaceStore cloudPlaces,
|
||||||
UiPreferencesStore preferences)
|
UiPreferencesStore preferences,
|
||||||
|
IElevatedScanService? elevation = null,
|
||||||
|
IRecycleBinCatalog? recycle = null)
|
||||||
{
|
{
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_volumes = volumes;
|
_volumes = volumes;
|
||||||
@@ -29,6 +33,8 @@ public sealed class BrowseService
|
|||||||
_providers = providers;
|
_providers = providers;
|
||||||
_cloudPlaces = cloudPlaces;
|
_cloudPlaces = cloudPlaces;
|
||||||
_preferences = preferences;
|
_preferences = preferences;
|
||||||
|
_elevation = elevation;
|
||||||
|
_recycle = recycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -50,12 +56,15 @@ public sealed class BrowseService
|
|||||||
.Select(place =>
|
.Select(place =>
|
||||||
{
|
{
|
||||||
var exists = Directory.Exists(place.Path);
|
var exists = Directory.Exists(place.Path);
|
||||||
|
var space = exists ? _volumes.GetSpace(place.Path) : default;
|
||||||
return new FileSystemItem
|
return new FileSystemItem
|
||||||
{
|
{
|
||||||
FullPath = place.Path,
|
FullPath = place.Path,
|
||||||
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
|
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
|
||||||
IsDirectory = true,
|
IsDirectory = true,
|
||||||
Attributes = AttributeFlags.Directory
|
Attributes = AttributeFlags.Directory,
|
||||||
|
FreeSpaceBytes = space.FreeBytes,
|
||||||
|
CapacityBytes = space.CapacityBytes
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -70,7 +79,7 @@ public sealed class BrowseService
|
|||||||
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var items = new List<FileSystemItem>();
|
var items = new List<FileSystemItem>();
|
||||||
foreach (var source in sources.Where(include)
|
foreach (var source in sources.Where(include)
|
||||||
.OrderBy(s => s.Kind)
|
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
|
||||||
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
IndexEntry? root = null;
|
IndexEntry? root = null;
|
||||||
@@ -79,6 +88,9 @@ public sealed class BrowseService
|
|||||||
root = await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
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
|
items.Add(new FileSystemItem
|
||||||
{
|
{
|
||||||
FullPath = source.LastRootPath ?? source.DisplayName,
|
FullPath = source.LastRootPath ?? source.DisplayName,
|
||||||
@@ -87,7 +99,9 @@ public sealed class BrowseService
|
|||||||
: source.DisplayName,
|
: source.DisplayName,
|
||||||
IsDirectory = true,
|
IsDirectory = true,
|
||||||
SizeBytes = root?.AggregateSize ?? 0,
|
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);
|
var reachable = _volumes.IsPathReachable(path);
|
||||||
|
|
||||||
if (reachable)
|
if (reachable)
|
||||||
@@ -117,7 +136,7 @@ public sealed class BrowseService
|
|||||||
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
|
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 })
|
if (source is { IsIndexed: true })
|
||||||
@@ -151,7 +170,7 @@ public sealed class BrowseService
|
|||||||
: null
|
: null
|
||||||
})
|
})
|
||||||
.ToList();
|
.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" };
|
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
|
var hint = isArchiveFile && items.Count == 0
|
||||||
? "Archive contents appear after the next scan."
|
? "Archive contents appear after the next scan."
|
||||||
: null;
|
: 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)
|
private async Task<bool> IsUnderArchiveAsync(long sourceId, string pathRel, CancellationToken cancellationToken)
|
||||||
@@ -262,8 +281,147 @@ public sealed class BrowseService
|
|||||||
FileId = i.FileId,
|
FileId = i.FileId,
|
||||||
ReparseTag = i.ReparseTag,
|
ReparseTag = i.ReparseTag,
|
||||||
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
|
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();
|
}).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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/Explorer.Application/IElevatedScanService.cs
Normal file
8
src/Explorer.Application/IElevatedScanService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public interface IElevatedScanService
|
||||||
|
{
|
||||||
|
bool IsElevated { get; }
|
||||||
|
bool CanRequestElevation { get; }
|
||||||
|
string ProtectedContentHint { get; }
|
||||||
|
}
|
||||||
13
src/Explorer.Application/IRecycleBinCatalog.cs
Normal file
13
src/Explorer.Application/IRecycleBinCatalog.cs
Normal 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;
|
||||||
|
}
|
||||||
46
src/Explorer.Application/LocationVisibility.cs
Normal file
46
src/Explorer.Application/LocationVisibility.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ public sealed class SourceManager
|
|||||||
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
|
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
|
||||||
source.Kind = fp.Kind;
|
source.Kind = fp.Kind;
|
||||||
source.LastSeenUtc = _clock.UtcNow;
|
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;
|
source.LastError = null;
|
||||||
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||||
if (source.IsIndexed)
|
if (source.IsIndexed)
|
||||||
@@ -251,7 +251,7 @@ public sealed class SourceManager
|
|||||||
{
|
{
|
||||||
existing.LastSeenUtc = _clock.UtcNow;
|
existing.LastSeenUtc = _clock.UtcNow;
|
||||||
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
|
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
|
||||||
? (existing.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online)
|
? await ResolveReachableStatusAsync(existing, cancellationToken).ConfigureAwait(false)
|
||||||
: SourceStatus.Offline;
|
: SourceStatus.Offline;
|
||||||
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||||
return existing;
|
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()
|
private IReadOnlyList<string> LoadRecents()
|
||||||
{
|
{
|
||||||
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||||
|
|||||||
@@ -6,9 +6,18 @@ public sealed record UiPreferences(
|
|||||||
string Theme,
|
string Theme,
|
||||||
bool GroupNetworkPlaces,
|
bool GroupNetworkPlaces,
|
||||||
bool GroupCloudPlaces,
|
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
|
public sealed class UiPreferencesStore
|
||||||
@@ -47,7 +56,11 @@ public sealed class UiPreferencesStore
|
|||||||
"theme=" + NormalizeTheme(preferences.Theme),
|
"theme=" + NormalizeTheme(preferences.Theme),
|
||||||
"group-network=" + (preferences.GroupNetworkPlaces ? "true" : "false"),
|
"group-network=" + (preferences.GroupNetworkPlaces ? "true" : "false"),
|
||||||
"group-cloud=" + (preferences.GroupCloudPlaces ? "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
|
catch
|
||||||
@@ -62,6 +75,15 @@ public sealed class UiPreferencesStore
|
|||||||
var groupNetwork = false;
|
var groupNetwork = false;
|
||||||
var groupCloud = false;
|
var groupCloud = false;
|
||||||
var indexArchives = 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)
|
foreach (var raw in lines)
|
||||||
{
|
{
|
||||||
var line = raw.Trim();
|
var line = raw.Trim();
|
||||||
@@ -94,11 +116,90 @@ public sealed class UiPreferencesStore
|
|||||||
{
|
{
|
||||||
indexArchives = IsTrue(value);
|
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)
|
public static string NormalizeTheme(string? theme)
|
||||||
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
|
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public interface IScanJobStore
|
|||||||
Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
|
Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
|
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
|
||||||
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
|
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> HasActiveAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
|
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
|
||||||
Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default);
|
Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ namespace Explorer.Domain.Abstractions;
|
|||||||
public interface IOsClipboard
|
public interface IOsClipboard
|
||||||
{
|
{
|
||||||
void SetFiles(IReadOnlyList<string> paths, bool cut);
|
void SetFiles(IReadOnlyList<string> paths, bool cut);
|
||||||
|
void SetText(string text);
|
||||||
bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut);
|
bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public interface IVolumeService
|
|||||||
{
|
{
|
||||||
IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes();
|
IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes();
|
||||||
VolumeFingerprint? Probe(string path);
|
VolumeFingerprint? Probe(string path);
|
||||||
|
VolumeSpace GetSpace(string path);
|
||||||
bool IsPathReachable(string path);
|
bool IsPathReachable(string path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +88,8 @@ public interface IShellFileOperations
|
|||||||
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
|
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
|
||||||
bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error);
|
bool Delete(IReadOnlyList<string> paths, bool recycle, out string? error);
|
||||||
bool CreateShortcut(string targetPath, string shortcutPath, 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 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);
|
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IIconService
|
public interface IIconService
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ public static class DefaultExcludes
|
|||||||
public static IReadOnlyList<ExcludeRule> Create() =>
|
public static IReadOnlyList<ExcludeRule> Create() =>
|
||||||
[
|
[
|
||||||
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\Windows", Scope = "global" },
|
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.PathPrefix, Pattern = @"C:\ProgramData\Microsoft", Scope = "global" },
|
||||||
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules", Scope = "global" },
|
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules", Scope = "global" },
|
||||||
new() { Kind = ExcludeKind.Glob, Pattern = ".git", Scope = "global" },
|
new() { Kind = ExcludeKind.Glob, Pattern = ".git", Scope = "global" },
|
||||||
|
|||||||
@@ -31,11 +31,14 @@ public sealed class VolumeFingerprint
|
|||||||
public string? Filesystem { get; init; }
|
public string? Filesystem { get; init; }
|
||||||
public string? Label { get; init; }
|
public string? Label { get; init; }
|
||||||
public long? CapacityBytes { get; init; }
|
public long? CapacityBytes { get; init; }
|
||||||
|
public long? FreeBytes { get; init; }
|
||||||
public string? DeviceInstanceId { get; init; }
|
public string? DeviceInstanceId { get; init; }
|
||||||
public required string RootPath { get; init; }
|
public required string RootPath { get; init; }
|
||||||
public string? DisplayName { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly record struct VolumeSpace(long? CapacityBytes, long? FreeBytes);
|
||||||
|
|
||||||
public sealed class IndexEntry
|
public sealed class IndexEntry
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
@@ -113,7 +116,11 @@ public sealed class TransferJob
|
|||||||
public TransferStatus Status { get; set; }
|
public TransferStatus Status { get; set; }
|
||||||
public long? BytesTotal { get; set; }
|
public long? BytesTotal { get; set; }
|
||||||
public long BytesDone { 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 CreatedUtc { get; set; }
|
||||||
|
public DateTimeOffset? StartedUtc { get; set; }
|
||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
|
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
|
||||||
}
|
}
|
||||||
@@ -134,6 +141,8 @@ public sealed class FileSystemItem
|
|||||||
public LocationInfo Location { get; init; } = LocationInfo.None;
|
public LocationInfo Location { get; init; } = LocationInfo.None;
|
||||||
public SizeKnowledge SizeKnowledge { get; init; } = SizeKnowledge.Calculated;
|
public SizeKnowledge SizeKnowledge { get; init; } = SizeKnowledge.Calculated;
|
||||||
public string? DisplayName { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
|
public long? FreeSpaceBytes { get; init; }
|
||||||
|
public long? CapacityBytes { get; init; }
|
||||||
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public enum TransferStatus
|
|||||||
{
|
{
|
||||||
Queued,
|
Queued,
|
||||||
Running,
|
Running,
|
||||||
|
Paused,
|
||||||
Cancelling,
|
Cancelling,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
Failed,
|
Failed,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Threading.Channels;
|
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -12,9 +11,15 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
private readonly IFileSystemEnumerator _enumerator;
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly ILogger<TransferQueue> _logger;
|
private readonly ILogger<TransferQueue> _logger;
|
||||||
private readonly Channel<Work> _channel = Channel.CreateUnbounded<Work>();
|
|
||||||
private readonly List<TransferJob> _jobs = [];
|
private readonly List<TransferJob> _jobs = [];
|
||||||
private readonly object _gate = new();
|
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? Changed;
|
||||||
public event EventHandler<TransferJob>? JobFinished;
|
public event EventHandler<TransferJob>? JobFinished;
|
||||||
@@ -29,8 +34,11 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_store = store;
|
_store = store;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_pauseRequested = () => _haltPause;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsPaused => _queuePaused;
|
||||||
|
|
||||||
public IReadOnlyList<TransferJob> Snapshot()
|
public IReadOnlyList<TransferJob> Snapshot()
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -84,19 +92,106 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
}, cancellationToken).ConfigureAwait(false);
|
}, 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)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
var job = _jobs.FirstOrDefault(j => j.Id == jobId);
|
var job = Find(jobId);
|
||||||
if (job is null)
|
if (job is null)
|
||||||
{
|
{
|
||||||
return;
|
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;
|
job.Status = TransferStatus.Cancelling;
|
||||||
|
_haltPause = false;
|
||||||
|
running = _runningCts;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -104,17 +199,67 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
running?.Cancel();
|
||||||
|
RaiseChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dismiss(long jobId)
|
public void Dismiss(long jobId)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
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)
|
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
||||||
@@ -122,42 +267,86 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
|
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_jobs.Insert(0, job);
|
_jobs.Add(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
_channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
|
Pulse();
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
RaiseChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
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;
|
TransferJob? job;
|
||||||
if (job.Status == TransferStatus.Cancelling)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
job.Status = TransferStatus.Cancelled;
|
job = CanStartNext() ? NextRunnable() : null;
|
||||||
await Persist(job).ConfigureAwait(false);
|
}
|
||||||
|
|
||||||
|
if (job is null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _signal.WaitAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.Status = TransferStatus.Running;
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
job.StartedUtc ??= DateTimeOffset.UtcNow;
|
||||||
|
job.Error = null;
|
||||||
|
RaiseChanged();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
switch (job.Op)
|
switch (job.Op)
|
||||||
{
|
{
|
||||||
case TransferOp.Copy:
|
case TransferOp.Copy:
|
||||||
await CopyOrMove(job, move: false, stoppingToken).ConfigureAwait(false);
|
await CopyOrMove(job, move: false, linked.Token).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
case TransferOp.Move:
|
case TransferOp.Move:
|
||||||
await CopyOrMove(job, move: true, stoppingToken).ConfigureAwait(false);
|
await CopyOrMove(job, move: true, linked.Token).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
case TransferOp.Delete:
|
case TransferOp.Delete:
|
||||||
Delete(job);
|
Delete(job);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (job.Status == TransferStatus.Paused)
|
||||||
|
{
|
||||||
|
await Persist(job).ConfigureAwait(false);
|
||||||
|
RaiseChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (job.Status == TransferStatus.Cancelling)
|
if (job.Status == TransferStatus.Cancelling)
|
||||||
{
|
{
|
||||||
job.Status = TransferStatus.Cancelled;
|
job.Status = TransferStatus.Cancelled;
|
||||||
@@ -169,28 +358,36 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
else if (job.Status != TransferStatus.Failed)
|
else if (job.Status != TransferStatus.Failed)
|
||||||
{
|
{
|
||||||
job.Status = TransferStatus.Done;
|
job.Status = TransferStatus.Done;
|
||||||
|
job.CurrentPath = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Cancelled;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
|
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
|
||||||
job.Status = TransferStatus.Failed;
|
job.Status = TransferStatus.Failed;
|
||||||
job.Error = ex.Message;
|
job.Error = ex.Message;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_runningCts, linked))
|
||||||
|
{
|
||||||
|
_runningCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.Status == TransferStatus.Paused)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await Persist(job).ConfigureAwait(false);
|
await Persist(job).ConfigureAwait(false);
|
||||||
if (job.Status is TransferStatus.Done or TransferStatus.Cancelled)
|
RaiseChanged();
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_jobs.Remove(job);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
|
||||||
JobFinished?.Invoke(this, job);
|
JobFinished?.Invoke(this, job);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
|
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
@@ -207,7 +404,7 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
if (item.IsDirectory)
|
if (item.IsDirectory)
|
||||||
{
|
{
|
||||||
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
|
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 */ }
|
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
|
||||||
}
|
}
|
||||||
@@ -215,36 +412,36 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
job.BytesTotal = item.SizeBytes;
|
job.FilesTotal = Math.Max(job.FilesTotal, 1);
|
||||||
var progress = new Progress<long>(b =>
|
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;
|
ApplyHalt(job, src, error);
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
return;
|
||||||
});
|
|
||||||
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;
|
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)
|
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(PathRules.ToExtended(dst));
|
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)>();
|
var stack = new Stack<(string From, string To)>();
|
||||||
stack.Push((src, dst));
|
stack.Push((src, dst));
|
||||||
while (stack.Count > 0)
|
while (stack.Count > 0)
|
||||||
{
|
{
|
||||||
stoppingToken.ThrowIfCancellationRequested();
|
stoppingToken.ThrowIfCancellationRequested();
|
||||||
if (job.Status == TransferStatus.Cancelling)
|
if (job.Status == TransferStatus.Cancelling || _haltPause)
|
||||||
{
|
{
|
||||||
|
ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,30 +460,82 @@ public sealed class TransferQueue : BackgroundService
|
|||||||
{
|
{
|
||||||
Directory.CreateDirectory(PathRules.ToExtended(childDest));
|
Directory.CreateDirectory(PathRules.ToExtended(childDest));
|
||||||
stack.Push((child.FullPath, childDest));
|
stack.Push((child.FullPath, childDest));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
job.FilesTotal++;
|
||||||
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
|
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
|
||||||
var ok = move
|
var destExists = File.Exists(PathRules.ToExtended(childDest));
|
||||||
? _shell.MoveFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out var err)
|
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
|
||||||
: _shell.CopyFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out err);
|
if (destExists && destLen == child.SizeBytes)
|
||||||
if (ok)
|
|
||||||
{
|
{
|
||||||
job.BytesDone += child.SizeBytes;
|
job.BytesDone += child.SizeBytes;
|
||||||
|
job.FilesDone++;
|
||||||
|
RaiseChanged(throttled: true);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (err != "Cancelled")
|
|
||||||
|
job.CurrentPath = child.FullPath;
|
||||||
|
var committed = job.BytesDone;
|
||||||
|
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, stoppingToken, out var err))
|
||||||
{
|
{
|
||||||
job.Error = err;
|
ApplyHalt(job, child.FullPath, err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
job.BytesDone = committed + child.SizeBytes;
|
||||||
}
|
job.FilesDone++;
|
||||||
|
RaiseChanged(throttled: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
job.CurrentPath = null;
|
||||||
await Task.CompletedTask.ConfigureAwait(false);
|
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)
|
private void Delete(TransferJob job)
|
||||||
{
|
{
|
||||||
var paths = job.AdditionalSources.Count > 0
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,19 +12,22 @@ public sealed class FilesystemScanner
|
|||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly ILogger<FilesystemScanner> _logger;
|
private readonly ILogger<FilesystemScanner> _logger;
|
||||||
private readonly ArchiveContentsIndexer? _archives;
|
private readonly ArchiveContentsIndexer? _archives;
|
||||||
|
private readonly UiPreferencesStore? _preferences;
|
||||||
|
|
||||||
public FilesystemScanner(
|
public FilesystemScanner(
|
||||||
IIndexStore store,
|
IIndexStore store,
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
ILogger<FilesystemScanner> logger,
|
ILogger<FilesystemScanner> logger,
|
||||||
ArchiveContentsIndexer? archives = null)
|
ArchiveContentsIndexer? archives = null,
|
||||||
|
UiPreferencesStore? preferences = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_archives = archives;
|
_archives = archives;
|
||||||
|
_preferences = preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ScanJob> ScanAsync(
|
public async Task<ScanJob> ScanAsync(
|
||||||
@@ -56,7 +59,8 @@ public sealed class FilesystemScanner
|
|||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var excludes = await _store.Excludes.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
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 root = source.LastRootPath ?? throw new InvalidOperationException("Source has no root path.");
|
||||||
var startRel = folderPathRel ?? "";
|
var startRel = folderPathRel ?? "";
|
||||||
var startPath = PathRules.Combine(root, startRel);
|
var startPath = PathRules.Combine(root, startRel);
|
||||||
@@ -128,13 +132,23 @@ public sealed class FilesystemScanner
|
|||||||
continue;
|
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 rel = PathRules.MakeRelative(root, child.FullPath);
|
||||||
var entry = CreateEntry(source, frame.Id, child, rel, DateTimeOffset.UtcNow, generation);
|
var entry = CreateEntry(source, frame.Id, child, rel, DateTimeOffset.UtcNow, generation);
|
||||||
pending.Add(entry);
|
pending.Add(entry);
|
||||||
if (child.IsDirectory)
|
if (child.IsDirectory)
|
||||||
{
|
{
|
||||||
job.DirsSeen++;
|
job.DirsSeen++;
|
||||||
if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
|
if (location.IsRecycleBin)
|
||||||
|
{
|
||||||
|
frame.Dirs++;
|
||||||
|
}
|
||||||
|
else if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
|
||||||
{
|
{
|
||||||
childDirs.Add(new Frame
|
childDirs.Add(new Frame
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ public sealed class FolderReconciler
|
|||||||
private readonly IFileSystemEnumerator _enumerator;
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly ArchiveContentsIndexer? _archives;
|
private readonly ArchiveContentsIndexer? _archives;
|
||||||
|
private readonly UiPreferencesStore? _preferences;
|
||||||
|
|
||||||
public FolderReconciler(
|
public FolderReconciler(
|
||||||
IIndexStore store,
|
IIndexStore store,
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
ArchiveContentsIndexer? archives = null)
|
ArchiveContentsIndexer? archives = null,
|
||||||
|
UiPreferencesStore? preferences = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
_archives = archives;
|
_archives = archives;
|
||||||
|
_preferences = preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||||
@@ -56,7 +59,7 @@ public sealed class FolderReconciler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Directory.Exists(full))
|
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,11 +72,18 @@ public sealed class FolderReconciler
|
|||||||
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
|
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
|
||||||
var archivesToExpand = new List<IndexEntry>();
|
var archivesToExpand = new List<IndexEntry>();
|
||||||
var archivesToTomb = new List<string>();
|
var archivesToTomb = new List<string>();
|
||||||
|
var prefs = _preferences?.Load() ?? UiPreferences.Default;
|
||||||
|
|
||||||
await _store.RunWriteAsync(async s =>
|
await _store.RunWriteAsync(async s =>
|
||||||
{
|
{
|
||||||
foreach (var item in live)
|
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 rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
|
||||||
var entry = new IndexEntry
|
var entry = new IndexEntry
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,22 +92,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
var sizeFromIndex = CurrentSource is { IsIndexed: true };
|
var sizeFromIndex = CurrentSource is { IsIndexed: true };
|
||||||
Items.Clear();
|
Items.Clear();
|
||||||
IEnumerable<FileSystemItem> ordered = listing.Items;
|
foreach (var item in 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)
|
|
||||||
{
|
{
|
||||||
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
|
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyCurrentSort();
|
||||||
|
|
||||||
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
|
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
|
||||||
&& Directory.Exists(path))
|
&& Directory.Exists(path))
|
||||||
{
|
{
|
||||||
@@ -141,6 +132,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
|
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyCurrentSort();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -218,8 +211,56 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
|
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
|
public void SortBy(string property)
|
||||||
partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
|
{
|
||||||
|
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)
|
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,22 +6,53 @@ namespace Explorer.Presentation;
|
|||||||
public sealed partial class FolderItemViewModel : ObservableObject
|
public sealed partial class FolderItemViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
[ObservableProperty] private bool _isRenaming;
|
||||||
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
|
[ObservableProperty] private string _editName = "";
|
||||||
|
|
||||||
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
|
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
|
||||||
{
|
{
|
||||||
Item = item;
|
Item = item;
|
||||||
SizeFromIndex = sizeFromIndex;
|
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 FileSystemItem Item { get; }
|
||||||
public bool SizeFromIndex { get; }
|
public bool SizeFromIndex { get; }
|
||||||
public string Name => Item.Name;
|
public string Name => Item.DisplayName ?? Item.Name;
|
||||||
public string FullPath => Item.FullPath;
|
public string FullPath => Item.FullPath;
|
||||||
public bool IsDirectory => Item.IsDirectory;
|
public bool IsDirectory => Item.IsDirectory;
|
||||||
public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
|
public string TypeLabel => Item.Location.IsRecycleBin
|
||||||
public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
|
? "Recycle Bin"
|
||||||
? ""
|
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
|
||||||
: Formatters.Size(Item.SizeBytes);
|
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 ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
|
||||||
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
|
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
|
||||||
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
|
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)}";
|
: $"{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}";
|
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
PathHistoryStore pathHistory,
|
PathHistoryStore pathHistory,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
CloudPlaceStore cloudPlaces,
|
CloudPlaceStore cloudPlaces,
|
||||||
UiPreferencesStore preferences)
|
UiPreferencesStore preferences,
|
||||||
|
IVolumeService volumes)
|
||||||
{
|
{
|
||||||
_browse = browse;
|
_browse = browse;
|
||||||
_ops = ops;
|
_ops = ops;
|
||||||
@@ -64,10 +65,10 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
Theme = prefs.Theme;
|
Theme = prefs.Theme;
|
||||||
PathHistory = [];
|
PathHistory = [];
|
||||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
|
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
|
||||||
Search = new SearchViewModel(search, sources);
|
Search = new SearchViewModel(search, sources, volumes);
|
||||||
Analysis = new AnalysisViewModel(analysis);
|
Analysis = new AnalysisViewModel(analysis);
|
||||||
Duplicates = new DuplicateViewModel(store, sources);
|
Duplicates = new DuplicateViewModel(store, sources);
|
||||||
Transfers = new TransferQueueViewModel(transfers);
|
Transfers = new TransferQueueViewModel(transfers, preferences);
|
||||||
Tabs = [];
|
Tabs = [];
|
||||||
Clipboard = clipboard;
|
Clipboard = clipboard;
|
||||||
transfers.JobFinished += (_, job) =>
|
transfers.JobFinished += (_, job) =>
|
||||||
@@ -87,13 +88,22 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
var text = p.Status == ScanJobStatus.Done
|
var text = p.Status == ScanJobStatus.Done
|
||||||
? $"Indexed {p.FilesSeen:N0} files"
|
? $"Indexed {p.FilesSeen:N0} files"
|
||||||
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
|
: $"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)
|
if (_ui is { } ctx)
|
||||||
{
|
{
|
||||||
ctx.Post(_ => Footer = text, null);
|
ctx.Post(_ => Apply(), null);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Footer = text;
|
Apply();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -304,32 +314,52 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
paths = [ActivePane.CurrentPath];
|
paths = [ActivePane.CurrentPath];
|
||||||
}
|
}
|
||||||
|
|
||||||
CopyPathsToClipboard(paths);
|
CopyPathText(paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event EventHandler<string>? InlineRenameRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[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)
|
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);
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
Footer = $"Created {PathRules.GetFileName(created)}.";
|
||||||
|
await RefreshFolderViewsAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RenameSelected(string newName)
|
public void RenameSelected(string newName)
|
||||||
{
|
{
|
||||||
var item = ActivePane.SelectedItems.FirstOrDefault();
|
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);
|
_ops.Rename(item.FullPath, newName);
|
||||||
EnqueueReconcile(ActivePane.CurrentPath);
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
|
Footer = $"Renamed to {newName}.";
|
||||||
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +569,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (Analysis.SelectedPath is { } path)
|
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) };
|
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)
|
public async Task ApplyPreferencesAsync(UiPreferences preferences)
|
||||||
{
|
{
|
||||||
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };
|
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };
|
||||||
_preferences.Save(normalized);
|
_preferences.Save(normalized);
|
||||||
Theme = normalized.Theme;
|
Theme = normalized.Theme;
|
||||||
|
Transfers.ApplyPreferences();
|
||||||
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
foreach (var tab in Tabs)
|
foreach (var tab in Tabs)
|
||||||
{
|
{
|
||||||
@@ -849,6 +895,23 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
Clipboard.SetFiles(paths, _clipboardIsCut);
|
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)
|
partial void OnActiveTabChanged(ExplorerTabViewModel value)
|
||||||
{
|
{
|
||||||
PathText = value.ActivePane.CurrentPath;
|
PathText = value.ActivePane.CurrentPath;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _status = "";
|
[ObservableProperty] private string _status = "";
|
||||||
[ObservableProperty] private bool _isOffline;
|
[ObservableProperty] private bool _isOffline;
|
||||||
[ObservableProperty] private bool _childrenLoaded;
|
[ObservableProperty] private bool _childrenLoaded;
|
||||||
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
|
|
||||||
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
||||||
public string Glyph { get; init; } = "\uE8B7";
|
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)
|
public static bool PathsEqual(string a, string b)
|
||||||
{
|
{
|
||||||
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -362,16 +394,7 @@ public sealed class NavigationTreeViewModel
|
|||||||
{
|
{
|
||||||
Label = source.DisplayName,
|
Label = source.DisplayName,
|
||||||
Path = source.LastRootPath ?? source.DisplayName,
|
Path = source.LastRootPath ?? source.DisplayName,
|
||||||
Status = source.Status switch
|
Status = FormatStatus(source),
|
||||||
{
|
|
||||||
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" : ""
|
|
||||||
},
|
|
||||||
IsOffline = source.Status == SourceStatus.Offline,
|
IsOffline = source.Status == SourceStatus.Offline,
|
||||||
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2",
|
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2",
|
||||||
SourceId = source.Id,
|
SourceId = source.Id,
|
||||||
@@ -489,6 +512,30 @@ public sealed class NavigationTreeViewModel
|
|||||||
return null;
|
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)
|
private static NavNodeViewModel? FindByPath(IEnumerable<NavNodeViewModel> nodes, string path)
|
||||||
{
|
{
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
using Explorer.Search;
|
using Explorer.Search;
|
||||||
|
|
||||||
namespace Explorer.Presentation.ViewModels;
|
namespace Explorer.Presentation.ViewModels;
|
||||||
@@ -13,6 +14,7 @@ public sealed partial class SearchViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly SearchService _search;
|
private readonly SearchService _search;
|
||||||
private readonly SourceManager _sources;
|
private readonly SourceManager _sources;
|
||||||
|
private readonly IVolumeService _volumes;
|
||||||
private CancellationTokenSource? _runCts;
|
private CancellationTokenSource? _runCts;
|
||||||
|
|
||||||
[ObservableProperty] private string _text = "";
|
[ObservableProperty] private string _text = "";
|
||||||
@@ -26,10 +28,11 @@ public sealed partial class SearchViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
[ObservableProperty] private string _status = "";
|
[ObservableProperty] private string _status = "";
|
||||||
|
|
||||||
public SearchViewModel(SearchService search, SourceManager sources)
|
public SearchViewModel(SearchService search, SourceManager sources, IVolumeService volumes)
|
||||||
{
|
{
|
||||||
_search = search;
|
_search = search;
|
||||||
_sources = sources;
|
_sources = sources;
|
||||||
|
_volumes = volumes;
|
||||||
Results = [];
|
Results = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,12 +114,19 @@ public sealed partial class SearchViewModel : ObservableObject
|
|||||||
|
|
||||||
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
|
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
|
||||||
var byId = sources.ToDictionary(s => s.Id);
|
var byId = sources.ToDictionary(s => s.Id);
|
||||||
|
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
{
|
{
|
||||||
byId.TryGetValue(entry.SourceId, out var src);
|
byId.TryGetValue(entry.SourceId, out var src);
|
||||||
var full = src?.LastRootPath is null
|
var full = src?.LastRootPath is null
|
||||||
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
|
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
|
||||||
: PathRules.Combine(src.LastRootPath, 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
|
Results.Add(new FolderItemViewModel(new FileSystemItem
|
||||||
{
|
{
|
||||||
FullPath = full,
|
FullPath = full,
|
||||||
@@ -127,7 +137,9 @@ public sealed partial class SearchViewModel : ObservableObject
|
|||||||
ModifiedUtc = entry.ModifiedUtc,
|
ModifiedUtc = entry.ModifiedUtc,
|
||||||
Attributes = entry.Attributes,
|
Attributes = entry.Attributes,
|
||||||
FileId = entry.FileId,
|
FileId = entry.FileId,
|
||||||
ReparseTag = entry.ReparseTag
|
ReparseTag = entry.ReparseTag,
|
||||||
|
FreeSpaceBytes = space.FreeBytes,
|
||||||
|
CapacityBytes = space.CapacityBytes
|
||||||
}, entry.IsDirectory));
|
}, entry.IsDirectory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,243 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.FileOperations;
|
using Explorer.FileOperations;
|
||||||
|
|
||||||
namespace Explorer.Presentation.ViewModels;
|
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
|
public sealed partial class TransferQueueViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly TransferQueue _queue;
|
private readonly TransferQueue _queue;
|
||||||
|
private readonly UiPreferencesStore _preferences;
|
||||||
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
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;
|
_queue = queue;
|
||||||
|
_preferences = preferences;
|
||||||
Jobs = [];
|
Jobs = [];
|
||||||
_queue.Changed += (_, _) =>
|
_queue.Changed += (_, _) => Dispatch(Reload);
|
||||||
{
|
|
||||||
if (_ui is { } ctx)
|
|
||||||
{
|
|
||||||
ctx.Post(_ => Reload(), null);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Reload();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<TransferJob> Jobs { get; }
|
public void ApplyPreferences()
|
||||||
public bool HasJobs => Jobs.Count > 0;
|
|
||||||
|
|
||||||
public void Cancel(TransferJob job)
|
|
||||||
{
|
{
|
||||||
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);
|
_queue.Cancel(job.Id);
|
||||||
}
|
}
|
||||||
@@ -43,18 +247,184 @@ public sealed partial class TransferQueueViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void MoveUp(TransferJobViewModel? job)
|
||||||
|
{
|
||||||
|
if (job is not null)
|
||||||
|
{
|
||||||
|
_queue.MoveUp(job.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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()
|
private void Reload()
|
||||||
{
|
{
|
||||||
Jobs.Clear();
|
if (_preferences.Load().AutoClearQueueWhenDone)
|
||||||
foreach (var job in _queue.Snapshot().Where(IsVisible))
|
|
||||||
{
|
{
|
||||||
Jobs.Add(job);
|
_queue.ClearFinished();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasJobs));
|
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)
|
private static bool IsVisible(TransferJob job)
|
||||||
=> job.Status is TransferStatus.Queued or TransferStatus.Running
|
=> job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
|
||||||
or TransferStatus.Cancelling or TransferStatus.Failed;
|
or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
|
||||||
|
or TransferStatus.Cancelled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ internal sealed class ExcludeStore : IExcludeStore
|
|||||||
{
|
{
|
||||||
await AddAsync(rule, cancellationToken).ConfigureAwait(false);
|
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);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +93,28 @@ internal sealed class ScanJobStore : IScanJobStore
|
|||||||
""", Args(job)), cancellationToken);
|
""", Args(job)), cancellationToken);
|
||||||
|
|
||||||
public Task InterruptRunningAsync(CancellationToken cancellationToken = default)
|
public Task InterruptRunningAsync(CancellationToken cancellationToken = default)
|
||||||
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
var utc = DateTimeOffset.UtcNow.ToString("O");
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
|
UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
|
||||||
WHERE status IN ('Running','Queued')
|
WHERE status IN ('Running','Queued')
|
||||||
""", new { utc = DateTimeOffset.UtcNow.ToString("O") }), cancellationToken);
|
""", 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)
|
public Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default)
|
||||||
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ internal static partial class NativeMethods
|
|||||||
public const uint MoveFileWriteThrough = 0x0008;
|
public const uint MoveFileWriteThrough = 0x0008;
|
||||||
public const int ProgressContinue = 0;
|
public const int ProgressContinue = 0;
|
||||||
public const int ProgressCancel = 1;
|
public const int ProgressCancel = 1;
|
||||||
|
public const int ProgressStop = 2;
|
||||||
|
public const int ErrorRequestAborted = 1235;
|
||||||
public const uint ProcessModeBackgroundBegin = 0x00100000;
|
public const uint ProcessModeBackgroundBegin = 0x00100000;
|
||||||
public const uint ProcessModeBackgroundEnd = 0x00200000;
|
public const uint ProcessModeBackgroundEnd = 0x00200000;
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ internal static partial class NativeMethods
|
|||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[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);
|
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)]
|
[LibraryImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
public static partial bool GetFileInformationByHandle(nint hFile, out ByHandleFileInformation lpFileInformation);
|
public static partial bool GetFileInformationByHandle(nint hFile, out ByHandleFileInformation lpFileInformation);
|
||||||
|
|||||||
27
src/Explorer.Windows/WindowsElevatedScanService.cs
Normal file
27
src/Explorer.Windows/WindowsElevatedScanService.cs
Normal 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.";
|
||||||
|
}
|
||||||
@@ -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;
|
error = null;
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
|
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
|
||||||
var cancel = 0;
|
var cancel = 0;
|
||||||
|
var paused = false;
|
||||||
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
|
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
|
||||||
{
|
{
|
||||||
progress?.Report(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(
|
var ok = NativeMethods.CopyFileEx(
|
||||||
PathRules.ToExtended(source),
|
PathRules.ToExtended(source),
|
||||||
PathRules.ToExtended(destination),
|
PathRules.ToExtended(destination),
|
||||||
@@ -99,7 +116,13 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
var code = Marshal.GetLastWin32Error();
|
var code = Marshal.GetLastWin32Error();
|
||||||
if (cancellationToken.IsCancellationRequested || code == 1235)
|
if (paused)
|
||||||
|
{
|
||||||
|
error = "Paused";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested || code == NativeMethods.ErrorRequestAborted)
|
||||||
{
|
{
|
||||||
error = "Cancelled";
|
error = "Cancelled";
|
||||||
return false;
|
return false;
|
||||||
@@ -112,14 +135,26 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
|||||||
return true;
|
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;
|
error = null;
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
|
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
|
||||||
|
var paused = false;
|
||||||
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
|
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
|
||||||
{
|
{
|
||||||
progress?.Report(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;
|
var flags = NativeMethods.MoveFileCopyAllowed | NativeMethods.MoveFileWriteThrough;
|
||||||
@@ -137,7 +172,13 @@ public sealed class WindowsShellFileOperations : IShellFileOperations
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
var code = Marshal.GetLastWin32Error();
|
var code = Marshal.GetLastWin32Error();
|
||||||
if (cancellationToken.IsCancellationRequested)
|
if (paused)
|
||||||
|
{
|
||||||
|
error = "Paused";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested || code == NativeMethods.ErrorRequestAborted)
|
||||||
{
|
{
|
||||||
error = "Cancelled";
|
error = "Cancelled";
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -46,12 +46,15 @@ public sealed class WindowsVolumeService : IVolumeService
|
|||||||
if (PathRules.IsUnc(path))
|
if (PathRules.IsUnc(path))
|
||||||
{
|
{
|
||||||
var root = PathRules.CanonicalUncRoot(path);
|
var root = PathRules.CanonicalUncRoot(path);
|
||||||
|
var uncSpace = QuerySpace(root);
|
||||||
return new VolumeFingerprint
|
return new VolumeFingerprint
|
||||||
{
|
{
|
||||||
Kind = SourceKind.Smb,
|
Kind = SourceKind.Smb,
|
||||||
RootPath = root,
|
RootPath = root,
|
||||||
DisplayName = 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;
|
uint serial = 0;
|
||||||
string? fs = null;
|
string? fs = null;
|
||||||
string? label = null;
|
string? label = null;
|
||||||
long? capacity = null;
|
var space = QuerySpace(rootPath);
|
||||||
|
long? capacity = space.CapacityBytes;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (drive is { IsReady: true })
|
if (drive is { IsReady: true })
|
||||||
{
|
{
|
||||||
fs = drive.DriveFormat;
|
fs = drive.DriveFormat;
|
||||||
label = drive.VolumeLabel;
|
label = drive.VolumeLabel;
|
||||||
capacity = drive.TotalSize;
|
capacity ??= drive.TotalSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
@@ -165,6 +169,7 @@ public sealed class WindowsVolumeService : IVolumeService
|
|||||||
Filesystem = fs,
|
Filesystem = fs,
|
||||||
Label = label,
|
Label = label,
|
||||||
CapacityBytes = capacity,
|
CapacityBytes = capacity,
|
||||||
|
FreeBytes = space.FreeBytes,
|
||||||
RootPath = rootPath,
|
RootPath = rootPath,
|
||||||
DisplayName = display
|
DisplayName = display
|
||||||
};
|
};
|
||||||
@@ -176,6 +181,8 @@ public sealed class WindowsVolumeService : IVolumeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public VolumeSpace GetSpace(string path) => QuerySpace(path);
|
||||||
|
|
||||||
public bool IsPathReachable(string path)
|
public bool IsPathReachable(string path)
|
||||||
{
|
{
|
||||||
try
|
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)
|
private static string FormatSize(long bytes)
|
||||||
{
|
{
|
||||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public class BrowseServiceTests
|
|||||||
var listing = await browse.ListThisPcAsync();
|
var listing = await browse.ListThisPcAsync();
|
||||||
var item = Assert.Single(listing.Items);
|
var item = Assert.Single(listing.Items);
|
||||||
Assert.Equal(1_073_741_824, item.SizeBytes);
|
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);
|
Assert.True(item.IsDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ public class BrowseServiceTests
|
|||||||
var listing = await browse.ListAsync(@"C:\");
|
var listing = await browse.ListAsync(@"C:\");
|
||||||
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
|
var movies = Assert.Single(listing.Items, i => i.Name == "Movies");
|
||||||
Assert.Equal(200, movies.SizeBytes);
|
Assert.Equal(200, movies.SizeBytes);
|
||||||
|
Assert.Equal(250_000_000, movies.FreeSpaceBytes);
|
||||||
var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt");
|
var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt");
|
||||||
Assert.Equal(10, loose.SizeBytes);
|
Assert.Equal(10, loose.SizeBytes);
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,9 @@ public class BrowseServiceTests
|
|||||||
Kind = SourceKind.NtfsLocal,
|
Kind = SourceKind.NtfsLocal,
|
||||||
RootPath = root,
|
RootPath = root,
|
||||||
DisplayName = display,
|
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));
|
=> Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||||
public bool IsPathReachable(string path)
|
public bool IsPathReachable(string path)
|
||||||
=> Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
=> 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
|
file sealed class BrowseEnv : IAppEnvironment
|
||||||
|
|||||||
@@ -136,6 +136,39 @@ public class SourceManagerTests
|
|||||||
Assert.Null(await store.Entries.GetRootAsync(source.Id));
|
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]
|
[Fact]
|
||||||
public async Task Forget_removes_unc_from_recents()
|
public async Task Forget_removes_unc_from_recents()
|
||||||
{
|
{
|
||||||
@@ -166,6 +199,11 @@ file sealed class FakeVolumes : IVolumeService
|
|||||||
|| v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase));
|
|| v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
public bool IsPathReachable(string path) => Online.Any(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 FakeEnv : IAppEnvironment
|
file sealed class FakeEnv : IAppEnvironment
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Application.Tests;
|
namespace Explorer.Application.Tests;
|
||||||
@@ -13,12 +14,14 @@ public class UiPreferencesStoreTests
|
|||||||
"theme=Light",
|
"theme=Light",
|
||||||
"group-network=true",
|
"group-network=true",
|
||||||
"group-cloud=false",
|
"group-cloud=false",
|
||||||
"index-archives=true"
|
"index-archives=true",
|
||||||
|
"auto-clear-queue=true"
|
||||||
]);
|
]);
|
||||||
Assert.Equal("Light", prefs.Theme);
|
Assert.Equal("Light", prefs.Theme);
|
||||||
Assert.True(prefs.GroupNetworkPlaces);
|
Assert.True(prefs.GroupNetworkPlaces);
|
||||||
Assert.False(prefs.GroupCloudPlaces);
|
Assert.False(prefs.GroupCloudPlaces);
|
||||||
Assert.True(prefs.IndexArchiveContents);
|
Assert.True(prefs.IndexArchiveContents);
|
||||||
|
Assert.True(prefs.AutoClearQueueWhenDone);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -29,6 +32,30 @@ public class UiPreferencesStoreTests
|
|||||||
Assert.False(prefs.GroupNetworkPlaces);
|
Assert.False(prefs.GroupNetworkPlaces);
|
||||||
Assert.False(prefs.GroupCloudPlaces);
|
Assert.False(prefs.GroupCloudPlaces);
|
||||||
Assert.False(prefs.IndexArchiveContents);
|
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]
|
[Fact]
|
||||||
@@ -38,12 +65,18 @@ public class UiPreferencesStoreTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var store = new UiPreferencesStore(new PrefsEnv(dir));
|
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();
|
var loaded = store.Load();
|
||||||
Assert.Equal("Light", loaded.Theme);
|
Assert.Equal("Light", loaded.Theme);
|
||||||
Assert.True(loaded.GroupNetworkPlaces);
|
Assert.True(loaded.GroupNetworkPlaces);
|
||||||
Assert.False(loaded.GroupCloudPlaces);
|
Assert.False(loaded.GroupCloudPlaces);
|
||||||
Assert.True(loaded.IndexArchiveContents);
|
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
|
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
|
file sealed class PrefsEnv : IAppEnvironment
|
||||||
{
|
{
|
||||||
public PrefsEnv(string dir)
|
public PrefsEnv(string dir)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.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="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ file sealed class StubShell : IShellFileOperations
|
|||||||
public void Open(string path) { }
|
public void Open(string path) { }
|
||||||
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error) => Delete(paths, true, out error);
|
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 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; }
|
{ 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; }
|
{ error = "n/a"; return false; }
|
||||||
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
public bool CreateShortcut(string targetPath, string shortcutPath, out string? error)
|
||||||
{ error = null; return true; }
|
{ error = null; return true; }
|
||||||
|
|||||||
273
tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
Normal file
273
tests/Explorer.FileOperations.Tests/TransferQueueTests.cs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user