Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

View File

@@ -88,6 +88,48 @@ public sealed class AnalysisService
public Task<IReadOnlyList<Source>> GetKnownSourcesAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken);
public Task<IReadOnlyList<ClassifiedDuplicateGroup>> GetClassifiedDuplicatesAsync(
int take = 200,
CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
var raw = await _store.Hashes.GetDuplicateGroupsAsync(null, null, Math.Max(take * 8, 400), ct)
.ConfigureAwait(false);
var ids = raw.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
var relations = await _store.Relations.GetAmongAsync(ids, ct).ConfigureAwait(false);
return (IReadOnlyList<ClassifiedDuplicateGroup>)raw
.Select(g => DuplicateClassifier.ClassifyGroup(g, DuplicateClassifier.RelationsFor(g.Entries, relations)))
.ToList();
}, cancellationToken);
public Task MarkDuplicateGroupAsync(
IReadOnlyList<IndexEntry> entries,
FileRelationKind kind,
CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
var ids = entries.Select(e => e.Id).Distinct().ToList();
await _store.Relations.DeleteAmongAsync(
ids,
[FileRelationKind.AccidentalDuplicate, FileRelationKind.IntentionalDuplicate],
ct)
.ConfigureAwait(false);
var utc = DateTimeOffset.UtcNow;
foreach (var (left, right) in DuplicateClassifier.Pairs(entries))
{
await _store.Relations.UpsertAsync(new FileRelation
{
LeftEntryId = left,
RightEntryId = right,
Kind = kind,
Origin = FileRelationOrigin.User,
CreatedUtc = utc
}, ct).ConfigureAwait(false);
}
return 0;
}, cancellationToken);
private async Task<T> CachedAsync<T>(string key, Func<CancellationToken, Task<T>> query, CancellationToken cancellationToken)
{
await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,32 @@
<Window x:Class="Explorer.App.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About Explorer Workbench"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="420" Width="520"
MinHeight="360" MinWidth="440"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
<DockPanel Margin="24">
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
IsDefault="True" IsCancel="True" Margin="0,20,0,0"/>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Image Width="48" Height="48" Margin="0,0,14,0"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Explorer Workbench" FontSize="22" FontWeight="SemiBold"/>
<TextBlock x:Name="VersionText" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
<TextBlock TextWrapping="Wrap" Margin="0,0,0,16"
Text="A personal file management workbench: live browsing, a local index for search and analysis, and a queue for copy, rename, archive, sync, and organize work. Windows stays the source of truth."/>
<TextBlock Text="Data folder" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox x:Name="DataPath" IsReadOnly="True" Margin="0,0,0,12"/>
<TextBlock Text="Logs" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox x:Name="LogPath" IsReadOnly="True"/>
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,24 @@
using System.IO;
using System.Reflection;
using System.Windows;
using Explorer.Domain;
namespace Explorer.App;
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
var version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? "0.1";
VersionText.Text = "Version " + version;
var data = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AppConstants.ProductFolderName);
DataPath.Text = data;
LogPath.Text = Path.Combine(data, AppConstants.LogFolderName);
}
}

View File

@@ -17,7 +17,6 @@
<local:ViewModeToVisibilityConverter x:Key="ViewList" Match="List"/>
<local:ViewModeToVisibilityConverter x:Key="ViewPreview" Match="Preview"/>
<local:ShellIconConverter x:Key="ShellIcon"/>
<local:ThumbnailConverter x:Key="Thumbnail"/>
<local:TransferActionTextConverter x:Key="TransferAction"/>
<Style x:Key="InlineRenameBox" TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
@@ -59,25 +58,14 @@
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding GitLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasGitLabel, Converter={StaticResource BoolVis}}"/>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="PreviewTile">
<StackPanel Width="96" Margin="2">
<Grid Width="96" Height="96">
<Image Width="96" Height="96" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Converter={StaticResource Thumbnail}}">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImage}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Image Width="64" Height="64" Stretch="Uniform"
HorizontalAlignment="Center" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
@@ -86,13 +74,27 @@
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImage}" Value="True">
<DataTrigger Binding="{Binding HasThumbnail}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Image Width="96" Height="96" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Thumbnail}">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasThumbnail}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
</Grid>
<TextBlock Tag="ItemName" Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}">
@@ -490,6 +492,49 @@
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Role" Value="SubmenuHeader">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<Grid>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="&#xE76C;" FontFamily="{StaticResource Symbol}"
FontSize="10" Margin="16,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}"/>
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center"/>
</DockPanel>
<Popup x:Name="PART_Popup"
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Right"
PlacementTarget="{Binding ElementName=Bd}"
HorizontalOffset="-2"
VerticalOffset="-4"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade">
<Border MinWidth="180" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="4">
<StackPanel IsItemsHost="True"/>
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsSubmenuOpen" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="Role" Value="TopLevelHeader">
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">

View File

@@ -8,6 +8,7 @@ using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
@@ -40,8 +41,11 @@ public static class AppServices
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IGitStatusProvider, WindowsGitStatusProvider>();
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, RecycleBinCatalog>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
@@ -49,6 +53,8 @@ public static class AppServices
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
@@ -56,8 +62,17 @@ public static class AppServices
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenamePlanner>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>();
@@ -66,6 +81,7 @@ public static class AppServices
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
@@ -75,11 +91,22 @@ public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
public WatcherHostedService(DirectoryWatcherHub hub, SourceManager sources)
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -89,6 +116,9 @@ public sealed class WatcherHostedService : BackgroundService
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,86 @@
<Window x:Class="Explorer.App.BatchRenameWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Batch rename"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="820"
MinHeight="480" MinWidth="640"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Search / replace" FontWeight="SemiBold" Margin="0,0,0,8"/>
<TextBlock Text="Search" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Search, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Replace" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Replace, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<CheckBox Content="Regular expression" IsChecked="{Binding UseRegex}" Margin="0,0,0,6"/>
<CheckBox Content="Match case" IsChecked="{Binding MatchCase}" Margin="0,0,0,6"/>
<CheckBox Content="Include extension in search" IsChecked="{Binding IncludeExtensionInSearch}" Margin="0,0,0,16"/>
<TextBlock Text="Prefix / suffix" FontWeight="SemiBold" Margin="0,0,0,8"/>
<TextBlock Text="Prefix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Prefix, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Suffix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Suffix, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16"/>
<TextBlock Text="Counter" FontWeight="SemiBold" Margin="0,0,0,8"/>
<CheckBox Content="Add counter" IsChecked="{Binding UseCounter}" Margin="0,0,0,8"/>
<Grid Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Start" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterStart, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Step" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterStep, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Digits" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterPadding, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
</Grid>
<TextBlock Text="Case" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding CaseOptions}" DisplayMemberPath="Label" SelectedValuePath="Mode"
SelectedValue="{Binding CaseMode}" Margin="0,0,0,16"/>
<TextBlock Text="Extension" FontWeight="SemiBold" Margin="0,0,0,8"/>
<CheckBox Content="Change extension" IsChecked="{Binding ChangeExtension}" Margin="0,0,0,8"/>
<TextBox Text="{Binding NewExtension, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding ChangeExtension}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Current" Width="220" DisplayMemberBinding="{Binding OldName}"/>
<GridViewColumn Header="New name" Width="220" DisplayMemberBinding="{Binding NewName}"/>
<GridViewColumn Header="Status" Width="220" DisplayMemberBinding="{Binding Status}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,26 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class BatchRenameWindow : Window
{
public BatchRenameWindow(BatchRenameViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
// not shown as a dialog
}
Close();
};
}
}

View File

@@ -1,11 +1,7 @@
using System.Globalization;
using System.IO;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Explorer.Domain;
using Explorer.Presentation;
namespace Explorer.App;
@@ -53,22 +49,6 @@ public sealed class ViewModeToVisibilityConverter : IValueConverter
=> throw new NotSupportedException();
}
public sealed class ThumbnailConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is FolderItemViewModel { IsImage: true, MayHydrateOnRead: false, FullPath: var path })
{
return ThumbnailCache.Load(path, decodeWidth: 240);
}
return null;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class TransferActionTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
@@ -77,31 +57,3 @@ public sealed class TransferActionTextConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
internal static class ThumbnailCache
{
public static ImageSource? Load(string path, int decodeWidth)
{
try
{
if (!File.Exists(path))
{
return null;
}
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.CreateOptions = BitmapCreateOptions.IgnoreImageCache | BitmapCreateOptions.IgnoreColorProfile;
bmp.DecodePixelWidth = decodeWidth;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,55 @@
using System.IO;
using System.Reflection;
namespace Explorer.App;
public static class DocumentationLoader
{
public const string EmbeddedName = "Explorer.Documentation.md";
public const string FileName = "Documentation.md";
public static LoadedDocumentation Load()
{
foreach (var path in CandidatePaths())
{
try
{
if (File.Exists(path))
{
return new LoadedDocumentation(File.ReadAllText(path), path);
}
}
catch (IOException)
{
// try the next candidate
}
}
var assembly = typeof(DocumentationLoader).Assembly;
using var stream = assembly.GetManifestResourceStream(EmbeddedName);
if (stream is null)
{
return new LoadedDocumentation(
"# Documentation\n\nThe user guide file was not found. Add `docs/Documentation.md` to the project.",
null);
}
using var reader = new StreamReader(stream);
return new LoadedDocumentation(reader.ReadToEnd(), null);
}
public static IEnumerable<string> CandidatePaths()
{
var baseDir = AppContext.BaseDirectory;
yield return Path.Combine(baseDir, FileName);
yield return Path.Combine(baseDir, "docs", FileName);
var dir = new DirectoryInfo(baseDir);
for (var n = 0; n < 8 && dir is not null; n++, dir = dir.Parent)
{
yield return Path.Combine(dir.FullName, "docs", FileName);
yield return Path.Combine(dir.FullName, FileName);
}
}
}
public sealed record LoadedDocumentation(string Markdown, string? SourcePath);

View File

@@ -0,0 +1,38 @@
<Window x:Class="Explorer.App.DocumentationWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Documentation"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="760" Width="1020"
MinHeight="480" MinWidth="720"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
x:Name="OpenFileButton" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Reload" MinWidth="88" Height="32" Click="OnReload" Margin="8,0,0,0"/>
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
Foreground="{DynamicResource FgMuted}"/>
</DockPanel>
<Grid Margin="16,16,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Contents" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ListBox x:Name="TocList" SelectionChanged="OnTocSelected"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}"/>
</DockPanel>
<FlowDocumentScrollViewer x:Name="Viewer" Grid.Column="2"
IsToolBarVisible="False"
Zoom="100"
Background="{DynamicResource Bg}"
VerticalScrollBarVisibility="Auto"/>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using Explorer.Application;
namespace Explorer.App;
public partial class DocumentationWindow : Window
{
private FlowDocument? _document;
private bool _suppressToc;
public DocumentationWindow()
{
InitializeComponent();
Loaded += (_, _) => Render();
}
private void OnReload(object sender, RoutedEventArgs e) => Render();
private void OnOpenFile(object sender, RoutedEventArgs e)
{
var path = OpenFileButton.Tag as string;
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
{
return;
}
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
private void OnTocSelected(object sender, SelectionChangedEventArgs e)
{
if (_suppressToc || TocList.SelectedItem is not TocItem item || _document is null)
{
return;
}
if (FindHeading(_document.Blocks, item.Id) is { } paragraph)
{
paragraph.BringIntoView();
}
}
private void Render()
{
var loaded = DocumentationLoader.Load();
var parsed = MarkdownParser.Parse(loaded.Markdown);
var brushes = new DocumentationBrushes(
Brush("Fg"),
Brush("FgMuted"),
Brush("Accent"),
Brush("Panel"),
Brush("Stroke"),
Brush("InputBg"));
_document = MarkdownFlowConverter.ToFlowDocument(parsed, brushes);
Viewer.Document = _document;
_suppressToc = true;
TocList.Items.Clear();
foreach (var heading in parsed.Headings)
{
TocList.Items.Add(new TocItem(heading.Text, heading.Id, heading.Level));
}
_suppressToc = false;
var fromFile = !string.IsNullOrWhiteSpace(loaded.SourcePath);
OpenFileButton.IsEnabled = fromFile;
OpenFileButton.Tag = loaded.SourcePath;
SourceLabel.Text = fromFile
? "Editing: " + loaded.SourcePath
: "Built-in copy. Place Documentation.md next to the executable to override it.";
}
private Brush Brush(string key)
=> TryFindResource(key) as Brush ?? Brushes.White;
private static Paragraph? FindHeading(BlockCollection blocks, string id)
{
foreach (var block in blocks)
{
if (block is Paragraph paragraph && paragraph.Tag as string == id)
{
return paragraph;
}
if (block is List list)
{
foreach (var item in list.ListItems)
{
var nested = FindHeading(item.Blocks, id);
if (nested is not null)
{
return nested;
}
}
}
}
return null;
}
private sealed record TocItem(string Title, string Id, int Level)
{
public override string ToString() => Level >= 3 ? " " + Title : Title;
}
}

View File

@@ -14,6 +14,8 @@
<ItemGroup>
<Resource Include="..\..\explorer-workbench-icons\explorer-workbench.ico" Link="Assets\explorer-workbench.ico" />
<Resource Include="..\..\explorer-workbench-icons\explorer-workbench-20.png" Link="Assets\explorer-workbench-20.png" />
<EmbeddedResource Include="..\..\docs\Documentation.md" LogicalName="Explorer.Documentation.md" />
<Content Include="..\..\docs\Documentation.md" Link="Documentation.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

View File

@@ -0,0 +1,77 @@
<Window x:Class="Explorer.App.FolderSyncWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Folder sync"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="680" Width="920"
MinHeight="520" MinWidth="720"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="New" MinWidth="64" Height="28" Command="{Binding NewProfileCommand}" Margin="0,0,8,0"/>
<Button Content="Delete" MinWidth="64" Height="28" Command="{Binding DeleteCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding Profiles}" SelectedItem="{Binding Selected}"
DisplayMemberPath="Name"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Source" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Mode" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding Modes}" DisplayMemberPath="Label" SelectedValuePath="Mode"
SelectedValue="{Binding Mode}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Copy / Update adds and overwrites from source. Mirror also deletes destination files that are not in the source — deletions stay in the preview until you Queue."/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Copy / Update only. Drive letters can change; the volume identity is stored. Mirror always needs Preview because it can delete."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>
<Button Content="Save profile" Margin="0,12,0,0" Height="28" Command="{Binding SaveCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="4" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Path" Width="240" DisplayMemberBinding="{Binding RelativePath}"/>
<GridViewColumn Header="Action" Width="80" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Detail" Width="180" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,39 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class FolderSyncWindow : Window
{
public FolderSyncWindow(FolderSyncViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e)
{
if (PickFolder("Source folder") is { } path && DataContext is FolderSyncViewModel vm)
{
vm.SourcePath = path;
}
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
if (PickFolder("Destination folder") is { } path && DataContext is FolderSyncViewModel vm)
{
vm.DestPath = path;
}
}
private string? PickFolder(string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
return picker.ShowDialog(this) == true ? picker.FolderName : null;
}
}

View File

@@ -0,0 +1,155 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public static class FolderViewport
{
public static readonly DependencyProperty IsTrackedProperty =
DependencyProperty.RegisterAttached(
"IsTracked",
typeof(bool),
typeof(FolderViewport),
new PropertyMetadata(false, OnTrackedChanged));
public static void SetIsTracked(ListView element, bool value) => element.SetValue(IsTrackedProperty, value);
public static bool GetIsTracked(ListView element) => (bool)element.GetValue(IsTrackedProperty);
private static void OnTrackedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ListView list)
{
return;
}
if (e.NewValue is true)
{
list.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnScrollChanged), true);
list.Loaded += OnLoaded;
}
else
{
list.RemoveHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnScrollChanged));
list.Loaded -= OnLoaded;
}
}
private static void OnLoaded(object sender, RoutedEventArgs e) => Report(sender as ListView);
private static void OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
var list = sender as ListView ?? FindAncestor<ListView>(e.OriginalSource as DependencyObject);
Report(list);
}
private static void Report(ListView? list)
{
if (list?.DataContext is not ExplorerPaneViewModel pane || list.Visibility != Visibility.Visible)
{
return;
}
var count = list.Items.Count;
if (count == 0)
{
pane.NotifyViewport([]);
return;
}
int first;
int lastExclusive;
if (FindWrapPanel(list) is { } wrap)
{
first = wrap.FirstVisibleIndex;
lastExclusive = Math.Max(first, wrap.LastVisibleIndex);
}
else
{
var viewer = FindScrollViewer(list);
int visible;
if (viewer is null || viewer.ExtentHeight <= 0)
{
first = 0;
visible = Math.Min(80, count);
}
else
{
first = (int)Math.Clamp(Math.Floor(viewer.VerticalOffset / viewer.ExtentHeight * count), 0, count - 1);
visible = Math.Max(8, (int)Math.Ceiling(viewer.ViewportHeight / viewer.ExtentHeight * count) + 8);
}
lastExclusive = Math.Min(count, first + visible);
}
lastExclusive = Math.Clamp(lastExclusive, 0, count);
first = Math.Clamp(first, 0, count);
var paths = new string[Math.Max(0, lastExclusive - first)];
for (var i = first; i < lastExclusive; i++)
{
if (list.Items[i] is FolderItemViewModel item)
{
paths[i - first] = item.FullPath;
}
}
pane.NotifyViewport(paths);
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer viewer)
{
return viewer;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var found = FindScrollViewer(VisualTreeHelper.GetChild(root, i));
if (found is not null)
{
return found;
}
}
return null;
}
private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root)
{
if (root is VirtualizingWrapPanel wrap)
{
return wrap;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var found = FindWrapPanel(VisualTreeHelper.GetChild(root, i));
if (found is not null)
{
return found;
}
}
return null;
}
private static T? FindAncestor<T>(DependencyObject? current)
where T : DependencyObject
{
while (current is not null)
{
if (current is T match)
{
return match;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
}

View File

@@ -56,16 +56,58 @@
<MenuItem Header="_Refresh" InputGestureText="F5" Command="{Binding RefreshCommand}"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
<MenuItem Header="_Storage" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
<Separator/>
<MenuItem Header="Add _network…" Click="OnAddNetwork"/>
<MenuItem Header="Add One_Drive…" Click="OnAddOneDrive"/>
<MenuItem Header="Add _Google Drive…" Click="OnAddGoogleDrive"/>
<MenuItem Header="Add Ne_xtcloud…" Click="OnAddNextcloud"/>
<MenuItem Header="_Storage">
<MenuItem Header="Storage _analysis" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
</MenuItem>
<MenuItem Header="_Locations">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
<MenuItem Header="Add _network…" Click="OnAddNetwork"/>
<MenuItem Header="Add _OneDrive…" Click="OnAddOneDrive"/>
<MenuItem Header="Add _Google Drive…" Click="OnAddGoogleDrive"/>
<MenuItem Header="Add Ne_xtcloud…" Click="OnAddNextcloud"/>
</MenuItem>
<MenuItem Header="_File Operations">
<MenuItem Header="_Batch rename…" Click="OnBatchRename"/>
<MenuItem Header="_Undo last rename batch" Command="{Binding UndoRenameBatchCommand}"
IsEnabled="{Binding CanUndoRenameBatch}"/>
<Separator/>
<MenuItem Header="_Archives">
<MenuItem Header="_Extract here" Click="OnExtractHere"
IsEnabled="{Binding ShowExtractArchive}"/>
<MenuItem Header="Extract _to…" Click="OnExtractTo"
IsEnabled="{Binding ShowExtractArchive}"/>
<MenuItem Header="Compress to _ZIP" Click="OnCompressZip"
IsEnabled="{Binding ShowCompress}"/>
<MenuItem Header="Compress to 7_z" Click="OnCompressSevenZip"
IsEnabled="{Binding ShowCompress}"/>
<MenuItem Header="_Add to archive…" Click="OnAddToArchive"
IsEnabled="{Binding ShowAddToArchive}"/>
<MenuItem Header="_Verify archive" Click="OnVerifyArchive"
IsEnabled="{Binding ShowVerifyArchive}"/>
</MenuItem>
<MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/>
</MenuItem>
<MenuItem Header="_Automation">
<MenuItem Header="_Folder sync…" Click="OnFolderSync"/>
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="Open _terminal here" Command="{Binding OpenTerminalCommand}"
IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
IsEnabled="{Binding ShowOpenInCursor}"/>
</MenuItem>
<MenuItem Header="_Recycle Bin">
<MenuItem Header="_Open Recycle Bin" Click="OnOpenRecycleBin"/>
<MenuItem Header="_Empty Recycle Bin" Click="OnEmptyRecycleBin"/>
</MenuItem>
</MenuItem>
<MenuItem Header="_Settings" Click="OnOpenSettings"/>
<MenuItem Header="_Help">
<MenuItem Header="_Documentation" InputGestureText="F1" Click="OnDocumentation"/>
<MenuItem Header="_About Explorer Workbench" Click="OnAbout"/>
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
<Grid>
@@ -146,6 +188,9 @@
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="{Binding ActivePane.GitBadge}" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}" Margin="0,0,16,0"
Visibility="{Binding ActivePane.HasGitBadge, Converter={StaticResource BoolVis}}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,12,0"
Visibility="{Binding Transfers.HasJobs, Converter={StaticResource BoolVis}}">
<ProgressBar Width="88" Height="6" Minimum="0" Maximum="1" VerticalAlignment="Center"
@@ -204,7 +249,7 @@
<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."
Text="Copy, move, delete, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}">
@@ -261,6 +306,10 @@
Command="{Binding DataContext.Transfers.ResumeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanResume, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Retry"
Command="{Binding DataContext.Transfers.RetryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanRetry, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Margin="0"
Content="{Binding Status, Converter={StaticResource TransferAction}}"
Command="{Binding DataContext.Transfers.RemoveCommand, RelativeSource={RelativeSource AncestorType=Window}}"
@@ -367,6 +416,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
@@ -386,15 +436,44 @@
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<MenuItem Header="Batch rename…" Click="OnBatchRename"
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Run profile" Tag="RunProfileMenu"
IsEnabled="{Binding ShowRunProfile}">
<MenuItem Header="No profiles yet" IsEnabled="False"/>
</MenuItem>
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract here" Click="OnExtractHere"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract to…" Click="OnExtractTo"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
Visibility="{Binding ShowVerifyArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding PinCloudCommand}"
@@ -423,6 +502,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
@@ -445,12 +525,17 @@
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}"
local:FolderViewport.IsTracked="True">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
<local:VirtualizingWrapPanel ItemWidth="120" ItemHeight="156"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
@@ -499,6 +584,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
@@ -528,6 +614,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
@@ -550,12 +637,17 @@
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}"
local:FolderViewport.IsTracked="True">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
<local:VirtualizingWrapPanel ItemWidth="120" ItemHeight="156"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
@@ -819,25 +911,65 @@
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="80" Padding="16">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseDuplicates"/>
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8"/>
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Show intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
<ListBox ItemsSource="{Binding Duplicates.Groups}" Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding Summary, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Border>

View File

@@ -15,6 +15,7 @@ namespace Explorer.App;
public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
@@ -46,6 +47,7 @@ public partial class MainWindow : Window
_wiredVm.InlineRenameRequested += OnInlineRenameRequested;
_wiredVm.PropertyChanged += OnViewModelPropertyChanged;
RestoreLayout(_wiredVm);
_ = _wiredVm.RefreshUndoRenameAsync();
}
};
Closing += (_, _) => PersistLayout();
@@ -274,6 +276,37 @@ public partial class MainWindow : Window
await Vm.ForgetSourceAsync(path).ConfigureAwait(true);
}
private async void OnOpenRecycleBin(object sender, RoutedEventArgs e)
=> await Vm.ActivePane.NavigateAsync(LocationRoots.RecycleBin).ConfigureAwait(true);
private async void OnEmptyRecycleBin(object sender, RoutedEventArgs e)
{
if (MessageBox.Show(
this,
"Empty Recycle Bin?\n\nItems will be permanently deleted. This is queued and can be cancelled until it starts.",
"Empty Recycle Bin",
MessageBoxButton.YesNo,
MessageBoxImage.Warning) != MessageBoxResult.Yes)
{
return;
}
await Vm.EmptyRecycleBinAsync().ConfigureAwait(true);
}
private async void OnImportWindowsLocation(object sender, RoutedEventArgs e)
{
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
await Vm.Tree.ReloadAsync(item.FullPath).ConfigureAwait(true);
Vm.Footer = "Added Windows location. Indexing is optional.";
}
private static IEnumerable<NavNodeViewModel> FlattenTree(NavNodeViewModel node)
{
yield return node;
@@ -584,6 +617,7 @@ public partial class MainWindow : Window
if (sender is FrameworkElement { ContextMenu: { } menu })
{
menu.DataContext = DataContext;
_ = FillRunProfileMenuAsync(menu);
}
}
@@ -1045,7 +1079,6 @@ public partial class MainWindow : Window
private void OnCloseSearch(object sender, RoutedEventArgs e) => Vm.Search.IsOpen = false;
private void OnCloseAnalysis(object sender, RoutedEventArgs e) => Vm.Analysis.Close();
private void OnCloseDuplicates(object sender, RoutedEventArgs e) => Vm.Duplicates.IsOpen = false;
private void OnBuildIndex(object sender, RoutedEventArgs e) => Vm.BuildIndexCommand.Execute(null);
@@ -1084,6 +1117,12 @@ public partial class MainWindow : Window
private void OnCtxRename(object sender, RoutedEventArgs e)
{
if (Vm.ActivePane.SelectedItems.Count >= 2)
{
OnBatchRename(sender, e);
return;
}
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is not null)
{
@@ -1091,6 +1130,143 @@ public partial class MainWindow : Window
}
}
private async void OnBatchRename(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateBatchRenameViewModel();
if (vm is null)
{
return;
}
var dlg = new BatchRenameWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Rename queued.";
await Vm.RefreshUndoRenameAsync().ConfigureAwait(true);
}
}
private async void OnExtractHere(object sender, RoutedEventArgs e)
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
private async void OnExtractTo(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Extract to",
Multiselect = false
};
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
{
return;
}
await Vm.ExtractSelectedAsync(picker.FolderName).ConfigureAwait(true);
}
private async void OnCompressZip(object sender, RoutedEventArgs e)
=> await Vm.CompressSelectedAsync(ArchiveFormat.Zip).ConfigureAwait(true);
private async void OnCompressSevenZip(object sender, RoutedEventArgs e)
=> await Vm.CompressSelectedAsync(ArchiveFormat.SevenZip).ConfigureAwait(true);
private async void OnAddToArchive(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Add to archive",
Filter = "Archives|*.zip;*.7z;*.zipx;*.cbz;*.cb7|All files|*.*",
CheckFileExists = true
};
if (dlg.ShowDialog(this) != true || string.IsNullOrWhiteSpace(dlg.FileName))
{
return;
}
await Vm.AddSelectedToArchiveAsync(dlg.FileName).ConfigureAwait(true);
}
private async void OnVerifyArchive(object sender, RoutedEventArgs e)
=> await Vm.VerifySelectedAsync().ConfigureAwait(true);
private async void OnFolderSync(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateFolderSyncViewModel();
await vm.LoadAsync().ConfigureAwait(true);
var dlg = new FolderSyncWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private async void OnOperationProfiles(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateOperationProfilesViewModel();
await vm.LoadAsync().ConfigureAwait(true);
var dlg = new OperationProfilesWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private void OnOrganizeFolder(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateReorganizeViewModel();
var dlg = new ReorganizeWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private async Task FillRunProfileMenuAsync(ContextMenu menu)
{
var host = menu.Items.OfType<MenuItem>().FirstOrDefault(i => Equals(i.Tag, "RunProfileMenu"));
if (host is null)
{
return;
}
host.Items.Clear();
IReadOnlyList<OperationProfile> profiles;
try
{
profiles = await Vm.ListOperationProfilesAsync().ConfigureAwait(true);
}
catch (Exception)
{
host.Items.Add(new MenuItem { Header = "Could not load profiles", IsEnabled = false });
return;
}
if (profiles.Count == 0)
{
host.Items.Add(new MenuItem { Header = "No profiles yet", IsEnabled = false });
return;
}
foreach (var profile in profiles)
{
var item = new MenuItem { Header = profile.Name, Tag = profile.Id };
item.Click += OnRunProfile;
host.Items.Add(item);
}
}
private async void OnRunProfile(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: long id })
{
return;
}
var sources = Vm.SelectedRealPaths();
if (sources.Count == 0)
{
Vm.Footer = "Select files or folders to run a profile.";
return;
}
var vm = Vm.CreateOperationProfilesViewModel();
await vm.LoadAsync().ConfigureAwait(true);
await vm.RunOnAsync(id, sources).ConfigureAwait(true);
var dlg = new OperationProfilesWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private void BeginInlineRenameForPath(string path)
{
var item = Vm.ActivePane.Items.FirstOrDefault(i => NavigationTreeViewModel.PathsEqual(i.FullPath, path));
@@ -1299,6 +1475,24 @@ public partial class MainWindow : Window
await Vm.RefreshForgetActionAsync().ConfigureAwait(true);
}
private void OnDocumentation(object sender, RoutedEventArgs e) => ShowDocumentation();
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private void ShowDocumentation()
{
if (_docs is { IsVisible: true })
{
_docs.Activate();
return;
}
_docs = new DocumentationWindow { Owner = this };
_docs.Closed += (_, _) => _docs = null;
_docs.Show();
}
private void OnAddNetwork(object sender, RoutedEventArgs e)
{
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
@@ -1350,6 +1544,13 @@ public partial class MainWindow : Window
return;
}
if (e.Key == Key.F1)
{
ShowDocumentation();
e.Handled = true;
return;
}
if (e.Key == Key.F5)
{
await Vm.RefreshAsync().ConfigureAwait(true);

View File

@@ -0,0 +1,290 @@
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using Explorer.Application;
namespace Explorer.App;
public sealed record DocumentationBrushes(
Brush Foreground,
Brush Muted,
Brush Accent,
Brush Panel,
Brush Stroke,
Brush CodeBackground);
public static class MarkdownFlowConverter
{
public static FlowDocument ToFlowDocument(MarkdownDocument markdown, DocumentationBrushes brushes)
{
var doc = new FlowDocument
{
FontFamily = new FontFamily("Segoe UI"),
FontSize = 14,
Foreground = brushes.Foreground,
PagePadding = new Thickness(20),
TextAlignment = TextAlignment.Left
};
foreach (var block in markdown.Blocks)
{
switch (block)
{
case MarkdownHeading heading:
doc.Blocks.Add(Heading(heading, brushes));
break;
case MarkdownParagraph paragraph:
doc.Blocks.Add(Paragraph(paragraph.Text, brushes, 0, 12));
break;
case MarkdownQuote quote:
var q = Paragraph(quote.Text, brushes, 16, 12);
q.Foreground = brushes.Muted;
q.FontStyle = FontStyles.Italic;
q.BorderBrush = brushes.Accent;
q.BorderThickness = new Thickness(3, 0, 0, 0);
q.Padding = new Thickness(12, 0, 0, 0);
doc.Blocks.Add(q);
break;
case MarkdownList list:
doc.Blocks.Add(ListBlock(list, brushes));
break;
case MarkdownCode code:
doc.Blocks.Add(CodeBlock(code, brushes));
break;
case MarkdownTable table:
doc.Blocks.Add(TableBlock(table, brushes));
break;
case MarkdownRule:
doc.Blocks.Add(new BlockUIContainer(new System.Windows.Controls.Border
{
Height = 1,
Background = brushes.Stroke,
Margin = new Thickness(0, 12, 0, 16)
}));
break;
}
}
return doc;
}
private static Paragraph Heading(MarkdownHeading heading, DocumentationBrushes brushes)
{
var size = heading.Level switch
{
1 => 26d,
2 => 20d,
3 => 16d,
_ => 14d
};
var p = Paragraph(heading.Text, brushes, 0, heading.Level == 1 ? 16 : 10);
p.FontSize = size;
p.FontWeight = FontWeights.SemiBold;
p.Foreground = heading.Level <= 2 ? brushes.Accent : brushes.Foreground;
p.Margin = new Thickness(0, heading.Level == 1 ? 0 : 18, 0, 8);
p.Tag = heading.Id;
return p;
}
private static List ListBlock(MarkdownList list, DocumentationBrushes brushes)
{
var result = new List
{
MarkerStyle = list.Ordered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc,
Margin = new Thickness(8, 0, 0, 12),
Padding = new Thickness(16, 0, 0, 0)
};
foreach (var item in list.Items)
{
var p = Paragraph(item, brushes, 0, 4);
result.ListItems.Add(new ListItem(p));
}
return result;
}
private static Paragraph CodeBlock(MarkdownCode code, DocumentationBrushes brushes)
{
var p = new Paragraph
{
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Background = brushes.CodeBackground,
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(0, 4, 0, 14),
Foreground = brushes.Foreground
};
p.Inlines.Add(new Run(code.Text));
return p;
}
private static Table TableBlock(MarkdownTable table, DocumentationBrushes brushes)
{
var columns = table.Headers.Count;
foreach (var row in table.Rows)
{
columns = Math.Max(columns, row.Count);
}
var result = new Table
{
CellSpacing = 0,
BorderBrush = brushes.Stroke,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 4, 0, 16)
};
for (var c = 0; c < columns; c++)
{
result.Columns.Add(new TableColumn());
}
var group = new TableRowGroup();
group.Rows.Add(Cells(table.Headers, columns, brushes, header: true));
foreach (var row in table.Rows)
{
group.Rows.Add(Cells(row, columns, brushes, header: false));
}
result.RowGroups.Add(group);
return result;
}
private static TableRow Cells(IReadOnlyList<string> values, int columns, DocumentationBrushes brushes, bool header)
{
var row = new TableRow { Background = header ? brushes.Panel : Brushes.Transparent };
for (var i = 0; i < columns; i++)
{
var text = i < values.Count ? values[i] : "";
var p = Paragraph(text, brushes, 0, 0);
p.FontWeight = header ? FontWeights.SemiBold : FontWeights.Normal;
p.Margin = new Thickness(0);
row.Cells.Add(new TableCell(p)
{
Padding = new Thickness(8, 6, 8, 6),
BorderBrush = brushes.Stroke,
BorderThickness = new Thickness(0, 0, 1, 1)
});
}
return row;
}
private static Paragraph Paragraph(string text, DocumentationBrushes brushes, double indent, double bottom)
{
var p = new Paragraph
{
Margin = new Thickness(indent, 0, 0, bottom),
LineHeight = 22
};
AddInlines(p.Inlines, text, brushes);
return p;
}
private static void AddInlines(InlineCollection inlines, string text, DocumentationBrushes brushes)
{
var i = 0;
while (i < text.Length)
{
var tick = text.IndexOf('`', i);
var bold = text.IndexOf("**", i, StringComparison.Ordinal);
var link = text.IndexOf('[', i);
var next = MinPositive(tick, bold, link);
if (next < 0)
{
inlines.Add(new Run(text[i..]));
return;
}
if (next > i)
{
inlines.Add(new Run(text[i..next]));
}
if (next == tick)
{
var end = text.IndexOf('`', tick + 1);
if (end < 0)
{
inlines.Add(new Run(text[tick..]));
return;
}
inlines.Add(new Run(text[(tick + 1)..end])
{
FontFamily = new FontFamily("Consolas"),
Background = brushes.CodeBackground
});
i = end + 1;
continue;
}
if (next == bold)
{
var end = text.IndexOf("**", bold + 2, StringComparison.Ordinal);
if (end < 0)
{
inlines.Add(new Run(text[bold..]));
return;
}
inlines.Add(new Run(text[(bold + 2)..end]) { FontWeight = FontWeights.SemiBold });
i = end + 2;
continue;
}
var close = text.IndexOf("](", next, StringComparison.Ordinal);
var urlEnd = close > next ? text.IndexOf(')', close + 2) : -1;
if (close < 0 || urlEnd < 0)
{
inlines.Add(new Run(text[next].ToString()));
i = next + 1;
continue;
}
var label = text[(next + 1)..close];
var url = text[(close + 2)..urlEnd];
var hyper = new Hyperlink(new Run(label))
{
Foreground = brushes.Accent,
NavigateUri = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null,
ToolTip = url
};
hyper.RequestNavigate += (_, e) =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(e.Uri.AbsoluteUri)
{
UseShellExecute = true
});
}
catch
{
// ignore
}
e.Handled = true;
};
inlines.Add(hyper);
i = urlEnd + 1;
}
}
private static int MinPositive(params int[] values)
{
var min = -1;
foreach (var v in values)
{
if (v < 0)
{
continue;
}
if (min < 0 || v < min)
{
min = v;
}
}
return min;
}
}

View File

@@ -0,0 +1,108 @@
<Window x:Class="Explorer.App.OperationProfilesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Operation profiles"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="740" Width="980"
MinHeight="560" MinWidth="780"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" TextWrapping="Wrap" FontSize="12" Margin="0,8,0,0"
Foreground="{DynamicResource FgMuted}"
Text="Drop files onto a profile to preview with those items."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="New" MinWidth="56" Height="28" Command="{Binding NewProfileCommand}" Margin="0,0,8,0"/>
<Button Content="Duplicate" MinWidth="72" Height="28" Command="{Binding DuplicateCommand}" Margin="0,0,8,0"/>
<Button Content="Delete" MinWidth="56" Height="28" Command="{Binding DeleteCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding Profiles}" SelectedItem="{Binding Selected}"
DisplayMemberPath="Name"
AllowDrop="True"
DragOver="OnProfileDragOver"
Drop="OnProfileDrop"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Source" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<CheckBox Content="Require a clean Git working tree" IsChecked="{Binding RequireGitClean}" Margin="0,0,0,8"/>
<CheckBox Content="Rename" IsChecked="{Binding DoRename}" Margin="0,0,0,8"/>
<TextBlock Text="Prefix / suffix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"
IsEnabled="{Binding DoRename}"/>
<Grid Margin="0,0,0,8" IsEnabled="{Binding DoRename}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding RenamePrefix, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Column="2" Text="{Binding RenameSuffix, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<TextBlock Text="Search / replace" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"
IsEnabled="{Binding DoRename}"/>
<Grid Margin="0,0,0,8" IsEnabled="{Binding DoRename}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding RenameSearch, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Column="2" Text="{Binding RenameReplace, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<CheckBox Content="Compress" IsChecked="{Binding DoCompress}" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format"
SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8"
IsEnabled="{Binding CompressOptionsEnabled}"/>
<CheckBox Content="Copy to destination" IsChecked="{Binding DoCopy}" Margin="0,0,0,8"/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Auto-run is Copy only — not Rename or Compress. Drive letters can change; the volume identity is stored."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>
<Button Content="Save profile" Margin="0,12,0,0" Height="28" Command="{Binding SaveCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="4" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Action" Width="90" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Path" Width="240" DisplayMemberBinding="{Binding Path}"/>
<GridViewColumn Header="Detail" Width="140" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,84 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Explorer.Domain;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class OperationProfilesWindow : Window
{
public OperationProfilesWindow(OperationProfilesViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e)
{
if (PickFolder("Source folder") is { } path && DataContext is OperationProfilesViewModel vm)
{
vm.SourcePath = path;
}
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
if (PickFolder("Destination folder") is { } path && DataContext is OperationProfilesViewModel vm)
{
vm.DestPath = path;
}
}
private void OnProfileDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private async void OnProfileDrop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)
|| e.Data.GetData(DataFormats.FileDrop) is not string[] files
|| files.Length == 0
|| DataContext is not OperationProfilesViewModel vm
|| sender is not ListBox list)
{
return;
}
var profile = HitProfile(list, e.GetPosition(list)) ?? vm.Selected;
if (profile is null || profile.Id <= 0)
{
vm.Status = "Drop onto a saved profile.";
return;
}
await vm.RunOnAsync(profile.Id, files).ConfigureAwait(true);
}
private static OperationProfile? HitProfile(ListBox list, Point position)
{
if (list.InputHitTest(position) is not DependencyObject hit)
{
return null;
}
while (hit is not null && hit is not ListBoxItem)
{
hit = VisualTreeHelper.GetParent(hit);
}
return (hit as ListBoxItem)?.DataContext as OperationProfile;
}
private string? PickFolder(string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
return picker.ShowDialog(this) == true ? picker.FolderName : null;
}
}

View File

@@ -0,0 +1,86 @@
<Window x:Class="Explorer.App.ReorganizeWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Organize folder"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="980"
MinHeight="560" MinWidth="780"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="340"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Classification suggests moves. Unknown, system, backup, and build folders stay put. Nothing is moved until you Queue."/>
<TextBlock Text="Folder to organize" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Pictures" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowsePictures" Margin="8,0,0,0"/>
<TextBox Text="{Binding PicturesPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Videos" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseVideos" Margin="8,0,0,0"/>
<TextBox Text="{Binding VideosPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Audio" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseAudio" Margin="8,0,0,0"/>
<TextBox Text="{Binding AudioPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Documents" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDocuments" Margin="8,0,0,0"/>
<TextBox Text="{Binding DocumentsPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Installers / Software" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseInstallers" Margin="8,0,0,0"/>
<TextBox Text="{Binding InstallersPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Archives" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseArchives" Margin="8,0,0,0"/>
<TextBox Text="{Binding ArchivesPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Development" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDevelopment" Margin="8,0,0,0"/>
<TextBox Text="{Binding DevelopmentPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<Button Content="Save destinations" Height="28" Command="{Binding SaveDestinationsCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="160" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="Category" Width="110" DisplayMemberBinding="{Binding Category}"/>
<GridViewColumn Header="Action" Width="70" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Destination" Width="200" DisplayMemberBinding="{Binding Destination}"/>
<GridViewColumn Header="Detail" Width="180" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,37 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class ReorganizeWindow : Window
{
public ReorganizeWindow(ReorganizeViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e) => Browse(path => vm().SourcePath = path, "Folder to organize");
private void OnBrowsePictures(object sender, RoutedEventArgs e) => Browse(path => vm().PicturesPath = path, "Pictures folder");
private void OnBrowseVideos(object sender, RoutedEventArgs e) => Browse(path => vm().VideosPath = path, "Videos folder");
private void OnBrowseAudio(object sender, RoutedEventArgs e) => Browse(path => vm().AudioPath = path, "Audio folder");
private void OnBrowseDocuments(object sender, RoutedEventArgs e) => Browse(path => vm().DocumentsPath = path, "Documents folder");
private void OnBrowseInstallers(object sender, RoutedEventArgs e) => Browse(path => vm().InstallersPath = path, "Installers folder");
private void OnBrowseArchives(object sender, RoutedEventArgs e) => Browse(path => vm().ArchivesPath = path, "Archives folder");
private void OnBrowseDevelopment(object sender, RoutedEventArgs e) => Browse(path => vm().DevelopmentPath = path, "Development folder");
private ReorganizeViewModel vm() => (ReorganizeViewModel)DataContext;
private void Browse(Action<string> assign, string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
if (picker.ShowDialog(this) == true && !string.IsNullOrWhiteSpace(picker.FolderName))
{
assign(picker.FolderName);
}
}
}

View File

@@ -54,13 +54,29 @@
<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."/>
Text="Finished copy, move, delete, and rename steps are removed automatically. Failed items stay until you dismiss or retry them. The queue is restored after restart."/>
<TextBlock Text="Indexing" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,0" FontSize="12"
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,18">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseSevenZip" Margin="8,0,0,0"/>
<TextBox x:Name="SevenZipPath"/>
</DockPanel>
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Repository badges use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. Explorer Workbench does not commit, push, or pull."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/>
</DockPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>

View File

@@ -23,6 +23,8 @@ public partial class SettingsWindow : Window
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? "";
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
@@ -45,13 +47,43 @@ public partial class SettingsWindow : Window
IndexArchiveContents = IndexArchives.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
};
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
DialogResult = true;
Close();
}
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "7-Zip executable",
Filter = "7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
FileName = SevenZipPath.Text
};
if (dlg.ShowDialog(this) == true)
{
SevenZipPath.Text = dlg.FileName;
}
}
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Git executable",
Filter = "Git|git.exe|Executables|*.exe|All files|*.*",
FileName = GitPath.Text
};
if (dlg.ShowDialog(this) == true)
{
GitPath.Text = dlg.FileName;
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_vm.Theme = _originalTheme;

View File

@@ -47,6 +47,11 @@ internal static class ShellIconCache
return isDirectory ? "dir:generic" : "file:generic";
}
if (path == LocationRoots.RecycleBin)
{
return "recycle";
}
if (isDirectory)
{
return PathRules.IsDriveRoot(path) || (path.Length <= 3 && path.Contains(':', StringComparison.Ordinal))
@@ -79,6 +84,10 @@ internal static class ShellIconCache
psz = isDirectory ? "folder" : "file";
flags |= ShgfiUseFileAttributes;
}
else if (path == LocationRoots.RecycleBin)
{
psz = @"::{645FF040-5081-101B-9F08-00AA002F954E}";
}
else if (isDirectory && PathRules.IsDriveRoot(path))
{
psz = PathRules.EnsureDirectoryTrailingSlashIfRoot(path);

View File

@@ -0,0 +1,335 @@
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App;
public sealed class ThumbnailService : BackgroundService, IThumbnailService
{
public const int CacheCapacity = 384;
private readonly ThumbnailScheduler _scheduler = new();
private readonly LruCache<ThumbnailKey, object?> _bitmaps = new(CacheCapacity);
private readonly ILogger<ThumbnailService> _logger;
private readonly SemaphoreSlim _ready = new(0, 1);
private readonly List<WeakReference<ExplorerPaneViewModel>> _panes = [];
private readonly object _panesGate = new();
private Dispatcher? _dispatcher;
public ThumbnailService(ILogger<ThumbnailService> logger) => _logger = logger;
public ThumbnailSchedulerStats Stats => _scheduler.Snapshot();
public void OnSessionChanged(ExplorerPaneViewModel pane, int generation)
{
Remember(pane);
_scheduler.Reset(generation);
Pulse();
_logger.LogDebug("Thumbnail session {Generation} for {Path}", generation, pane.CurrentPath);
}
public void OnPreviewEnabledChanged(ExplorerPaneViewModel pane)
{
Remember(pane);
var enabled = pane.ViewMode == FolderViewMode.Preview;
_scheduler.SetEnabled(enabled);
if (!enabled)
{
_logger.LogDebug("Thumbnail pipeline paused; Preview disabled");
return;
}
OnViewportChanged(pane, []);
}
public void OnViewportChanged(ExplorerPaneViewModel pane, IReadOnlyList<FolderItemViewModel> visible)
{
Remember(pane);
_dispatcher ??= System.Windows.Application.Current?.Dispatcher;
var enabled = pane.ViewMode == FolderViewMode.Preview;
_scheduler.SetEnabled(enabled);
if (!enabled)
{
return;
}
var generation = pane.BrowseGeneration;
var items = pane.SnapshotItems();
var visibleSet = new HashSet<string>(visible.Select(v => v.FullPath), StringComparer.OrdinalIgnoreCase);
var first = 0;
var last = 0;
if (visible.Count > 0)
{
first = IndexOf(items, visible[0].FullPath);
last = IndexOf(items, visible[^1].FullPath);
if (first < 0 || last < 0)
{
first = 0;
last = Math.Min(items.Count, visible.Count) - 1;
}
if (last < first)
{
(first, last) = (last, first);
}
}
else if (items.Count > 0)
{
last = Math.Min(items.Count, 24) - 1;
}
var prefetchStart = Math.Max(0, first - ThumbnailScheduler.PrefetchItems);
var prefetchEnd = Math.Min(items.Count, last + 1 + ThumbnailScheduler.PrefetchItems);
var jobs = new List<ThumbnailJob>(prefetchEnd - prefetchStart);
for (var i = prefetchStart; i < prefetchEnd; i++)
{
var item = items[i];
if (!item.IsImage || item.MayHydrateOnRead)
{
continue;
}
var key = ThumbnailKey.From(item.FullPath, item.Item.ModifiedUtc, ThumbnailScheduler.DefaultPixelWidth);
var rank = i >= first && i <= last ? 0 : 1;
if (_bitmaps.TryGet(key, out var cached))
{
_scheduler.MarkCached(key);
_scheduler.RecordCacheHit();
if (cached is not null && item.Thumbnail is null)
{
item.SetThumbnail(cached);
}
continue;
}
jobs.Add(new ThumbnailJob(key, rank, generation));
}
var queued = _scheduler.SetWindow(generation, jobs);
_logger.LogDebug(
"Thumbnail window gen={Generation} visible={Visible} queued={Queued} {Stats}",
generation,
visibleSet.Count,
queued.Count,
_scheduler.Snapshot());
Pulse();
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var threads = new Thread[ThumbnailScheduler.DefaultConcurrency];
stoppingToken.Register(Pulse);
stoppingToken.Register(Pulse);
for (var i = 0; i < threads.Length; i++)
{
var thread = new Thread(() => WorkerLoop(stoppingToken))
{
IsBackground = true,
Name = $"Explorer-Thumbnail-{i}"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
threads[i] = thread;
}
return Task.Run(() =>
{
foreach (var thread in threads)
{
thread.Join();
}
}, CancellationToken.None);
}
private void WorkerLoop(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
_ready.Wait(stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
while (_scheduler.TryTake(out var job))
{
Process(job);
}
}
}
private void Process(ThumbnailJob job)
{
if (!_scheduler.IsCurrent(job))
{
_scheduler.Complete(job, generated: false, failed: false);
return;
}
if (_bitmaps.TryGet(job.Key, out var cached))
{
_scheduler.RecordCacheHit();
_scheduler.Complete(job, generated: false, failed: false);
Apply(job, cached);
return;
}
var started = Environment.TickCount64;
object? bitmap = null;
var failed = false;
try
{
bitmap = Decode(job.Key);
}
catch (Exception ex)
{
failed = true;
_logger.LogDebug(ex, "Thumbnail decode failed for {Path}", job.Key.Path);
}
_bitmaps.Set(job.Key, bitmap);
_scheduler.Complete(job, generated: bitmap is not null, failed);
_logger.LogDebug(
"Thumbnail {Result} path={Path} ms={Ms} {Stats}",
failed ? "failed" : bitmap is null ? "empty" : "generated",
job.Key.Path,
Environment.TickCount64 - started,
_scheduler.Snapshot());
if (bitmap is not null && _scheduler.IsCurrent(job))
{
Apply(job, bitmap);
}
Pulse();
}
private void Apply(ThumbnailJob job, object? bitmap)
{
if (bitmap is null)
{
return;
}
var dispatcher = _dispatcher ?? System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(() =>
{
if (!_scheduler.IsCurrent(job))
{
return;
}
ExplorerPaneViewModel? pane = null;
lock (_panesGate)
{
foreach (var entry in _panes)
{
if (entry.TryGetTarget(out var candidate) && candidate.BrowseGeneration == job.Generation)
{
pane = candidate;
break;
}
}
}
if (pane is null)
{
return;
}
foreach (var item in pane.SnapshotItems())
{
if (string.Equals(item.FullPath, job.Key.Path, StringComparison.OrdinalIgnoreCase))
{
item.SetThumbnail(bitmap);
break;
}
}
}, DispatcherPriority.Background);
}
private static object? Decode(ThumbnailKey key)
{
if (!File.Exists(key.Path))
{
return null;
}
using var stream = new FileStream(
key.Path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
64 * 1024,
FileOptions.SequentialScan);
var image = new BitmapImage();
image.BeginInit();
image.StreamSource = stream;
image.CacheOption = BitmapCacheOption.OnLoad;
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
image.DecodePixelWidth = key.PixelWidth;
image.EndInit();
image.Freeze();
return image;
}
private void Remember(ExplorerPaneViewModel pane)
{
lock (_panesGate)
{
for (var i = _panes.Count - 1; i >= 0; i--)
{
if (!_panes[i].TryGetTarget(out var existing) || existing == pane)
{
if (existing == pane)
{
return;
}
_panes.RemoveAt(i);
}
}
_panes.Add(new WeakReference<ExplorerPaneViewModel>(pane));
}
}
private void Pulse()
{
try
{
_ready.Release();
}
catch (SemaphoreFullException)
{
}
}
private static int IndexOf(IReadOnlyList<FolderItemViewModel> items, string path)
{
for (var i = 0; i < items.Count; i++)
{
if (string.Equals(items[i].FullPath, path, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,237 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
namespace Explorer.App;
public sealed class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
{
public static readonly DependencyProperty ItemWidthProperty =
DependencyProperty.Register(
nameof(ItemWidth),
typeof(double),
typeof(VirtualizingWrapPanel),
new FrameworkPropertyMetadata(120d, FrameworkPropertyMetadataOptions.AffectsMeasure));
public static readonly DependencyProperty ItemHeightProperty =
DependencyProperty.Register(
nameof(ItemHeight),
typeof(double),
typeof(VirtualizingWrapPanel),
new FrameworkPropertyMetadata(148d, FrameworkPropertyMetadataOptions.AffectsMeasure));
private Size _extent;
private Size _viewport;
private Point _offset;
private int _columns = 1;
private int _firstVisible;
private int _lastVisible;
public double ItemWidth
{
get => (double)GetValue(ItemWidthProperty);
set => SetValue(ItemWidthProperty, value);
}
public double ItemHeight
{
get => (double)GetValue(ItemHeightProperty);
set => SetValue(ItemHeightProperty, value);
}
public int FirstVisibleIndex => _firstVisible;
public int LastVisibleIndex => _lastVisible;
public ScrollViewer? ScrollOwner { get; set; }
public bool CanVerticallyScroll { get; set; } = true;
public bool CanHorizontallyScroll { get; set; }
public double ExtentWidth => _extent.Width;
public double ExtentHeight => _extent.Height;
public double ViewportWidth => _viewport.Width;
public double ViewportHeight => _viewport.Height;
public double HorizontalOffset => _offset.X;
public double VerticalOffset => _offset.Y;
protected override Size MeasureOverride(Size availableSize)
{
EnsureGenerator();
var items = ItemsControl.GetItemsOwner(this);
var count = items?.Items.Count ?? 0;
var itemWidth = Math.Max(1, ItemWidth);
var itemHeight = Math.Max(1, ItemHeight);
var viewportWidth = double.IsInfinity(availableSize.Width) ? itemWidth : Math.Max(itemWidth, availableSize.Width);
var viewportHeight = double.IsInfinity(availableSize.Height) ? itemHeight : Math.Max(0, availableSize.Height);
_columns = Math.Max(1, (int)(viewportWidth / itemWidth));
var rows = count == 0 ? 0 : (int)Math.Ceiling(count / (double)_columns);
_extent = new Size(viewportWidth, rows * itemHeight);
_viewport = new Size(viewportWidth, viewportHeight);
_offset.Y = Math.Clamp(_offset.Y, 0, Math.Max(0, _extent.Height - _viewport.Height));
var firstRow = (int)(_offset.Y / itemHeight);
var visibleRows = Math.Max(1, (int)Math.Ceiling(_viewport.Height / itemHeight) + 1);
_firstVisible = Math.Min(count, firstRow * _columns);
_lastVisible = Math.Min(count, _firstVisible + visibleRows * _columns);
GenerateChildren(_firstVisible, _lastVisible, new Size(itemWidth, itemHeight));
ScrollOwner?.InvalidateScrollInfo();
return _viewport;
}
protected override Size ArrangeOverride(Size finalSize)
{
var itemWidth = Math.Max(1, ItemWidth);
var itemHeight = Math.Max(1, ItemHeight);
foreach (UIElement child in InternalChildren)
{
var index = ContainerIndex(child);
if (index < 0)
{
child.Arrange(new Rect());
continue;
}
var column = index % _columns;
var row = index / _columns;
var x = column * itemWidth;
var y = row * itemHeight - _offset.Y;
child.Arrange(new Rect(x, y, itemWidth, itemHeight));
}
return finalSize;
}
protected override void OnItemsChanged(object sender, ItemsChangedEventArgs args)
{
switch (args.Action)
{
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
case System.Collections.Specialized.NotifyCollectionChangedAction.Move:
RemoveInternalChildRange(args.Position.Index, args.ItemUICount);
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
RemoveInternalChildRange(0, InternalChildren.Count);
break;
}
InvalidateMeasure();
base.OnItemsChanged(sender, args);
}
protected override void BringIndexIntoView(int index)
{
var itemHeight = Math.Max(1, ItemHeight);
var row = index / Math.Max(1, _columns);
SetVerticalOffset(row * itemHeight);
}
public void LineUp() => SetVerticalOffset(VerticalOffset - ItemHeight);
public void LineDown() => SetVerticalOffset(VerticalOffset + ItemHeight);
public void LineLeft() { }
public void LineRight() { }
public void PageUp() => SetVerticalOffset(VerticalOffset - ViewportHeight);
public void PageDown() => SetVerticalOffset(VerticalOffset + ViewportHeight);
public void PageLeft() { }
public void PageRight() { }
public void MouseWheelUp() => SetVerticalOffset(VerticalOffset - ItemHeight * SystemParameters.WheelScrollLines);
public void MouseWheelDown() => SetVerticalOffset(VerticalOffset + ItemHeight * SystemParameters.WheelScrollLines);
public void MouseWheelLeft() { }
public void MouseWheelRight() { }
public void SetHorizontalOffset(double offset) { }
public void SetVerticalOffset(double offset)
{
_offset.Y = Math.Clamp(offset, 0, Math.Max(0, _extent.Height - _viewport.Height));
InvalidateMeasure();
ScrollOwner?.InvalidateScrollInfo();
}
public Rect MakeVisible(Visual visual, Rect rectangle)
{
var index = ContainerIndex(visual);
if (index >= 0)
{
BringIndexIntoView(index);
}
return rectangle;
}
private void GenerateChildren(int first, int last, Size childSize)
{
var generator = (IRecyclingItemContainerGenerator)ItemContainerGenerator;
CleanUp(first, last, generator);
if (first >= last)
{
return;
}
var position = generator.GeneratorPositionFromIndex(first);
var childIndex = position.Offset == 0 ? position.Index : position.Index + 1;
if (childIndex < 0)
{
childIndex = 0;
}
using (generator.StartAt(position, GeneratorDirection.Forward, true))
{
for (var i = first; i < last; i++, childIndex++)
{
var child = (UIElement)generator.GenerateNext(out var newlyRealized);
if (newlyRealized)
{
if (childIndex >= InternalChildren.Count)
{
AddInternalChild(child);
}
else
{
InsertInternalChild(childIndex, child);
}
}
generator.PrepareItemContainer(child);
child.Measure(childSize);
}
}
}
private void CleanUp(int first, int last, IRecyclingItemContainerGenerator generator)
{
for (var i = InternalChildren.Count - 1; i >= 0; i--)
{
var index = ContainerIndex(InternalChildren[i]);
if (index >= first && index < last)
{
continue;
}
if (index >= 0)
{
var pos = generator.GeneratorPositionFromIndex(index);
RemoveInternalChildRange(i, 1);
generator.Recycle(pos, 1);
}
else
{
RemoveInternalChildRange(i, 1);
}
}
}
private int ContainerIndex(DependencyObject container)
=> ItemContainerGenerator is ItemContainerGenerator generator
? generator.IndexFromContainer(container)
: -1;
private void EnsureGenerator()
{
if (ItemContainerGenerator is not null)
{
return;
}
var owner = ItemsControl.GetItemsOwner(this);
owner?.ApplyTemplate();
}
}

View File

@@ -0,0 +1,65 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class BrowseHydration
{
public const int LocalWorkers = 8;
public const int ConstrainedWorkers = 2;
public const int LocalProviderBatch = 64;
public const int ConstrainedProviderBatch = 16;
public const int PublishBatch = 48;
public const int NearbyWindow = 32;
public static int WorkerCount(SourceKind? kind, bool constrained)
=> kind is SourceKind.Smb or SourceKind.Nfs or SourceKind.Cloud || constrained
? ConstrainedWorkers
: LocalWorkers;
public static int ProviderBatchSize(SourceKind? kind, bool constrained)
=> kind is SourceKind.Smb or SourceKind.Nfs or SourceKind.Cloud || constrained
? ConstrainedProviderBatch
: LocalProviderBatch;
public static List<FileSystemItem> Prioritize(
IReadOnlyList<FileSystemItem> pending,
IReadOnlyList<string> visiblePaths)
{
if (pending.Count == 0)
{
return [];
}
if (visiblePaths.Count == 0)
{
return pending.ToList();
}
var visible = new HashSet<string>(visiblePaths, StringComparer.OrdinalIgnoreCase);
var nearby = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < pending.Count; i++)
{
if (!visible.Contains(pending[i].FullPath))
{
continue;
}
var from = Math.Max(0, i - NearbyWindow);
var to = Math.Min(pending.Count, i + NearbyWindow + 1);
for (var j = from; j < to; j++)
{
nearby.Add(pending[j].FullPath);
}
}
return pending
.Select((item, index) => (
item,
rank: visible.Contains(item.FullPath) ? 0 : nearby.Contains(item.FullPath) ? 1 : 2,
index))
.OrderBy(x => x.rank)
.ThenBy(x => x.index)
.Select(x => x.item)
.ToList();
}
}

View File

@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -40,35 +42,70 @@ public sealed class BrowseService
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
{
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
return await ListSourcesAsync(
var listing = await ListSourcesAsync(
LocationRoots.ThisPc,
source => !groupNetwork || !source.Kind.IsNetwork(),
cancellationToken).ConfigureAwait(false);
var items = listing.Items.ToList();
if (!groupNetwork)
{
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
}
items.Add(RecycleBinItem());
return new FolderListing { Path = listing.Path, Items = items };
}
public Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
=> ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken);
public Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
{
var items = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(place =>
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
.ConfigureAwait(false);
var items = listing.Items.ToList();
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
return new FolderListing { Path = listing.Path, Items = items };
}
public async Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
{
var items = new List<FileSystemItem>();
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
var exists = Directory.Exists(place.Path);
var space = exists ? _volumes.GetSpace(place.Path) : default;
var quota = await _providers.TryGetQuotaAsync(place.Path, cancellationToken).ConfigureAwait(false);
var capacity = quota?.TotalBytes ?? space.CapacityBytes;
long? free = quota is { TotalBytes: long total, UsedBytes: long used }
? Math.Max(0, total - used)
: space.FreeBytes;
items.Add(new FileSystemItem
{
var exists = Directory.Exists(place.Path);
var space = exists ? _volumes.GetSpace(place.Path) : default;
return new FileSystemItem
{
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = space.FreeBytes,
CapacityBytes = space.CapacityBytes
};
})
.ToList();
return Task.FromResult(new FolderListing { Path = LocationRoots.Cloud, Items = items });
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = free,
CapacityBytes = capacity
});
}
return new FolderListing { Path = LocationRoots.Cloud, Items = items };
}
public FolderListing ListRecycleBin(string? path = null)
{
var root = string.IsNullOrWhiteSpace(path) ? LocationRoots.RecycleBin : path;
var summary = _recycle?.TrySummarize(root);
var hint = summary is null
? "Recycle Bin contents are managed by Windows."
: $"{summary.ItemCount:N0} deleted items · {FormatBytes(summary.UsedBytes)} used";
return new FolderListing
{
Path = LocationRoots.RecycleBin,
IsOffline = false,
Items = [RecycleBinItem(summary)],
Error = hint
};
}
private async Task<FolderListing> ListSourcesAsync(
@@ -109,6 +146,31 @@ public sealed class BrowseService
}
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
{
var items = new List<FileSystemItem>();
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
string? error = null;
var offline = false;
var listingPath = path;
await foreach (var delta in ListProgressiveAsync(path, viewport: null, cancellationToken).ConfigureAwait(false))
{
listingPath = delta.Path;
offline = delta.IsOffline;
if (delta.Error is not null)
{
error = delta.Error;
}
ApplyDelta(items, byPath, delta);
}
return new FolderListing { Path = listingPath, IsOffline = offline, Items = items, Error = error };
}
public async IAsyncEnumerable<BrowseDelta> ListProgressiveAsync(
string path,
BrowseViewport? viewport = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
@@ -116,69 +178,472 @@ public sealed class BrowseService
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
return archiveListing;
yield return CompleteDelta(archiveListing);
yield break;
}
}
if (IsRecycleBinPath(path))
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
{
return ListRecycleBin(path);
yield return CompleteDelta(ListRecycleBin(path));
yield break;
}
var reachable = _volumes.IsPathReachable(path);
if (reachable)
if (!reachable)
{
var items = _enumerator.EnumerateChildrenSafe(path, out var error);
var listing = (await _providers.EnrichAsync(items.ToList(), cancellationToken).ConfigureAwait(false)).ToList();
if (source is { IsIndexed: true })
{
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
}
return FinishListing(path, AttachVolumeSpace(listing), isOffline: false, error);
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
yield break;
}
if (source is { IsIndexed: true })
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
{
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
if (dir is null)
{
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
}
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
.ConfigureAwait(false);
var items = children
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
.Select(c => new FileSystemItem
{
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, c.PathRel),
Name = c.Name,
IsDirectory = c.IsDirectory,
SizeBytes = c.IsDirectory ? c.AggregateSize : c.SizeBytes,
CreatedUtc = c.CreatedUtc,
ModifiedUtc = c.ModifiedUtc,
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes,
Cloud = c.CloudAvailability is { } availability
? new CloudPresence(null, availability, c.SizeBytes, c.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null
})
.ToList();
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
yield return delta;
}
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
public bool CanBrowseArchive(string name)
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
private static void ApplyDelta(
List<FileSystemItem> items,
Dictionary<string, int> byPath,
BrowseDelta delta)
{
foreach (var added in delta.Added)
{
Upsert(items, byPath, added);
}
foreach (var updated in delta.Updated)
{
Upsert(items, byPath, updated);
}
}
private static void Upsert(
List<FileSystemItem> items,
Dictionary<string, int> byPath,
FileSystemItem item)
{
if (byPath.TryGetValue(item.FullPath, out var index))
{
items[index] = item;
return;
}
byPath[item.FullPath] = items.Count;
items.Add(item);
}
private static BrowseDelta CompleteDelta(FolderListing listing)
=> new()
{
Path = listing.Path,
IsOffline = listing.IsOffline,
Error = listing.Error,
Added = listing.Items,
EnumerationComplete = true,
HydrationComplete = true,
CompletedStages = ItemHydrationFlags.All
};
private async Task<FolderListing> ListOfflineAsync(Source? source, string path, CancellationToken cancellationToken)
{
if (source is not { IsIndexed: true })
{
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
if (dir is null)
{
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
}
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
.ConfigureAwait(false);
var items = children
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
.Select(c => FromIndex(source, c))
.ToList();
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
}
private static FileSystemItem FromIndex(Source source, IndexEntry entry)
=> new()
{
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, entry.PathRel),
Name = entry.Name,
IsDirectory = entry.IsDirectory,
SizeBytes = entry.IsDirectory ? entry.AggregateSize : entry.SizeBytes,
CreatedUtc = entry.CreatedUtc,
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
ReparseTag = entry.ReparseTag,
AllocatedSizeBytes = entry.AllocatedSizeBytes,
Cloud = entry.CloudAvailability is { } availability
? new CloudPresence(null, availability, entry.SizeBytes, entry.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null,
Hydration = ItemHydrationFlags.All
};
private async IAsyncEnumerable<BrowseDelta> ListLiveProgressiveAsync(
string path,
Source? source,
BrowseViewport? viewport,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var prefs = _preferences.Load();
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
var indexTask = source is { IsIndexed: true }
? LoadIndexChildrenAsync(source, path, cancellationToken)
: Task.FromResult(new Dictionary<string, IndexEntry>(StringComparer.Ordinal));
var batch = new List<FileSystemItem>(BrowseHydration.PublishBatch);
var all = new List<FileSystemItem>();
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
Dictionary<string, IndexEntry>? indexMap = indexTask.IsCompletedSuccessfully ? indexTask.Result : null;
var sink = new FileEnumerationSink();
await foreach (var raw in StreamEnumerationAsync(path, sink, cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
if (indexMap is null && indexTask.IsCompleted)
{
indexMap = await indexTask.ConfigureAwait(false);
}
var item = Annotate(raw, sizeFromIndex: false, prefs, probeAccess: false);
if (!LocationVisibility.ShouldShow(item.Location, prefs))
{
continue;
}
if (indexMap is not null)
{
item = OverlayIndex(item, indexMap, prefs);
}
item = AttachVolumeSpace(item, spaceCache);
Upsert(all, byPath, item);
batch.Add(item);
if (batch.Count >= BrowseHydration.PublishBatch)
{
yield return new BrowseDelta
{
Path = path,
Added = batch.ToArray(),
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
batch.Clear();
}
}
yield return new BrowseDelta
{
Path = path,
Error = sink.Error,
Added = batch.Count > 0 ? batch.ToArray() : [],
EnumerationComplete = true,
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
if (indexMap is null)
{
indexMap = await indexTask.ConfigureAwait(false);
}
var indexUpdates = OverlayPendingIndex(all, byPath, indexMap, prefs);
if (indexUpdates.Count > 0)
{
yield return new BrowseDelta
{
Path = path,
Updated = indexUpdates,
CompletedStages = ItemHydrationFlags.Index
};
}
var accessUpdates = await ProbeAccessDeniedAsync(all, byPath, prefs, source?.Kind, cancellationToken)
.ConfigureAwait(false);
if (accessUpdates.Count > 0)
{
yield return new BrowseDelta { Path = path, Updated = accessUpdates };
}
var constrained = _providers.Find(path) is not null;
if (constrained)
{
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken)
.ConfigureAwait(false))
{
if (enriched.Count == 0)
{
continue;
}
yield return new BrowseDelta
{
Path = path,
Updated = enriched,
CompletedStages = ItemHydrationFlags.Provider
};
}
}
yield return new BrowseDelta
{
Path = path,
Error = sink.Error,
HydrationComplete = true,
CompletedStages = ItemHydrationFlags.All
};
}
private async IAsyncEnumerable<FileSystemItem> StreamEnumerationAsync(
string path,
FileEnumerationSink sink,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var channel = Channel.CreateBounded<FileSystemItem>(new BoundedChannelOptions(256)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait
});
var writer = Task.Run(async () =>
{
try
{
foreach (var item in _enumerator.EnumerateChildrenStreaming(path, sink, cancellationToken))
{
await channel.Writer.WriteAsync(item, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Reader observes cancellation.
}
catch (Exception ex)
{
sink.Error ??= ex.Message;
}
finally
{
channel.Writer.TryComplete();
}
}, CancellationToken.None);
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return item;
}
}
finally
{
try
{
await writer.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// superseded
}
}
}
private async Task<Dictionary<string, IndexEntry>> LoadIndexChildrenAsync(
Source source,
string path,
CancellationToken cancellationToken)
{
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? (string.IsNullOrEmpty(rel)
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
: null);
if (indexed is null)
{
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
}
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
return children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
}
private FileSystemItem OverlayIndex(
FileSystemItem item,
IReadOnlyDictionary<string, IndexEntry> byName,
UiPreferences preferences)
{
if (!byName.TryGetValue(NameNormalizer.Normalize(item.Name), out var entry))
{
return item;
}
var size = item.IsDirectory ? entry.AggregateSize : item.SizeBytes;
var cloud = item.Cloud;
if (cloud is null && entry.CloudAvailability is { } availability)
{
cloud = new CloudPresence(
null,
availability,
entry.SizeBytes,
entry.AllocatedSizeBytes,
availability == CloudAvailability.OnlineOnly);
}
var hydrated = item.Overlay(
sizeBytes: size,
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
fileId: item.FileId ?? entry.FileId,
cloud: cloud,
hydration: item.Hydration | ItemHydrationFlags.Index);
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
}
private List<FileSystemItem> OverlayPendingIndex(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
IReadOnlyDictionary<string, IndexEntry> indexMap,
UiPreferences preferences)
{
if (indexMap.Count == 0)
{
return [];
}
var updates = new List<FileSystemItem>();
for (var i = 0; i < all.Count; i++)
{
var current = all[i];
if ((current.Hydration & ItemHydrationFlags.Index) != 0)
{
continue;
}
var updated = OverlayIndex(current, indexMap, preferences);
if (ReferenceEquals(updated, current))
{
all[i] = current.Overlay(hydration: current.Hydration | ItemHydrationFlags.Index);
continue;
}
all[i] = updated;
byPath[updated.FullPath] = i;
updates.Add(updated);
}
return updates;
}
private async Task<List<FileSystemItem>> ProbeAccessDeniedAsync(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
UiPreferences preferences,
SourceKind? kind,
CancellationToken cancellationToken)
{
if (!preferences.ShowProtectedSystemLocations)
{
return [];
}
var candidates = all
.Where(i => i.IsDirectory && i.Location.IsProtected && !i.Location.IsRecycleBin && !i.Location.AccessDenied)
.ToList();
if (candidates.Count == 0)
{
return [];
}
var updates = new List<FileSystemItem>();
var gate = new object();
await Parallel.ForEachAsync(
candidates,
new ParallelOptions
{
MaxDegreeOfParallelism = BrowseHydration.WorkerCount(kind, constrained: false),
CancellationToken = cancellationToken
},
(item, token) =>
{
token.ThrowIfCancellationRequested();
if (!IsAccessDenied(item.FullPath))
{
return ValueTask.CompletedTask;
}
var updated = Annotate(item, sizeFromIndex: item.SizeBytes > 0 && item.IsDirectory, preferences, probeAccess: true);
lock (gate)
{
if (byPath.TryGetValue(updated.FullPath, out var index))
{
all[index] = updated;
}
updates.Add(updated);
}
return ValueTask.CompletedTask;
}).ConfigureAwait(false);
return updates;
}
private async IAsyncEnumerable<IReadOnlyList<FileSystemItem>> EnrichInBatchesAsync(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
BrowseViewport? viewport,
SourceKind? kind,
bool constrained,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (all.Count == 0)
{
yield break;
}
var pending = all.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
var batchSize = BrowseHydration.ProviderBatchSize(kind, constrained);
while (pending.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var leftover = all.Where(i => pending.ContainsKey(i.FullPath)).ToList();
var ordered = BrowseHydration.Prioritize(leftover, viewport?.Snapshot() ?? []);
var chunk = ordered.Take(batchSize).ToList();
var enriched = await _providers.EnrichAsync(chunk, cancellationToken).ConfigureAwait(false);
var changed = new List<FileSystemItem>(chunk.Count);
for (var i = 0; i < chunk.Count; i++)
{
var original = chunk[i];
pending.Remove(original.FullPath);
var next = i < enriched.Count ? enriched[i] : original;
if (ReferenceEquals(next, original))
{
continue;
}
if (byPath.TryGetValue(next.FullPath, out var index))
{
all[index] = next;
}
changed.Add(next);
}
yield return changed;
}
}
private async Task<FolderListing?> TryListArchiveAsync(Source source, string path, CancellationToken cancellationToken)
{
if (source.LastRootPath is null)
@@ -243,54 +708,6 @@ public sealed class BrowseService
return false;
}
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
Source source,
string path,
List<FileSystemItem> listing,
CancellationToken cancellationToken)
{
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? (string.IsNullOrEmpty(rel)
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
: null);
if (indexed is null)
{
return listing;
}
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var byName = children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
return listing.Select(i =>
{
if (!i.IsDirectory || !byName.TryGetValue(NameNormalizer.Normalize(i.Name), out var e))
{
return i;
}
return new FileSystemItem
{
FullPath = i.FullPath,
Name = i.Name,
IsDirectory = true,
SizeBytes = e.AggregateSize,
CreatedUtc = i.CreatedUtc,
ModifiedUtc = i.ModifiedUtc,
Attributes = i.Attributes,
FileId = i.FileId,
ReparseTag = i.ReparseTag,
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
Cloud = i.Cloud,
Location = i.Location,
SizeKnowledge = i.SizeKnowledge,
DisplayName = i.DisplayName,
FreeSpaceBytes = i.FreeSpaceBytes,
CapacityBytes = i.CapacityBytes
};
}).ToList();
}
private FolderListing FinishListing(string path, IReadOnlyList<FileSystemItem> items, bool isOffline, string? error)
{
var prefs = _preferences.Load();
@@ -306,73 +723,44 @@ public sealed class BrowseService
return new FolderListing { Path = path, IsOffline = isOffline, Items = visible, Error = error };
}
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences)
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences, bool probeAccess = true)
{
var looksRestricted = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
var accessDenied = preferences.ShowProtectedSystemLocations
var accessDenied = probeAccess
&& 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
return item.Overlay(
location: location,
sizeKnowledge: knowledge,
displayName: location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
hydration: item.Hydration | ItemHydrationFlags.Location);
}
private FileSystemItem AttachVolumeSpace(FileSystemItem item, Dictionary<string, VolumeSpace> cache)
{
var key = SpaceKey(item.FullPath);
if (!cache.TryGetValue(key, out var space))
{
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
};
space = _volumes.GetSpace(item.FullPath);
cache[key] = space;
}
if (space.FreeBytes is null && space.CapacityBytes is null)
{
return item;
}
return item.Overlay(freeSpaceBytes: space.FreeBytes, capacityBytes: space.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();
return items.Select(item => AttachVolumeSpace(item, cache)).ToList();
}
private static string SpaceKey(string path)
@@ -386,18 +774,57 @@ public sealed class BrowseService
return string.IsNullOrWhiteSpace(root) ? path : root;
}
private FolderListing ListRecycleBin(string path)
private FileSystemItem RecycleBinItem(RecycleBinSummary? summary = null)
{
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 })
summary ??= _recycle?.TrySummarize(LocationRoots.RecycleBin);
return new FileSystemItem
{
hint += " " + _elevation.ProtectedContentHint;
FullPath = LocationRoots.RecycleBin,
Name = LocationRoots.RecycleBin,
DisplayName = LocationRoots.RecycleBin,
IsDirectory = true,
Attributes = AttributeFlags.Directory,
SizeBytes = summary?.UsedBytes ?? 0,
SizeKnowledge = summary is null ? SizeKnowledge.Unknown : SizeKnowledge.Calculated,
Location = new LocationInfo(false, false, false, false, true)
};
}
private async Task<IReadOnlyList<FileSystemItem>> UntrackedNetworkItemsAsync(CancellationToken cancellationToken)
{
var untracked = await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(false);
return untracked
.Where(fp => fp.Kind.IsNetwork())
.Select(fp =>
{
var space = _volumes.GetSpace(fp.RootPath);
return new FileSystemItem
{
FullPath = fp.RootPath,
Name = fp.DisplayName ?? fp.RootPath,
DisplayName = (fp.DisplayName ?? fp.RootPath) + " (Windows)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = space.FreeBytes ?? fp.FreeBytes,
CapacityBytes = space.CapacityBytes ?? fp.CapacityBytes,
AvailableToImport = true
};
})
.ToList();
}
private 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 new FolderListing { Path = path, IsOffline = false, Items = [], Error = hint };
return unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
}
private static bool IsRecycleBinPath(string path)

View File

@@ -0,0 +1,30 @@
namespace Explorer.Application;
public static class CursorLocator
{
public static string? Find(Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(local, "Programs", "cursor", "Cursor.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "Cursor.exe");
yield return Path.Combine(directory, "cursor.exe");
}
}
}

View File

@@ -12,4 +12,7 @@
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Explorer.Application.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,251 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class FileOperationProfilePlanner
{
private readonly RenamePlanner _rename;
public FileOperationProfilePlanner(RenamePlanner rename) => _rename = rename;
public OperationPlan Build(
OperationProfile profile,
IReadOnlyList<string> sourcePaths,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
GitStatus? gitStatus,
bool gitAvailable,
bool compressAvailable,
string compressMissingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
var sources = sourcePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToList();
if (sources.Count == 0)
{
return Error("Choose a source folder or drop files onto the profile.");
}
if (!profile.DoCopy && !profile.DoCompress && !profile.HasRenameRules)
{
return Error("Turn on Copy, Compress, or Rename.");
}
if (profile.RequireGitClean)
{
if (!gitAvailable)
{
return Error("git.exe is not available.");
}
if (gitStatus is null)
{
return Error("Source is not a Git repository.");
}
if (!gitStatus.WorkingTreeClean)
{
return Error("Working tree is not clean.");
}
}
var needsDest = profile.DoCopy || profile.DoCompress;
var destRoot = profile.DestPath?.Trim() ?? "";
if (needsDest)
{
if (string.IsNullOrWhiteSpace(destRoot))
{
return Error("Choose a destination folder.");
}
if (!pathReachable(destRoot) && !pathReachable(PathRules.Parent(destRoot)))
{
return Error("Destination is not available.", destRoot);
}
}
foreach (var source in sources)
{
if (!pathReachable(source))
{
return Error("Source is not available.", source);
}
}
var payload = Collect(sources, profile.Excludes, enumerator, wouldHydrate, issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
if (payload.Items.Count == 0)
{
return Error("Nothing to process after excludes.");
}
var operations = new List<PlannedOperation>();
var working = payload.Items.Select(i => i.FullPath).ToList();
if (profile.HasRenameRules)
{
var subjects = payload.Items
.Select(i => new RenameSubject(i.FullPath, i.Name, i.IsDirectory))
.ToList();
var renamePlan = _rename.Build(subjects, profile.RenameRules(), pathExists);
issues.AddRange(renamePlan.Issues);
if (renamePlan.HasErrors)
{
return new OperationPlan
{
Issues = issues,
Preview = renamePlan.Preview,
ProfilePreview = preview
};
}
foreach (var op in renamePlan.Operations)
{
operations.Add(op);
preview.Add(new ProfilePreviewRow("Rename", op.SourcePath, op.NewName));
}
working = payload.Items.Select(item =>
{
var op = renamePlan.Operations.FirstOrDefault(o =>
string.Equals(o.SourcePath, item.FullPath, StringComparison.OrdinalIgnoreCase));
return op?.DestinationPath ?? item.FullPath;
}).ToList();
if (renamePlan.Operations.Count == 0)
{
preview.Add(new ProfilePreviewRow("Rename", payload.Label, "No names change."));
}
}
if (profile.DoCompress)
{
if (!compressAvailable)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, compressMissingHint));
return new OperationPlan { Issues = issues, ProfilePreview = preview, Preview = [] };
}
var ext = profile.ArchiveFormat == ArchiveFormat.Zip ? "zip" : "7z";
var archive = PathRules.Combine(destRoot, payload.Label + "." + ext);
if (pathExists?.Invoke(archive) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "An archive with that name already exists.", archive));
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
operations.Add(new PlannedOperation(TransferOp.Compress, string.Join("|", working), archive));
preview.Add(new ProfilePreviewRow("Compress", archive, $"{working.Count} item(s)"));
}
if (profile.DoCopy)
{
var copyDest = payload.ContainerName is null
? destRoot
: PathRules.Combine(destRoot, payload.ContainerName);
foreach (var path in working)
{
var dest = PathRules.Combine(copyDest, PathRules.GetFileName(path));
operations.Add(new PlannedOperation(TransferOp.Copy, path, dest));
preview.Add(new ProfilePreviewRow("Copy", dest, null));
}
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
ProfilePreview = preview
};
}
private static Payload Collect(
IReadOnlyList<string> sources,
string excludes,
IFileSystemEnumerator enumerator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues)
{
var rules = FolderSyncPlanner.ParseExcludes(excludes);
var evaluator = new ExcludeEvaluator(rules, skipHidden: false, skipSystem: false);
var items = new List<FileSystemItem>();
string? container = null;
var label = sources.Count == 1 ? PathRules.GetFileName(sources[0]) : "archive";
if (string.IsNullOrEmpty(label))
{
label = "archive";
}
foreach (var path in sources)
{
var item = enumerator.GetItem(path);
if (item is null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Source was not found.", path));
continue;
}
if (item.IsDirectory)
{
container ??= item.Name;
var children = enumerator.EnumerateChildrenSafe(item.FullPath, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, item.FullPath));
continue;
}
var included = children
.Where(child => !evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, null))
.ToList();
if (included.Count == 0 && string.IsNullOrWhiteSpace(excludes))
{
items.Add(item);
continue;
}
foreach (var child in included)
{
Add(child, wouldHydrate, issues, items);
}
}
else if (!evaluator.ShouldExclude(item.FullPath, item.Name, false, item.Attributes, null))
{
Add(item, wouldHydrate, issues, items);
}
}
if (sources.Count != 1 || enumerator.GetItem(sources[0]) is not { IsDirectory: true })
{
container = null;
}
return new Payload(label, items, container);
}
private static void Add(
FileSystemItem item,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
List<FileSystemItem> items)
{
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
return;
}
items.Add(item);
}
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
private sealed record Payload(string Label, IReadOnlyList<FileSystemItem> Items, string? ContainerName);
}

View File

@@ -0,0 +1,71 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class FolderListingSort
{
public static bool DependsOnIncompleteMetadata(string property)
=> property is "Size" or "Free";
public static bool ShouldDeferAutoSort(string property, bool enumerationComplete, bool sizeMetadataReady)
{
if (!enumerationComplete)
{
return true;
}
if (property == "Size" && !sizeMetadataReady)
{
return true;
}
return false;
}
public static IEnumerable<FileSystemItem> Order(
IEnumerable<FileSystemItem> items,
string property,
bool descending)
{
var names = StringComparer.CurrentCultureIgnoreCase;
return property switch
{
"Size" => descending
? items.OrderByDescending(i => i.SizeBytes).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.SizeBytes).ThenBy(i => i.Name, names),
"Free" => descending
? items.OrderByDescending(i => i.FreeSpaceBytes ?? -1).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.FreeSpaceBytes ?? long.MaxValue).ThenBy(i => i.Name, names),
"Modified" => descending
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.ModifiedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
: items.OrderBy(TypeKey).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)
};
}
public static string TypeKey(FileSystemItem item)
{
if (item.AvailableToImport)
{
return "Available in Windows";
}
if (item.Location.IsRecycleBin)
{
return "Recycle Bin";
}
if (item.IsDirectory)
{
return "File folder";
}
var ext = NameNormalizer.Extension(item.Name);
return string.IsNullOrEmpty(ext) ? "File" : ext.ToUpperInvariant() + " file";
}
}

View File

@@ -0,0 +1,275 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class FolderSyncPlanner
{
public OperationPlan Build(
string sourceRoot,
string destRoot,
SyncMode mode,
string excludes,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
if (string.IsNullOrWhiteSpace(sourceRoot) || string.IsNullOrWhiteSpace(destRoot))
{
return Error("Source and destination folders are required.");
}
if (PathRules.FromExtended(sourceRoot).TrimEnd('\\')
.Equals(PathRules.FromExtended(destRoot).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
return Error("Source and destination must be different folders.");
}
if (IsUnder(sourceRoot, destRoot) || IsUnder(destRoot, sourceRoot))
{
return Error("Source and destination cannot contain each other.");
}
if (!pathReachable(sourceRoot))
{
return Error("Source is not available.", sourceRoot);
}
var destOnline = pathReachable(destRoot) || pathReachable(PathRules.Parent(destRoot));
if (!destOnline)
{
return Error("Destination is not available.", destRoot);
}
var sourceRootItem = enumerator.GetItem(sourceRoot);
if (sourceRootItem is not { IsDirectory: true })
{
return Error("Source folder was not found.", sourceRoot);
}
var rules = ParseExcludes(excludes);
var evaluator = new ExcludeEvaluator(rules, skipHidden: false, skipSystem: false);
var sourceFiles = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var sourceDirs = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
Walk(sourceRoot, "", enumerator, evaluator, wouldHydrate, issues, sourceFiles, sourceDirs);
var destFiles = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var destDirs = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var destRootItem = enumerator.GetItem(destRoot);
if (destRootItem is { IsDirectory: true })
{
Walk(destRoot, "", enumerator, evaluator, null, issues, destFiles, destDirs);
}
var operations = new List<PlannedOperation>();
var preview = new List<SyncPreviewRow>();
foreach (var (rel, src) in sourceFiles.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
var destPath = PathRules.Combine(destRoot, rel);
if (!destFiles.TryGetValue(rel, out var dest))
{
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Copy", null));
continue;
}
if (dest.IsDirectory)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A folder with that name already exists at the destination.", destPath));
preview.Add(new SyncPreviewRow(rel, "Error", "Destination is a folder."));
continue;
}
if (IsSame(src, dest))
{
preview.Add(new SyncPreviewRow(rel, "Skip", "Already up to date."));
continue;
}
if (IsNewer(dest, src))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Destination is newer; skipped.", destPath));
preview.Add(new SyncPreviewRow(rel, "Skip", "Destination is newer."));
continue;
}
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Update", null));
}
foreach (var (rel, src) in sourceDirs.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
if (sourceFiles.Keys.Any(f => f.StartsWith(rel + "\\", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
if (destDirs.ContainsKey(rel) || destFiles.ContainsKey(rel))
{
continue;
}
var destPath = PathRules.Combine(destRoot, rel);
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Copy", "Empty folder"));
}
if (mode == SyncMode.Mirror)
{
foreach (var (rel, dest) in destFiles.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
if (sourceFiles.ContainsKey(rel) || evaluator.ShouldExclude(dest.FullPath, dest.Name, false, dest.Attributes, null))
{
continue;
}
operations.Add(new PlannedOperation(TransferOp.Delete, dest.FullPath, "recycle"));
preview.Add(new SyncPreviewRow(rel, "Delete", "Only on destination"));
}
foreach (var (rel, dest) in destDirs.OrderByDescending(p => p.Key.Count(c => c == '\\')))
{
if (sourceDirs.ContainsKey(rel) || sourceFiles.Keys.Any(f => f.StartsWith(rel + "\\", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
operations.Add(new PlannedOperation(TransferOp.Delete, dest.FullPath, "recycle"));
preview.Add(new SyncPreviewRow(rel, "Delete", "Only on destination"));
}
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
SyncPreview = preview
};
}
public static string RemapToVolume(string path, string? volumeGuid, IReadOnlyList<Source> sources)
{
if (string.IsNullOrWhiteSpace(volumeGuid) || string.IsNullOrWhiteSpace(path))
{
return path;
}
var hits = sources
.Where(s => !string.IsNullOrEmpty(s.VolumeGuid)
&& string.Equals(s.VolumeGuid, volumeGuid, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(s.LastRootPath))
.ToList();
if (hits.Count != 1)
{
return path;
}
var oldRoot = PathRules.VolumeRoot(path);
var rel = PathRules.MakeRelative(oldRoot, path);
return PathRules.Combine(hits[0].LastRootPath!, rel);
}
public static IReadOnlyList<ExcludeRule> ParseExcludes(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return [];
}
return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !line.StartsWith('#'))
.Select(line => new ExcludeRule { Kind = ExcludeKind.Glob, Pattern = line, Enabled = true })
.ToList();
}
private static void Walk(
string root,
string relative,
IFileSystemEnumerator enumerator,
ExcludeEvaluator evaluator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
Dictionary<string, FileSystemItem> files,
Dictionary<string, FileSystemItem> dirs)
{
var full = string.IsNullOrEmpty(relative) ? root : PathRules.Combine(root, relative);
var children = enumerator.EnumerateChildrenSafe(full, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, full));
return;
}
foreach (var child in children)
{
var rel = string.IsNullOrEmpty(relative) ? child.Name : relative + "\\" + child.Name;
if (evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, null))
{
continue;
}
if (wouldHydrate?.Invoke(child) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", child.FullPath));
continue;
}
if (child.IsDirectory)
{
if (!ReparsePolicy.ShouldRecurseIntoDirectory(child))
{
continue;
}
dirs[rel] = child;
Walk(root, rel, enumerator, evaluator, wouldHydrate, issues, files, dirs);
}
else
{
files[rel] = child;
}
}
}
private static bool IsSame(FileSystemItem source, FileSystemItem dest)
{
if (source.SizeBytes != dest.SizeBytes)
{
return false;
}
if (source.ModifiedUtc is null || dest.ModifiedUtc is null)
{
return source.SizeBytes == dest.SizeBytes;
}
return Abs(source.ModifiedUtc.Value - dest.ModifiedUtc.Value) <= AppConstants.SyncTimestampSkew;
}
private static bool IsNewer(FileSystemItem candidate, FileSystemItem other)
{
if (candidate.ModifiedUtc is null || other.ModifiedUtc is null)
{
return false;
}
return candidate.ModifiedUtc.Value - other.ModifiedUtc.Value > AppConstants.SyncTimestampSkew;
}
private static TimeSpan Abs(TimeSpan value) => value < TimeSpan.Zero ? -value : value;
private static bool IsUnder(string parent, string child)
{
var p = PathRules.FromExtended(parent).TrimEnd('\\');
var c = PathRules.FromExtended(child).TrimEnd('\\');
return c.StartsWith(p + "\\", StringComparison.OrdinalIgnoreCase);
}
private static OperationPlan Error(string message, string? path = null)
=> new()
{
Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)]
};
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Application;
public static class GitLocator
{
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(programFiles, "Git", "cmd", "git.exe");
yield return Path.Combine(programFiles, "Git", "bin", "git.exe");
yield return Path.Combine(programFilesX86, "Git", "cmd", "git.exe");
yield return Path.Combine(local, "Programs", "Git", "cmd", "git.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "git.exe");
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public static class GitPorcelainParser
{
private static readonly Regex AheadBehind = new(@"\+(\d+) -(\d+)", RegexOptions.CultureInvariant);
public static GitStatus? Parse(string output, string repoRoot)
{
if (string.IsNullOrWhiteSpace(output) || string.IsNullOrWhiteSpace(repoRoot))
{
return null;
}
var branch = "HEAD";
var oid = "";
var ahead = 0;
var behind = 0;
var modified = 0;
var untracked = 0;
foreach (var raw in output.Split(["\r\n", "\n"], StringSplitOptions.None))
{
var line = raw.TrimEnd();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("# branch.head ", StringComparison.Ordinal))
{
branch = line["# branch.head ".Length..].Trim();
continue;
}
if (line.StartsWith("# branch.oid ", StringComparison.Ordinal))
{
oid = line["# branch.oid ".Length..].Trim();
continue;
}
if (line.StartsWith("# branch.ab ", StringComparison.Ordinal))
{
var match = AheadBehind.Match(line);
if (match.Success)
{
ahead = int.Parse(match.Groups[1].Value);
behind = int.Parse(match.Groups[2].Value);
}
continue;
}
if (line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("? ", StringComparison.Ordinal))
{
untracked++;
continue;
}
if (line.StartsWith("! ", StringComparison.Ordinal))
{
continue;
}
if (line.StartsWith("1 ", StringComparison.Ordinal)
|| line.StartsWith("2 ", StringComparison.Ordinal)
|| line.StartsWith("u ", StringComparison.Ordinal))
{
modified++;
}
}
if (branch.Equals("(detached)", StringComparison.OrdinalIgnoreCase))
{
branch = oid.Length >= 7 ? oid[..7] : "detached";
}
else if (string.IsNullOrWhiteSpace(branch))
{
branch = "HEAD";
}
return new GitStatus
{
RepoRoot = repoRoot,
Branch = branch,
ModifiedCount = modified,
UntrackedCount = untracked,
Ahead = ahead,
Behind = behind
};
}
}

View File

@@ -0,0 +1,53 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class GitRepoDetector
{
public static bool IsRepoRoot(string path, Func<string, bool>? directoryExists = null, Func<string, bool>? fileExists = null)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return false;
}
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var git = Path.Combine(PathRules.FromExtended(path), ".git");
return directoryExists(git) || fileExists(git);
}
public static string? FindRoot(string path, Func<string, bool>? directoryExists = null, Func<string, bool>? fileExists = null)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return null;
}
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var current = PathRules.FromExtended(path);
if (fileExists(current) && !directoryExists(current))
{
current = PathRules.Parent(current);
}
while (!string.IsNullOrEmpty(current))
{
if (IsRepoRoot(current, directoryExists, fileExists))
{
return current;
}
var parent = PathRules.Parent(current);
if (string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
{
break;
}
current = parent;
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record ArchiveProgress(int Percent, long FilesDone, string? CurrentPath);
public interface IArchiveExecutor
{
bool IsAvailable { get; }
string MissingHint { get; }
Task ExtractAsync(
string archivePath,
string destinationDirectory,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task CompressAsync(
IReadOnlyList<string> sources,
string archivePath,
ArchiveFormat format,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task AddAsync(
string archivePath,
IReadOnlyList<string> sources,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task VerifyAsync(
string archivePath,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,17 @@
using Explorer.Domain;
namespace Explorer.Application;
public interface IGitStatusProvider
{
bool IsAvailable { get; }
string? FindRepoRoot(string path);
bool IsRepoRoot(string path);
Task<GitStatus?> GetStatusAsync(string path, CancellationToken cancellationToken = default);
}
public interface IWorkspaceLauncher
{
void OpenTerminal(string directory);
bool TryOpenInCursor(string directory);
}

View File

@@ -1,13 +1,17 @@
namespace Explorer.Application;
public sealed record RecycleBinSummary(int ItemCount, long UsedBytes);
public sealed record RecycleBinSummary(long ItemCount, long UsedBytes)
{
public static RecycleBinSummary FromQuery(long usedBytes, long itemCount)
=> new(Math.Max(0, itemCount), Math.Max(0, usedBytes));
}
public interface IRecycleBinCatalog
{
RecycleBinSummary? TrySummarize(string recycleBinPath);
RecycleBinSummary? TrySummarize(string? rootPath = null);
}
public sealed class RecycleBinCatalog : IRecycleBinCatalog
{
public RecycleBinSummary? TrySummarize(string recycleBinPath) => null;
public RecycleBinSummary? TrySummarize(string? rootPath = null) => null;
}

View File

@@ -6,6 +6,11 @@ public static class LocationVisibility
{
public static bool ShouldShow(LocationInfo info, UiPreferences preferences)
{
if (info.IsRecycleBin)
{
return false;
}
if (info.IsProtected)
{
return preferences.ShowProtectedSystemLocations;

View File

@@ -0,0 +1,63 @@
namespace Explorer.Application;
public sealed class LruCache<TKey, TValue>
where TKey : notnull
{
private readonly int _capacity;
private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map;
private readonly LinkedList<(TKey Key, TValue Value)> _order;
private readonly object _gate = new();
public LruCache(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_capacity = capacity;
_map = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>(capacity);
_order = new LinkedList<(TKey, TValue)>();
}
public int Count
{
get { lock (_gate) return _map.Count; }
}
public bool TryGet(TKey key, out TValue value)
{
lock (_gate)
{
if (_map.TryGetValue(key, out var node))
{
_order.Remove(node);
_order.AddFirst(node);
value = node.Value.Value;
return true;
}
}
value = default!;
return false;
}
public void Set(TKey key, TValue value)
{
lock (_gate)
{
if (_map.TryGetValue(key, out var existing))
{
_order.Remove(existing);
existing.Value = (key, value);
_order.AddFirst(existing);
return;
}
var node = _order.AddFirst((key, value));
_map[key] = node;
while (_map.Count > _capacity)
{
var last = _order.Last!;
_order.RemoveLast();
_map.Remove(last.Value.Key);
}
}
}
}

View File

@@ -0,0 +1,258 @@
namespace Explorer.Application;
public sealed record MarkdownDocument(IReadOnlyList<MarkdownBlock> Blocks, IReadOnlyList<MarkdownHeading> Headings);
public abstract record MarkdownBlock;
public sealed record MarkdownHeading(int Level, string Text, string Id) : MarkdownBlock;
public sealed record MarkdownParagraph(string Text) : MarkdownBlock;
public sealed record MarkdownList(bool Ordered, IReadOnlyList<string> Items) : MarkdownBlock;
public sealed record MarkdownCode(string Language, string Text) : MarkdownBlock;
public sealed record MarkdownTable(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows) : MarkdownBlock;
public sealed record MarkdownRule : MarkdownBlock;
public sealed record MarkdownQuote(string Text) : MarkdownBlock;
public static class MarkdownParser
{
public static MarkdownDocument Parse(string text)
{
var lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var blocks = new List<MarkdownBlock>();
var headings = new List<MarkdownHeading>();
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
while (i < lines.Length)
{
var raw = lines[i];
if (string.IsNullOrWhiteSpace(raw))
{
i++;
continue;
}
if (raw.StartsWith("```", StringComparison.Ordinal))
{
var lang = raw[3..].Trim();
i++;
var body = new List<string>();
while (i < lines.Length && !lines[i].StartsWith("```", StringComparison.Ordinal))
{
body.Add(lines[i]);
i++;
}
if (i < lines.Length)
{
i++;
}
blocks.Add(new MarkdownCode(lang, string.Join("\n", body)));
continue;
}
if (IsRule(raw))
{
blocks.Add(new MarkdownRule());
i++;
continue;
}
var heading = ParseHeading(raw);
if (heading is not null)
{
var id = UniqueId(Slug(heading.Text), ids);
var block = heading with { Id = id };
blocks.Add(block);
if (block.Level is 2 or 3)
{
headings.Add(block);
}
i++;
continue;
}
if (raw.TrimStart().StartsWith('|'))
{
var table = ReadTable(lines, ref i);
if (table is not null)
{
blocks.Add(table);
continue;
}
}
if (LooksLikeList(raw, out var ordered))
{
var items = new List<string>();
while (i < lines.Length && LooksLikeList(lines[i], out var next) && next == ordered)
{
items.Add(ListText(lines[i]));
i++;
}
blocks.Add(new MarkdownList(ordered, items));
continue;
}
if (raw.StartsWith("> ", StringComparison.Ordinal) || raw == ">")
{
var quote = new List<string>();
while (i < lines.Length && (lines[i].StartsWith("> ", StringComparison.Ordinal) || lines[i] == ">"))
{
quote.Add(lines[i].StartsWith("> ", StringComparison.Ordinal) ? lines[i][2..] : "");
i++;
}
blocks.Add(new MarkdownQuote(string.Join(" ", quote)));
continue;
}
var para = new List<string> { raw.Trim() };
i++;
while (i < lines.Length
&& !string.IsNullOrWhiteSpace(lines[i])
&& ParseHeading(lines[i]) is null
&& !IsRule(lines[i])
&& !lines[i].StartsWith("```", StringComparison.Ordinal)
&& !LooksLikeList(lines[i], out _)
&& !lines[i].TrimStart().StartsWith('|'))
{
para.Add(lines[i].Trim());
i++;
}
blocks.Add(new MarkdownParagraph(string.Join(" ", para)));
}
return new MarkdownDocument(blocks, headings);
}
private static MarkdownHeading? ParseHeading(string line)
{
if (!line.StartsWith('#') || line.Length < 3)
{
return null;
}
var level = 0;
while (level < line.Length && line[level] == '#' && level < 6)
{
level++;
}
if (level == 0 || level >= line.Length || line[level] != ' ')
{
return null;
}
return new MarkdownHeading(level, line[(level + 1)..].Trim(), "");
}
private static MarkdownTable? ReadTable(string[] lines, ref int i)
{
var header = SplitRow(lines[i]);
if (header.Count == 0)
{
return null;
}
i++;
if (i >= lines.Length || !IsAlignmentRow(lines[i]))
{
return new MarkdownTable(header, []);
}
i++;
var rows = new List<IReadOnlyList<string>>();
while (i < lines.Length && lines[i].TrimStart().StartsWith('|'))
{
rows.Add(SplitRow(lines[i]));
i++;
}
return new MarkdownTable(header, rows);
}
private static List<string> SplitRow(string line)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('|'))
{
trimmed = trimmed[1..];
}
if (trimmed.EndsWith('|'))
{
trimmed = trimmed[..^1];
}
return trimmed.Split('|').Select(c => c.Trim()).ToList();
}
private static bool IsAlignmentRow(string line)
=> line.Contains('|') && line.All(c => c is '|' or ':' or '-' or ' ' or '\t');
private static bool IsRule(string line)
{
var t = line.Trim();
return t is "---" or "***" or "___" || (t.Length >= 3 && t.All(c => c == '-' || c == '*' || c == '_'));
}
private static bool LooksLikeList(string line, out bool ordered)
{
ordered = false;
var t = line.TrimStart();
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
{
return true;
}
var dot = t.IndexOf(". ", StringComparison.Ordinal);
if (dot > 0 && t[..dot].All(char.IsDigit))
{
ordered = true;
return true;
}
return false;
}
private static string ListText(string line)
{
var t = line.TrimStart();
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
{
return t[2..].Trim();
}
var dot = t.IndexOf(". ", StringComparison.Ordinal);
return dot > 0 ? t[(dot + 2)..].Trim() : t;
}
private static string Slug(string text)
{
var chars = text.Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-').ToArray();
return new string(chars).Trim().Replace(' ', '-').ToLowerInvariant();
}
private static string UniqueId(string slug, HashSet<string> ids)
{
var id = string.IsNullOrEmpty(slug) ? "section" : slug;
var n = 2;
var candidate = id;
while (!ids.Add(candidate))
{
candidate = id + "-" + n;
n++;
}
return candidate;
}
}

View File

@@ -0,0 +1,288 @@
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public sealed class RenamePlanner
{
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250);
public OperationPlan Build(
IReadOnlyList<RenameSubject> subjects,
RenameRuleSet rules,
Func<string, bool>? pathExists = null)
{
var issues = new List<PlanIssue>();
Regex? regex = null;
if (rules.UseRegex && !string.IsNullOrEmpty(rules.Search))
{
try
{
var options = RegexOptions.CultureInvariant;
if (!rules.MatchCase)
{
options |= RegexOptions.IgnoreCase;
}
regex = new Regex(rules.Search, options, RegexTimeout);
}
catch (ArgumentException ex)
{
return new OperationPlan
{
Issues = [new PlanIssue(PlanIssueSeverity.Error, "Invalid regular expression: " + ex.Message)]
};
}
}
var rows = new List<(RenameSubject Subject, string NewName, string Dest, bool Unchanged, string? Status)>();
var index = 0;
foreach (var subject in subjects)
{
string newName;
try
{
newName = Apply(subject.Name, rules, index, regex);
}
catch (RegexMatchTimeoutException)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Search pattern took too long.", subject.FullPath));
rows.Add((subject, subject.Name, subject.FullPath, true, "Search pattern took too long."));
index++;
continue;
}
index++;
if (!WindowsFileNames.IsValid(newName, out var invalid))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, invalid ?? "Invalid name.", subject.FullPath));
rows.Add((subject, newName, PathRules.Combine(PathRules.Parent(subject.FullPath), newName), false, invalid));
continue;
}
var dest = PathRules.Combine(PathRules.Parent(subject.FullPath), newName);
var unchanged = dest.Equals(subject.FullPath, StringComparison.OrdinalIgnoreCase)
&& newName.Equals(subject.Name, StringComparison.Ordinal);
rows.Add((subject, newName, dest, unchanged, unchanged ? "Unchanged" : null));
}
var changed = rows.Where(r => !r.Unchanged && r.Status is null).ToList();
foreach (var clash in changed.GroupBy(r => DestKey(r.Dest), StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1))
{
foreach (var row in clash)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Two files would get the same name.", row.Subject.FullPath));
}
}
var moving = new HashSet<string>(changed.Select(r => r.Subject.FullPath), StringComparer.OrdinalIgnoreCase);
if (pathExists is not null)
{
foreach (var row in changed)
{
if (moving.Contains(row.Dest))
{
continue;
}
if (pathExists(row.Dest))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", row.Subject.FullPath));
}
}
}
foreach (var row in changed)
{
if (!moving.Contains(row.Dest)
&& rows.Any(other => other.Unchanged
&& other.Dest.Equals(row.Dest, StringComparison.OrdinalIgnoreCase)))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", row.Subject.FullPath));
}
}
var preview = rows.Select(r =>
{
var status = r.Status;
if (status is null && issues.Any(i => i.Path == r.Subject.FullPath && i.Severity == PlanIssueSeverity.Error))
{
status = issues.First(i => i.Path == r.Subject.FullPath).Message;
}
return new RenamePreviewRow(r.Subject.FullPath, r.Subject.Name, r.NewName, status, r.Unchanged);
}).ToList();
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, Preview = preview };
}
var ordered = Order(changed.Select(r => (r.Subject.FullPath, r.Dest, r.NewName)).ToList(), issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, Preview = preview };
}
return new OperationPlan
{
Operations = ordered.Select(r => new PlannedOperation(TransferOp.Rename, r.Source, r.Dest, r.NewName)).ToList(),
Issues = issues,
Preview = preview
};
}
public OperationPlan BuildUndo(RenameBatch batch, Func<string, bool> pathExists)
{
var issues = new List<PlanIssue>();
var operations = new List<PlannedOperation>();
foreach (var item in batch.Items.OrderByDescending(i => i.SortOrder))
{
if (!pathExists(item.NewPath))
{
continue;
}
if (pathExists(item.OldPath)
&& !item.OldPath.Equals(item.NewPath, StringComparison.OrdinalIgnoreCase))
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"Original name is already taken.",
item.OldPath));
continue;
}
operations.Add(new PlannedOperation(
TransferOp.Rename,
item.NewPath,
item.OldPath,
PathRules.GetFileName(item.OldPath)));
}
if (issues.Count > 0)
{
return new OperationPlan { Issues = issues };
}
if (operations.Count == 0)
{
var pending = batch.Items.Any(i =>
pathExists(i.OldPath)
&& !pathExists(i.NewPath)
&& !i.OldPath.Equals(i.NewPath, StringComparison.OrdinalIgnoreCase));
return new OperationPlan
{
Issues =
[
pending
? new PlanIssue(PlanIssueSeverity.Error, "Rename has not finished yet.")
: new PlanIssue(PlanIssueSeverity.Warning, "Nothing left to undo.")
]
};
}
return new OperationPlan { Operations = operations };
}
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex)
{
var (stem, extension) = WindowsFileNames.Split(name);
var text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
var counter = rules.UseCounter
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
: "";
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
{
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
}
else if (rules.UseCounter)
{
text += counter;
}
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
{
return text;
}
var newExt = rules.ChangeExtension ? (rules.NewExtension ?? "").Trim().TrimStart('.') : extension;
return WindowsFileNames.Join(text, newExt);
}
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
{
if (string.IsNullOrEmpty(rules.Search))
{
return text;
}
if (regex is not null)
{
return regex.Replace(text, rules.Replace ?? "");
}
var comparison = rules.MatchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
var search = rules.Search;
var replace = rules.Replace ?? "";
var start = 0;
while (start <= text.Length - search.Length)
{
var found = text.IndexOf(search, start, comparison);
if (found < 0)
{
break;
}
text = text[..found] + replace + text[(found + search.Length)..];
start = found + replace.Length;
if (search.Length == 0)
{
break;
}
}
return text;
}
private static string DestKey(string path)
=> PathRules.FromExtended(path);
internal static List<(string Source, string Dest, string NewName)> Order(
List<(string Source, string Dest, string NewName)> items,
List<PlanIssue> issues)
{
if (items.Count <= 1)
{
return items;
}
var comparer = StringComparer.OrdinalIgnoreCase;
var remaining = items.ToList();
var ordered = new List<(string Source, string Dest, string NewName)>();
var sources = remaining.Select(i => i.Source).ToHashSet(comparer);
while (remaining.Count > 0)
{
var ready = remaining.Where(i => !sources.Contains(i.Dest) || comparer.Equals(i.Dest, i.Source)).ToList();
if (ready.Count == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Rename cycle: two files would swap names that cannot be applied in order."));
return items;
}
foreach (var item in ready)
{
ordered.Add(item);
remaining.Remove(item);
sources.Remove(item.Source);
}
}
return ordered;
}
}

View File

@@ -0,0 +1,192 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class ReorganizePlanner
{
public static readonly TimeSpan OldInstallerAge = TimeSpan.FromDays(365);
public OperationPlan Build(
string sourceRoot,
OrganizeDestinations destinations,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
Func<string, bool> isRepoRoot,
Func<FileSystemItem, bool>? wouldHydrate = null,
Func<string, bool>? pathExists = null,
DateTimeOffset? now = null)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
{
return Error("Choose a folder to organize.");
}
if (!pathReachable(sourceRoot))
{
return Error("Source is not available.", sourceRoot);
}
var root = enumerator.GetItem(sourceRoot);
if (root is null || !root.IsDirectory)
{
return Error("Source was not found.", sourceRoot);
}
var children = enumerator.EnumerateChildrenSafe(sourceRoot, out var error);
if (error is not null)
{
return Error(error, sourceRoot);
}
var issues = new List<PlanIssue>();
var operations = new List<PlannedOperation>();
var preview = new List<OrganizePreviewRow>();
var utc = now ?? DateTimeOffset.UtcNow;
var destTaken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in children)
{
if (item.IsReparsePoint && item.IsDirectory)
{
preview.Add(Row(item, FileCategory.Unknown, "Skip", null, "Reparse point left untouched."));
continue;
}
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
preview.Add(Row(item, FileCategory.Unknown, "Skip", null, "Online-only cloud file skipped."));
continue;
}
var childCategories = item.IsDirectory && !FileClassifier.SkipDescent(item.Name)
? ChildCategories(item, enumerator, isRepoRoot)
: null;
var classification = FileClassifier.Classify(
item.Name,
item.FullPath,
item.IsDirectory,
item.Attributes,
item.IsDirectory && isRepoRoot(item.FullPath),
childCategories);
if (FileClassifier.ShouldLeave(classification.Category))
{
preview.Add(Row(item, classification.Category, "Skip", null, classification.Reason));
continue;
}
var destRoot = destinations.For(classification.Category);
if (destRoot is null)
{
preview.Add(Row(item, classification.Category, "Skip", null, "No destination for " + OrganizeDestinations.Label(classification.Category).ToLowerInvariant() + "."));
continue;
}
if (Same(sourceRoot, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Destination is the folder being organized."));
continue;
}
if (Same(item.FullPath, destRoot) || SameOrUnder(item.FullPath, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Would move the destination into itself."));
continue;
}
if (!pathReachable(destRoot) && !pathReachable(PathRules.Parent(destRoot)))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Destination is not available.", destRoot));
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Destination is not available."));
continue;
}
if (AlreadyThere(item.FullPath, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Already in the destination folder."));
continue;
}
var dest = PathRules.Combine(destRoot, item.Name);
var detail = classification.Reason;
if (classification.Category == FileCategory.Installer
&& item.ModifiedUtc is { } modified
&& utc - modified >= OldInstallerAge)
{
detail = "Older than 1 year.";
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Old installer.", item.FullPath));
}
if (!destTaken.Add(dest) || pathExists?.Invoke(dest) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", dest));
preview.Add(Row(item, classification.Category, "Skip", dest, "A file with that name already exists."));
continue;
}
operations.Add(new PlannedOperation(TransferOp.Move, item.FullPath, dest));
preview.Add(Row(item, classification.Category, "Move", dest, detail));
}
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, OrganizePreview = preview };
}
return new OperationPlan
{
Operations = operations,
Issues = issues,
OrganizePreview = preview
};
}
private static IReadOnlyList<FileCategory> ChildCategories(
FileSystemItem folder,
IFileSystemEnumerator enumerator,
Func<string, bool> isRepoRoot)
{
var children = enumerator.EnumerateChildrenSafe(folder.FullPath, out var error);
if (error is not null)
{
return [];
}
return children.Select(child => FileClassifier.Classify(
child.Name,
child.FullPath,
child.IsDirectory,
child.Attributes,
child.IsDirectory && isRepoRoot(child.FullPath)).Category).ToList();
}
private static bool Same(string left, string right)
{
var a = PathRules.FromExtended(left).TrimEnd('\\');
var b = PathRules.FromExtended(right).TrimEnd('\\');
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
private static bool AlreadyThere(string sourcePath, string destRoot)
=> Same(PathRules.Parent(sourcePath), destRoot);
private static bool SameOrUnder(string parent, string child)
{
var p = PathRules.FromExtended(parent).TrimEnd('\\');
var c = PathRules.FromExtended(child).TrimEnd('\\');
return c.StartsWith(p + "\\", StringComparison.OrdinalIgnoreCase);
}
private static OrganizePreviewRow Row(
FileSystemItem item,
FileCategory category,
string action,
string? destination,
string? detail)
=> new(item.Name, OrganizeDestinations.Label(category), action, destination, detail);
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
}

View File

@@ -0,0 +1,37 @@
namespace Explorer.Application;
public static class SevenZipLocator
{
public const string MissingHint = "7-Zip is not installed. Install 7-Zip or set its path in Settings.";
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "7-Zip", "7z.exe");
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "7-Zip", "7z.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "7z.exe");
yield return Path.Combine(directory, "7za.exe");
}
}
}

View File

@@ -64,6 +64,10 @@ public sealed class SourceManager
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
else if (fp.Kind.IsNetwork())
{
continue;
}
else
{
source = new Source
@@ -302,6 +306,29 @@ public sealed class SourceManager
return created;
}
public async Task<IReadOnlyList<VolumeFingerprint>> ListUntrackedOnlineVolumesAsync(CancellationToken cancellationToken = default)
{
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var untracked = new List<VolumeFingerprint>();
foreach (var fp in _volumes.EnumerateOnlineVolumes())
{
var match = VolumeIdentityMatcher.Match(fp, known);
if (match.Source is not null && !match.Ambiguous)
{
continue;
}
if (known.Any(s => s.LastRootPath is not null && RootsEqual(s.LastRootPath, fp.RootPath)))
{
continue;
}
untracked.Add(fp);
}
return untracked;
}
private async Task<Source?> FindSourceRootAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))

View File

@@ -188,25 +188,34 @@ public sealed class StorageProviderRegistry
return null;
}
}
public async Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
{
var provider = Find(rootPath);
if (provider is null)
{
return null;
}
try
{
return await provider.TryGetQuotaAsync(rootPath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed reading quota for {Path}", provider.Manifest.Id, rootPath);
return null;
}
}
}
public static class CloudPresenceMapper
{
public static FileSystemItem Apply(FileSystemItem item, ProviderItemState state)
=> new()
{
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 = state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
Cloud = ToPresence(state)
};
=> item.Overlay(
allocatedSizeBytes: state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
cloud: ToPresence(state),
hydration: item.Hydration | ItemHydrationFlags.Provider);
public static CloudPresence ToPresence(ProviderItemState state)
=> new(

View File

@@ -0,0 +1,233 @@
namespace Explorer.Application;
public readonly record struct ThumbnailKey(string Path, long ModifiedTicks, int PixelWidth)
{
public static ThumbnailKey From(string path, DateTimeOffset? modifiedUtc, int pixelWidth)
=> new(path, modifiedUtc?.UtcTicks ?? 0, pixelWidth);
}
public readonly record struct ThumbnailJob(
ThumbnailKey Key,
int Rank,
int Generation);
public readonly record struct ThumbnailSchedulerStats(
int Generation,
int Window,
int Pending,
int InFlight,
int Requested,
int CacheHits,
int Generated,
int Cancelled,
int Failed);
public sealed class ThumbnailScheduler
{
public const int DefaultConcurrency = 2;
public const int DefaultPixelWidth = 128;
public const int MaxPending = 96;
public const int PrefetchItems = 24;
private readonly object _gate = new();
private readonly List<ThumbnailJob> _window = [];
private readonly HashSet<ThumbnailKey> _windowKeys = [];
private readonly HashSet<ThumbnailKey> _inFlight = [];
private readonly HashSet<ThumbnailKey> _cached = [];
private int _generation;
private bool _enabled;
private int _requested;
private int _cacheHits;
private int _generated;
private int _cancelled;
private int _failed;
public int Generation
{
get { lock (_gate) return _generation; }
}
public int InFlight
{
get { lock (_gate) return _inFlight.Count; }
}
public void Reset(int generation)
{
lock (_gate)
{
_cancelled += _window.Count + _inFlight.Count;
_generation = generation;
_window.Clear();
_windowKeys.Clear();
_inFlight.Clear();
}
}
public void SetEnabled(bool enabled)
{
lock (_gate)
{
_enabled = enabled;
if (enabled)
{
return;
}
_cancelled += _window.Count + _inFlight.Count;
_window.Clear();
_windowKeys.Clear();
_inFlight.Clear();
}
}
public bool IsCached(ThumbnailKey key)
{
lock (_gate)
{
return _cached.Contains(key);
}
}
public void MarkCached(ThumbnailKey key)
{
lock (_gate)
{
_cached.Add(key);
}
}
public void RecordCacheHit()
{
lock (_gate)
{
_cacheHits++;
_requested++;
}
}
public IReadOnlyList<ThumbnailJob> SetWindow(int generation, IReadOnlyList<ThumbnailJob> jobs)
{
ArgumentNullException.ThrowIfNull(jobs);
lock (_gate)
{
if (generation != _generation || !_enabled)
{
_cancelled += jobs.Count;
return [];
}
var next = jobs
.Where(j => j.Generation == _generation)
.GroupBy(j => j.Key)
.Select(g => g.OrderBy(j => j.Rank).First())
.OrderBy(j => j.Rank)
.Take(MaxPending)
.ToList();
var nextKeys = next.Select(j => j.Key).ToHashSet();
foreach (var previous in _window)
{
if (!nextKeys.Contains(previous.Key))
{
_cancelled++;
}
}
foreach (var key in _inFlight.ToArray())
{
if (!nextKeys.Contains(key))
{
_inFlight.Remove(key);
_cancelled++;
}
}
_window.Clear();
_window.AddRange(next);
_windowKeys.Clear();
foreach (var job in next)
{
_windowKeys.Add(job.Key);
}
_requested += next.Count(j => !_cached.Contains(j.Key) && !_inFlight.Contains(j.Key));
return next;
}
}
public bool TryTake(out ThumbnailJob job)
{
lock (_gate)
{
if (!_enabled)
{
job = default;
return false;
}
foreach (var candidate in _window)
{
if (_cached.Contains(candidate.Key) || _inFlight.Contains(candidate.Key))
{
continue;
}
_inFlight.Add(candidate.Key);
job = candidate;
return true;
}
}
job = default;
return false;
}
public bool IsCurrent(ThumbnailJob job)
{
lock (_gate)
{
return _enabled
&& job.Generation == _generation
&& _windowKeys.Contains(job.Key);
}
}
public void Complete(ThumbnailJob job, bool generated, bool failed)
{
lock (_gate)
{
_inFlight.Remove(job.Key);
if (failed)
{
_failed++;
_cached.Add(job.Key);
return;
}
if (generated)
{
_generated++;
_cached.Add(job.Key);
}
}
}
public ThumbnailSchedulerStats Snapshot()
{
lock (_gate)
{
var pending = _window.Count(j => !_cached.Contains(j.Key) && !_inFlight.Contains(j.Key));
return new ThumbnailSchedulerStats(
_generation,
_window.Count,
pending,
_inFlight.Count,
_requested,
_cacheHits,
_generated,
_cancelled,
_failed);
}
}
}

View File

@@ -15,7 +15,16 @@ public sealed record UiPreferences(
double? WindowLeft = null,
double? WindowTop = null,
bool WindowMaximized = false,
double? TreeWidth = null)
double? TreeWidth = null,
string? SevenZipPath = null,
string? GitPath = null,
string? OrganizePictures = null,
string? OrganizeVideos = null,
string? OrganizeAudio = null,
string? OrganizeDocuments = null,
string? OrganizeInstallers = null,
string? OrganizeArchives = null,
string? OrganizeDevelopment = null)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -60,6 +69,9 @@ public sealed class UiPreferencesStore
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. OrganizeLines(preferences),
.. LayoutLines(preferences)
]);
}
@@ -78,6 +90,15 @@ public sealed class UiPreferencesStore
var showHidden = true;
var showProtected = false;
var autoClearQueue = false;
string? sevenZipPath = null;
string? gitPath = null;
string? organizePictures = null;
string? organizeVideos = null;
string? organizeAudio = null;
string? organizeDocuments = null;
string? organizeInstallers = null;
string? organizeArchives = null;
string? organizeDevelopment = null;
double? windowWidth = null;
double? windowHeight = null;
double? windowLeft = null;
@@ -128,6 +149,42 @@ public sealed class UiPreferencesStore
{
autoClearQueue = IsTrue(value);
}
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("git", StringComparison.OrdinalIgnoreCase))
{
gitPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("organize-pictures", StringComparison.OrdinalIgnoreCase))
{
organizePictures = EmptyToNull(value);
}
else if (key.Equals("organize-videos", StringComparison.OrdinalIgnoreCase))
{
organizeVideos = EmptyToNull(value);
}
else if (key.Equals("organize-audio", StringComparison.OrdinalIgnoreCase))
{
organizeAudio = EmptyToNull(value);
}
else if (key.Equals("organize-documents", StringComparison.OrdinalIgnoreCase))
{
organizeDocuments = EmptyToNull(value);
}
else if (key.Equals("organize-installers", StringComparison.OrdinalIgnoreCase))
{
organizeInstallers = EmptyToNull(value);
}
else if (key.Equals("organize-archives", StringComparison.OrdinalIgnoreCase))
{
organizeArchives = EmptyToNull(value);
}
else if (key.Equals("organize-development", StringComparison.OrdinalIgnoreCase))
{
organizeDevelopment = EmptyToNull(value);
}
else if (key.Equals("window-width", StringComparison.OrdinalIgnoreCase))
{
windowWidth = ParseDouble(value);
@@ -156,7 +213,63 @@ public sealed class UiPreferencesStore
return new UiPreferences(
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth);
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment);
}
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.SevenZipPath))
{
yield return "seven-zip=" + preferences.SevenZipPath;
}
}
private static IEnumerable<string> GitLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.GitPath))
{
yield return "git=" + preferences.GitPath;
}
}
private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
{
yield return "organize-pictures=" + preferences.OrganizePictures;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeVideos))
{
yield return "organize-videos=" + preferences.OrganizeVideos;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeAudio))
{
yield return "organize-audio=" + preferences.OrganizeAudio;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeDocuments))
{
yield return "organize-documents=" + preferences.OrganizeDocuments;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeInstallers))
{
yield return "organize-installers=" + preferences.OrganizeInstallers;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeArchives))
{
yield return "organize-archives=" + preferences.OrganizeArchives;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeDevelopment))
{
yield return "organize-development=" + preferences.OrganizeDevelopment;
}
}
private static IEnumerable<string> LayoutLines(UiPreferences preferences)
@@ -203,6 +316,9 @@ public sealed class UiPreferencesStore
public static string NormalizeTheme(string? theme)
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
private static bool IsTrue(string value)
=> value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)

View File

@@ -15,6 +15,10 @@ public interface IIndexStore
IAnalysisStore Analysis { get; }
IHistoryStore History { get; }
IHashStore Hashes { get; }
IFileRelationStore Relations { get; }
IRenameBatchStore RenameBatches { get; }
ISyncProfileStore SyncProfiles { get; }
IOperationProfileStore OperationProfiles { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
@@ -78,6 +82,8 @@ public interface ITransferStore
{
Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TransferJob>> GetIncompleteAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<TransferJob>> GetHistoryAsync(int take, CancellationToken cancellationToken = default);
}
public interface ISearchStore
@@ -161,6 +167,36 @@ public interface IHashStore
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
}
public interface IFileRelationStore
{
Task<IReadOnlyList<FileRelation>> GetAmongAsync(IReadOnlyList<long> entryIds, CancellationToken cancellationToken = default);
Task UpsertAsync(FileRelation relation, CancellationToken cancellationToken = default);
Task DeleteAmongAsync(IReadOnlyList<long> entryIds, IReadOnlyList<FileRelationKind> kinds, CancellationToken cancellationToken = default);
}
public interface IRenameBatchStore
{
Task<long> CreateAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default);
Task<RenameBatch?> GetLatestUndoableAsync(CancellationToken cancellationToken = default);
Task MarkUndoneAsync(long id, CancellationToken cancellationToken = default);
}
public interface ISyncProfileStore
{
Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default);
Task<SyncProfile?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(SyncProfile profile, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}
public interface IOperationProfileStore
{
Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default);
Task<OperationProfile?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(OperationProfile profile, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}
public sealed class HashWorkItem
{
public long EntryId { get; init; }

View File

@@ -30,6 +30,18 @@ public interface IFileSystemEnumerator
IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath);
FileSystemItem? GetItem(string path);
IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error);
IEnumerable<FileSystemItem> EnumerateChildrenStreaming(
string directoryPath,
FileEnumerationSink sink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(sink);
cancellationToken.ThrowIfCancellationRequested();
var items = EnumerateChildrenSafe(directoryPath, out var error);
sink.Error = error;
return items;
}
}
public interface IUsnJournal
@@ -90,6 +102,7 @@ public interface IShellFileOperations
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, Func<bool>? pauseRequested = null);
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
bool EmptyRecycleBin(out string? error);
}
public interface IIconService

View File

@@ -5,7 +5,7 @@ public static class AppConstants
public const string ProductFolderName = "ExplorerWorkbench";
public const string DatabaseFileName = "index.db";
public const string LogFolderName = "logs";
public const int SchemaVersion = 3;
public const int SchemaVersion = 8;
public const int DefaultTombstoneRetentionDays = 30;
public const int ScanBatchSize = 3000;
public const int SearchPageSize = 500;
@@ -19,4 +19,5 @@ public static class AppConstants
public const int NetworkScanParallelism = 1;
public const int ProgressHzMilliseconds = 100;
public const int MaxArchiveEntries = 8000;
public static readonly TimeSpan SyncTimestampSkew = TimeSpan.FromSeconds(2);
}

View File

@@ -68,4 +68,18 @@ public static class ArchiveFormats
relative = string.Join('\\', parts);
return relative.Length > 0;
}
public static string Stem(string name)
{
var lower = name.ToLowerInvariant();
if (lower.EndsWith(".tar.gz", StringComparison.Ordinal)
|| lower.EndsWith(".tar.bz2", StringComparison.Ordinal)
|| lower.EndsWith(".tar.xz", StringComparison.Ordinal))
{
return name[..name.LastIndexOf(".tar", StringComparison.OrdinalIgnoreCase)];
}
var stem = Path.GetFileNameWithoutExtension(name);
return string.IsNullOrEmpty(stem) ? name : stem;
}
}

View File

@@ -0,0 +1,41 @@
namespace Explorer.Domain;
public sealed class FileEnumerationSink
{
public string? Error { get; set; }
}
public sealed class BrowseDelta
{
public required string Path { get; init; }
public bool IsOffline { get; init; }
public string? Error { get; init; }
public IReadOnlyList<FileSystemItem> Added { get; init; } = [];
public IReadOnlyList<FileSystemItem> Updated { get; init; } = [];
public bool EnumerationComplete { get; init; }
public bool HydrationComplete { get; init; }
public ItemHydrationFlags CompletedStages { get; init; }
}
public sealed class BrowseViewport
{
private readonly object _gate = new();
private string[] _visible = [];
public void SetVisible(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
lock (_gate)
{
_visible = paths.Count == 0 ? [] : [.. paths];
}
}
public IReadOnlyList<string> Snapshot()
{
lock (_gate)
{
return _visible;
}
}
}

View File

@@ -0,0 +1,136 @@
using Explorer.Domain.Abstractions;
namespace Explorer.Domain;
public sealed class ClassifiedDuplicateGroup
{
public required DuplicateGroup Group { get; init; }
public DuplicateClass Classification { get; init; }
public int UniqueFileCount { get; init; }
public long WastedBytes { get; init; }
}
public static class DuplicateClassifier
{
public static bool IsHardlinkOnly(IReadOnlyList<IndexEntry> entries)
{
if (entries.Count < 2)
{
return false;
}
long? sourceId = null;
long? fileId = null;
foreach (var entry in entries)
{
if (entry.FileId is not > 0)
{
return false;
}
sourceId ??= entry.SourceId;
fileId ??= entry.FileId;
if (entry.SourceId != sourceId || entry.FileId != fileId)
{
return false;
}
}
return true;
}
public static int UniqueFileCount(IReadOnlyList<IndexEntry> entries)
{
var ids = new HashSet<(long SourceId, long Identity)>();
foreach (var entry in entries)
{
var identity = entry.FileId is > 0 ? entry.FileId.Value : -entry.Id;
ids.Add((entry.SourceId, identity));
}
return ids.Count;
}
public static DuplicateClass Classify(IReadOnlyList<IndexEntry> entries, IReadOnlyList<FileRelation> relations)
{
if (IsHardlinkOnly(entries))
{
return DuplicateClass.Hardlink;
}
var kinds = relations.Select(r => r.Kind).ToHashSet();
if (kinds.Contains(FileRelationKind.SyncCopy))
{
return DuplicateClass.Synchronized;
}
if (kinds.Contains(FileRelationKind.BackupCopy))
{
return DuplicateClass.Backup;
}
if (kinds.Contains(FileRelationKind.IntentionalDuplicate))
{
return DuplicateClass.Intentional;
}
if (kinds.Contains(FileRelationKind.AccidentalDuplicate))
{
return DuplicateClass.Accidental;
}
return DuplicateClass.Unknown;
}
public static ClassifiedDuplicateGroup ClassifyGroup(DuplicateGroup group, IReadOnlyList<FileRelation> relations)
{
var classification = Classify(group.Entries, relations);
var unique = UniqueFileCount(group.Entries);
return new ClassifiedDuplicateGroup
{
Group = group,
Classification = classification,
UniqueFileCount = unique,
WastedBytes = classification == DuplicateClass.Hardlink || unique <= 1
? 0
: group.SizeBytes * (unique - 1)
};
}
public static bool IsIntentional(DuplicateClass classification)
=> classification is DuplicateClass.Intentional or DuplicateClass.Synchronized or DuplicateClass.Backup;
public static bool IsHiddenByDefault(DuplicateClass classification)
=> classification == DuplicateClass.Hardlink || IsIntentional(classification);
public static IReadOnlyList<FileRelation> RelationsFor(
IReadOnlyList<IndexEntry> entries,
IReadOnlyList<FileRelation> all)
{
var ids = entries.Select(e => e.Id).ToHashSet();
return all.Where(r => ids.Contains(r.LeftEntryId) && ids.Contains(r.RightEntryId)).ToList();
}
public static IEnumerable<(long Left, long Right)> Pairs(IReadOnlyList<IndexEntry> entries)
{
var ids = entries.Select(e => e.Id).Distinct().OrderBy(id => id).ToList();
for (var i = 0; i < ids.Count; i++)
{
for (var j = i + 1; j < ids.Count; j++)
{
yield return (ids[i], ids[j]);
}
}
}
public static string Label(DuplicateClass classification)
=> classification switch
{
DuplicateClass.Accidental => "Accidental",
DuplicateClass.Synchronized => "Synchronized",
DuplicateClass.Backup => "Backup",
DuplicateClass.Intentional => "Intentional",
DuplicateClass.Hardlink => "Hard link",
_ => "Unknown"
};
}

View File

@@ -122,7 +122,11 @@ public sealed class TransferJob
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? StartedUtc { get; set; }
public string? Error { get; set; }
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
public int RetryCount { get; set; }
public string? WaitReason { get; set; }
public int SortOrder { get; set; }
public bool Dismissed { get; set; }
public IReadOnlyList<string> AdditionalSources { get; set; } = [];
}
public sealed class FileSystemItem
@@ -143,7 +147,47 @@ public sealed class FileSystemItem
public string? DisplayName { get; init; }
public long? FreeSpaceBytes { get; init; }
public long? CapacityBytes { get; init; }
public bool AvailableToImport { get; init; }
public ItemHydrationFlags Hydration { get; init; } = ItemHydrationFlags.All;
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
public FileSystemItem Overlay(
long? sizeBytes = null,
DateTimeOffset? createdUtc = null,
DateTimeOffset? modifiedUtc = null,
int? attributes = null,
long? fileId = null,
int? reparseTag = null,
long? allocatedSizeBytes = null,
CloudPresence? cloud = null,
LocationInfo? location = null,
SizeKnowledge? sizeKnowledge = null,
string? displayName = null,
long? freeSpaceBytes = null,
long? capacityBytes = null,
bool? availableToImport = null,
ItemHydrationFlags? hydration = null)
=> new()
{
FullPath = FullPath,
Name = Name,
IsDirectory = IsDirectory,
SizeBytes = sizeBytes ?? SizeBytes,
CreatedUtc = createdUtc ?? CreatedUtc,
ModifiedUtc = modifiedUtc ?? ModifiedUtc,
Attributes = attributes ?? Attributes,
FileId = fileId ?? FileId,
ReparseTag = reparseTag ?? ReparseTag,
AllocatedSizeBytes = allocatedSizeBytes ?? AllocatedSizeBytes,
Cloud = cloud ?? Cloud,
Location = location ?? Location,
SizeKnowledge = sizeKnowledge ?? SizeKnowledge,
DisplayName = displayName ?? DisplayName,
FreeSpaceBytes = freeSpaceBytes ?? FreeSpaceBytes,
CapacityBytes = capacityBytes ?? CapacityBytes,
AvailableToImport = availableToImport ?? AvailableToImport,
Hydration = hydration ?? Hydration
};
}
public sealed record CloudPresence(
@@ -173,3 +217,13 @@ public sealed class FolderListing
public IReadOnlyList<FileSystemItem> Items { get; init; } = [];
public string? Error { get; init; }
}
public sealed class FileRelation
{
public long Id { get; set; }
public long LeftEntryId { get; init; }
public long RightEntryId { get; init; }
public FileRelationKind Kind { get; init; }
public FileRelationOrigin Origin { get; init; } = FileRelationOrigin.User;
public DateTimeOffset CreatedUtc { get; init; }
}

View File

@@ -14,6 +14,18 @@ public static class SourceKinds
public static bool IsNetwork(this SourceKind kind) => kind is SourceKind.Smb or SourceKind.Nfs;
}
[Flags]
public enum ItemHydrationFlags
{
None = 0,
Shell = 1,
Metadata = 2,
Location = 4,
Index = 8,
Provider = 16,
All = Shell | Metadata | Location | Index | Provider
}
public enum SourceStatus
{
Online,
@@ -81,7 +93,18 @@ public enum TransferOp
Move,
Delete,
Rename,
NewFolder
NewFolder,
EmptyRecycleBin,
Extract,
Compress,
AddToArchive,
VerifyArchive
}
public enum ArchiveFormat
{
Zip,
SevenZip
}
public enum TransferStatus
@@ -89,6 +112,7 @@ public enum TransferStatus
Queued,
Running,
Paused,
Waiting,
Cancelling,
Cancelled,
Failed,
@@ -122,6 +146,56 @@ public enum IndexFreshness
NotIndexed
}
public enum FileRelationKind
{
AccidentalDuplicate,
Hardlink,
SyncCopy,
BackupCopy,
ConvertedFrom,
ArchivedFrom,
RenamedFrom,
IntentionalDuplicate
}
public enum FileRelationOrigin
{
User,
Detected,
Sync
}
public enum DuplicateClass
{
Unknown,
Accidental,
Synchronized,
Backup,
Intentional,
Hardlink
}
public enum SyncMode
{
CopyUpdate,
Mirror
}
public enum FileCategory
{
Unknown,
Photos,
Video,
Audio,
Documents,
CodeRepository,
Installer,
Backup,
Archive,
SystemData,
BuildOutput
}
public static class AttributeFlags
{
public const int ReadOnly = 1;

View File

@@ -0,0 +1,170 @@
namespace Explorer.Domain;
public static class FileClassifier
{
private static readonly HashSet<string> Photos = new(StringComparer.OrdinalIgnoreCase)
{
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "ico", "jfif", "heic", "heif",
"raw", "cr2", "nef", "arw", "dng", "orf", "rw2"
};
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
{
"mp4", "mkv", "avi", "mov", "wmv", "webm", "m4v", "mpg", "mpeg", "ts", "mts", "m2ts", "3gp"
};
private static readonly HashSet<string> Audio = new(StringComparer.OrdinalIgnoreCase)
{
"mp3", "wav", "flac", "aac", "m4a", "ogg", "wma", "aiff", "aif", "opus"
};
private static readonly HashSet<string> Documents = new(StringComparer.OrdinalIgnoreCase)
{
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
"txt", "rtf", "md", "csv", "one"
};
private static readonly HashSet<string> Installers = new(StringComparer.OrdinalIgnoreCase)
{
"msi", "msix", "appx", "msixbundle", "exe", "iso", "img", "apk", "msp", "msu"
};
private static readonly HashSet<string> Backups = new(StringComparer.OrdinalIgnoreCase)
{
"bak", "old", "wbk"
};
private static readonly HashSet<string> BuildNames = new(StringComparer.OrdinalIgnoreCase)
{
"node_modules", "bin", "obj", ".vs", "packages", "dist", "__pycache__", ".idea", ".git"
};
private static readonly HashSet<string> PhotoFolders = new(StringComparer.OrdinalIgnoreCase)
{
"photos", "pictures", "dcim", "camera", "images"
};
private static readonly HashSet<string> VideoFolders = new(StringComparer.OrdinalIgnoreCase)
{
"videos", "movies", "film", "films"
};
private static readonly HashSet<string> AudioFolders = new(StringComparer.OrdinalIgnoreCase)
{
"music", "audio", "sound"
};
private static readonly HashSet<string> BackupFolders = new(StringComparer.OrdinalIgnoreCase)
{
"backup", "backups"
};
public static FileClassification Classify(
string name,
string fullPath,
bool isDirectory,
int attributes,
bool isRepoRoot = false,
IReadOnlyList<FileCategory>? childCategories = null)
{
var location = LocationClassifier.Classify(fullPath, name, attributes, isDirectory);
if (location.IsProtected || location.IsRecycleBin)
{
return new FileClassification(FileCategory.SystemData, "Protected Windows location.");
}
if (BuildNames.Contains(name))
{
return new FileClassification(FileCategory.BuildOutput, "Known build or tool folder.");
}
if (isRepoRoot)
{
return new FileClassification(FileCategory.CodeRepository, "Git repository.");
}
if (!isDirectory)
{
if (ArchiveFormats.IsArchive(name))
{
return new FileClassification(FileCategory.Archive, "Archive file.");
}
var ext = NameNormalizer.Extension(name);
if (ext is not null)
{
if (Photos.Contains(ext))
{
return new FileClassification(FileCategory.Photos, "Image file.");
}
if (Videos.Contains(ext))
{
return new FileClassification(FileCategory.Video, "Video file.");
}
if (Audio.Contains(ext))
{
return new FileClassification(FileCategory.Audio, "Audio file.");
}
if (Documents.Contains(ext))
{
return new FileClassification(FileCategory.Documents, "Document.");
}
if (Installers.Contains(ext))
{
return new FileClassification(FileCategory.Installer, "Installer.");
}
if (Backups.Contains(ext))
{
return new FileClassification(FileCategory.Backup, "Backup copy.");
}
}
return new FileClassification(FileCategory.Unknown, "No matching signal.");
}
if (PhotoFolders.Contains(name))
{
return new FileClassification(FileCategory.Photos, "Photo folder name.");
}
if (VideoFolders.Contains(name))
{
return new FileClassification(FileCategory.Video, "Video folder name.");
}
if (AudioFolders.Contains(name))
{
return new FileClassification(FileCategory.Audio, "Audio folder name.");
}
if (BackupFolders.Contains(name))
{
return new FileClassification(FileCategory.Backup, "Backup folder name.");
}
if (childCategories is { Count: > 0 })
{
var known = childCategories.Where(c => c is not FileCategory.Unknown and not FileCategory.BuildOutput and not FileCategory.SystemData).ToList();
if (known.Count > 0)
{
var majority = known.GroupBy(c => c).OrderByDescending(g => g.Count()).First();
if (majority.Count() * 10 >= known.Count * 7)
{
return new FileClassification(majority.Key, "Folder contents are mostly " + OrganizeDestinations.Label(majority.Key).ToLowerInvariant() + ".");
}
}
}
return new FileClassification(FileCategory.Unknown, "No matching signal.");
}
public static bool ShouldLeave(FileCategory category)
=> category is FileCategory.Unknown or FileCategory.SystemData or FileCategory.BuildOutput or FileCategory.Backup;
public static bool SkipDescent(string name) => BuildNames.Contains(name);
}

View File

@@ -0,0 +1,47 @@
namespace Explorer.Domain;
public sealed record GitStatus
{
public required string RepoRoot { get; init; }
public required string Branch { get; init; }
public int ModifiedCount { get; init; }
public int UntrackedCount { get; init; }
public int Ahead { get; init; }
public int Behind { get; init; }
public bool WorkingTreeClean => ModifiedCount == 0 && UntrackedCount == 0;
public string Badge
{
get
{
var parts = new List<string> { Branch };
if (ModifiedCount > 0)
{
parts.Add($"{ModifiedCount} modified");
}
if (UntrackedCount > 0)
{
parts.Add($"{UntrackedCount} untracked");
}
if (Ahead > 0)
{
parts.Add($"{Ahead} ahead");
}
if (Behind > 0)
{
parts.Add($"{Behind} behind");
}
if (WorkingTreeClean && Ahead == 0 && Behind == 0)
{
parts.Add("clean");
}
return string.Join(" · ", parts);
}
}
}

View File

@@ -5,7 +5,8 @@ public static class LocationRoots
public const string ThisPc = "This PC";
public const string Network = "Network";
public const string Cloud = "Cloud";
public const string RecycleBin = "Recycle Bin";
public static bool IsVirtual(string? path)
=> path is ThisPc or Network or Cloud;
=> path is ThisPc or Network or Cloud or RecycleBin;
}

View File

@@ -0,0 +1,35 @@
namespace Explorer.Domain;
public enum PlanIssueSeverity
{
Error,
Warning
}
public sealed record PlanIssue(PlanIssueSeverity Severity, string Message, string? Path = null);
public sealed record PlannedOperation(
TransferOp Op,
string SourcePath,
string? DestinationPath,
string? NewName = null);
public sealed class OperationPlan
{
public IReadOnlyList<PlannedOperation> Operations { get; init; } = [];
public IReadOnlyList<PlanIssue> Issues { get; init; } = [];
public IReadOnlyList<RenamePreviewRow> Preview { get; init; } = [];
public IReadOnlyList<SyncPreviewRow> SyncPreview { get; init; } = [];
public IReadOnlyList<ProfilePreviewRow> ProfilePreview { get; init; } = [];
public IReadOnlyList<OrganizePreviewRow> OrganizePreview { get; init; } = [];
public bool HasErrors => Issues.Any(i => i.Severity == PlanIssueSeverity.Error);
public bool CanEnqueue => !HasErrors && Operations.Count > 0;
}
public sealed record RenamePreviewRow(
string SourcePath,
string OldName,
string NewName,
string? Status,
bool Unchanged);

View File

@@ -0,0 +1,44 @@
namespace Explorer.Domain;
public sealed class OperationProfile
{
public long Id { get; set; }
public required string Name { get; set; }
public string SourcePath { get; set; } = "";
public string DestPath { get; set; } = "";
public bool RequireGitClean { get; set; }
public bool DoCompress { get; set; }
public ArchiveFormat ArchiveFormat { get; set; } = ArchiveFormat.SevenZip;
public bool DoCopy { get; set; }
public bool DoRename { get; set; }
public string RenamePrefix { get; set; } = "";
public string RenameSuffix { get; set; } = "";
public string RenameSearch { get; set; } = "";
public string RenameReplace { get; set; } = "";
public string Excludes { get; set; } = "";
public bool AutoRun { get; set; }
public string? SourceVolumeGuid { get; set; }
public string? DestVolumeGuid { get; set; }
public bool IsBuiltIn { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? LastRunUtc { get; set; }
public string? LastStatus { get; set; }
public bool HasRenameRules
=> DoRename && (!string.IsNullOrWhiteSpace(RenamePrefix)
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
public bool CanAutoRun => AutoRun && DoCopy && !DoCompress && !HasRenameRules;
public RenameRuleSet RenameRules()
=> new()
{
Prefix = RenamePrefix ?? "",
Suffix = RenameSuffix ?? "",
Search = RenameSearch ?? "",
Replace = RenameReplace ?? ""
};
}
public sealed record ProfilePreviewRow(string Action, string Path, string? Detail);

View File

@@ -0,0 +1,53 @@
namespace Explorer.Domain;
public sealed record FileClassification(FileCategory Category, string Reason);
public sealed record OrganizePreviewRow(
string Name,
string Category,
string Action,
string? Destination,
string? Detail);
public sealed class OrganizeDestinations
{
public string Pictures { get; set; } = "";
public string Videos { get; set; } = "";
public string Audio { get; set; } = "";
public string Documents { get; set; } = "";
public string Installers { get; set; } = "";
public string Archives { get; set; } = "";
public string Development { get; set; } = "";
public string? For(FileCategory category)
=> category switch
{
FileCategory.Photos => EmptyToNull(Pictures),
FileCategory.Video => EmptyToNull(Videos),
FileCategory.Audio => EmptyToNull(Audio),
FileCategory.Documents => EmptyToNull(Documents),
FileCategory.Installer => EmptyToNull(Installers),
FileCategory.Archive => EmptyToNull(Archives),
FileCategory.CodeRepository => EmptyToNull(Development),
_ => null
};
public static string Label(FileCategory category)
=> category switch
{
FileCategory.Photos => "Photos",
FileCategory.Video => "Video",
FileCategory.Audio => "Audio",
FileCategory.Documents => "Documents",
FileCategory.CodeRepository => "Code repository",
FileCategory.Installer => "Installer",
FileCategory.Backup => "Backup",
FileCategory.Archive => "Archive",
FileCategory.SystemData => "System data",
FileCategory.BuildOutput => "Build output",
_ => "Unknown"
};
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Domain;
public enum RenameCaseMode
{
Unchanged,
Lower,
Upper,
Title
}
public sealed record RenameSubject(string FullPath, string Name, bool IsDirectory);
public sealed class RenameRuleSet
{
public string Search { get; init; } = "";
public string Replace { get; init; } = "";
public bool UseRegex { get; init; }
public bool MatchCase { get; init; }
public bool IncludeExtensionInSearch { get; init; }
public string Prefix { get; init; } = "";
public string Suffix { get; init; } = "";
public bool UseCounter { get; init; }
public int CounterStart { get; init; } = 1;
public int CounterStep { get; init; } = 1;
public int CounterPadding { get; init; }
public RenameCaseMode CaseMode { get; init; }
public bool ChangeExtension { get; init; }
public string NewExtension { get; init; } = "";
}
public sealed class RenameBatch
{
public long Id { get; init; }
public DateTimeOffset CreatedUtc { get; init; }
public bool Undone { get; init; }
public IReadOnlyList<RenameBatchItem> Items { get; init; } = [];
}
public sealed record RenameBatchItem(string OldPath, string NewPath, int SortOrder);

View File

@@ -0,0 +1,19 @@
namespace Explorer.Domain;
public sealed class SyncProfile
{
public long Id { get; set; }
public required string Name { get; set; }
public required string SourcePath { get; set; }
public required string DestPath { get; set; }
public SyncMode Mode { get; set; } = SyncMode.CopyUpdate;
public string Excludes { get; set; } = "";
public bool AutoRun { get; set; }
public string? SourceVolumeGuid { get; set; }
public string? DestVolumeGuid { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? LastRunUtc { get; set; }
public string? LastStatus { get; set; }
}
public sealed record SyncPreviewRow(string RelativePath, string Action, string? Detail);

View File

@@ -0,0 +1,127 @@
namespace Explorer.Domain;
public static class WindowsFileNames
{
private static readonly HashSet<string> Reserved = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
public static bool IsValid(string name, out string? error)
{
if (string.IsNullOrWhiteSpace(name))
{
error = "Name is empty.";
return false;
}
if (name is "." or "..")
{
error = "Name is not allowed.";
return false;
}
if (name.Length > 255)
{
error = "Name is too long.";
return false;
}
if (name.EndsWith(' ') || name.EndsWith('.'))
{
error = "Name cannot end with a space or period.";
return false;
}
foreach (var c in name)
{
if (c is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*' || c < 32)
{
error = "Name contains characters Windows does not allow.";
return false;
}
}
var stem = name;
var dot = name.IndexOf('.');
if (dot >= 0)
{
stem = name[..dot];
}
if (Reserved.Contains(stem))
{
error = $"“{stem}” is a reserved Windows name.";
return false;
}
error = null;
return true;
}
public static (string Stem, string Extension) Split(string name)
{
var dot = name.LastIndexOf('.');
if (dot <= 0 || dot == name.Length - 1)
{
return (name, "");
}
return (name[..dot], name[(dot + 1)..]);
}
public static string Join(string stem, string extension)
{
extension = extension.Trim().TrimStart('.');
return string.IsNullOrEmpty(extension) ? stem : stem + "." + extension;
}
public static string ApplyCase(string value, RenameCaseMode mode)
=> mode switch
{
RenameCaseMode.Lower => value.ToLowerInvariant(),
RenameCaseMode.Upper => value.ToUpperInvariant(),
RenameCaseMode.Title => ToTitle(value),
_ => value
};
private static string ToTitle(string value)
{
if (value.Length == 0)
{
return value;
}
var chars = value.ToLowerInvariant().ToCharArray();
var word = true;
for (var i = 0; i < chars.Length; i++)
{
if (char.IsLetterOrDigit(chars[i]))
{
if (word)
{
chars[i] = char.ToUpperInvariant(chars[i]);
word = false;
}
}
else
{
word = true;
}
}
return new string(chars);
}
public static string FormatCounter(int value, int padding)
{
if (padding <= 0)
{
return value.ToString();
}
return value.ToString().PadLeft(padding, '0');
}
}

View File

@@ -0,0 +1,22 @@
namespace Explorer.FileOperations;
public static class FileOperationErrors
{
public const string FileInUse = "The file is in use. Retry when it is available.";
public const string NameExists = "A file with that name already exists.";
public const string DestinationUnavailable = "Destination unavailable";
public const string CloudHydration = "Online-only cloud files are not extracted or compressed.";
public static bool IsLock(string? error)
{
if (string.IsNullOrWhiteSpace(error))
{
return false;
}
return error.Contains("being used by another process", StringComparison.OrdinalIgnoreCase)
|| error.Contains("sharing violation", StringComparison.OrdinalIgnoreCase)
|| error.Contains("lock violation", StringComparison.OrdinalIgnoreCase)
|| error.Contains(FileInUse, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -62,6 +62,45 @@ public sealed class FileOperationService
return dest;
}
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> _queue.EnqueueRenameAsync(path, newName, cancellationToken);
public async Task EnqueueRenameAsync(IReadOnlyList<(string Path, string NewName)> items, CancellationToken cancellationToken = default)
{
foreach (var (path, newName) in items)
{
await _queue.EnqueueRenameAsync(path, newName, cancellationToken).ConfigureAwait(false);
}
}
public Task EmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> _queue.EnqueueEmptyRecycleBinAsync(cancellationToken);
public Task ExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> _queue.EnqueueExtractAsync(archivePath, destinationDirectory, cancellationToken);
public Task CompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueCompressAsync(sources, archivePath, cancellationToken);
public Task AddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> _queue.EnqueueAddToArchiveAsync(archivePath, sources, cancellationToken);
public Task VerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueVerifyArchiveAsync(archivePath, cancellationToken);
public static string UniqueArchivePath(string directory, string stem, string extension)
{
extension = extension.Trim().TrimStart('.');
var dest = Path.Combine(directory, stem + "." + extension);
var i = 2;
while (File.Exists(PathRules.ToExtended(dest)) || Directory.Exists(PathRules.ToExtended(dest)))
{
dest = Path.Combine(directory, $"{stem} ({i++}).{extension}");
}
return dest;
}
public void Rename(string path, string newName)
{
var parent = PathRules.Parent(path);

View File

@@ -0,0 +1,191 @@
using System.Collections.Concurrent;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class FolderSyncService
{
private readonly FolderSyncPlanner _planner;
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly FileOperationService _ops;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly ConcurrentDictionary<string, string> _pendingCopies = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public FolderSyncService(
FolderSyncPlanner planner,
IIndexStore store,
SourceManager sources,
FileOperationService ops,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration)
{
_planner = planner;
_store = store;
_sources = sources;
_ops = ops;
_volumes = volumes;
_enumerator = enumerator;
_hydration = hydration;
}
public Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default)
=> _store.SyncProfiles.ListAsync(cancellationToken);
public async Task<long> SaveAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{
if (profile.CreatedUtc == default)
{
profile.CreatedUtc = DateTimeOffset.UtcNow;
}
AttachVolumeGuids(profile);
profile.Id = await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id;
}
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken);
public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var sourcePath = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var destPath = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
return _planner.Build(
sourcePath,
destPath,
profile.Mode,
profile.Excludes,
_enumerator,
path => _volumes.IsPathReachable(path),
item => _hydration.WouldHydrateOnRead(item));
}
public async Task<OperationPlan> EnqueueAsync(SyncProfile profile, OperationPlan? plan = null, CancellationToken cancellationToken = default)
{
plan ??= await PreviewAsync(profile, cancellationToken).ConfigureAwait(false);
if (!plan.CanEnqueue)
{
return plan;
}
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{
_pendingCopies[op.DestinationPath] = op.SourcePath;
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
else if (op.Op == TransferOp.Delete)
{
await _ops.DeleteAsync([op.SourcePath], permanent: false, cancellationToken).ConfigureAwait(false);
}
}
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var deletes = plan.Operations.Count(o => o.Op == TransferOp.Delete);
profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = deletes > 0
? $"Queued {copies} copy, {deletes} delete"
: $"Queued {copies} copy";
await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return plan;
}
public async Task TryAutoRunAsync(CancellationToken cancellationToken = default)
{
var profiles = await _store.SyncProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var profile in profiles)
{
if (!profile.AutoRun || profile.Mode != SyncMode.CopyUpdate)
{
continue;
}
var dest = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
var source = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var ready = _volumes.IsPathReachable(source)
&& (_volumes.IsPathReachable(dest) || _volumes.IsPathReachable(PathRules.Parent(dest)));
if (!_autoRunOnline.TryGetValue(profile.Id, out var wasReady))
{
_autoRunOnline[profile.Id] = ready;
continue;
}
if (!ready)
{
_autoRunOnline[profile.Id] = false;
continue;
}
if (wasReady)
{
continue;
}
_autoRunOnline[profile.Id] = true;
var plan = await PreviewAsync(profile, cancellationToken).ConfigureAwait(false);
if (plan.CanEnqueue)
{
await EnqueueAsync(profile, plan, cancellationToken).ConfigureAwait(false);
}
}
}
public async Task TryMarkRelationAsync(TransferJob job, CancellationToken cancellationToken = default)
{
if (job.Op != TransferOp.Copy || job.Status != TransferStatus.Done || job.DestinationPath is null)
{
return;
}
if (!_pendingCopies.TryRemove(job.DestinationPath, out var sourcePath))
{
return;
}
var leftSource = await _sources.FindByPathAsync(sourcePath, cancellationToken).ConfigureAwait(false);
var rightSource = await _sources.FindByPathAsync(job.DestinationPath, cancellationToken).ConfigureAwait(false);
if (leftSource is not { IsIndexed: true, LastRootPath: not null }
|| rightSource is not { IsIndexed: true, LastRootPath: not null })
{
return;
}
var leftRel = PathRules.MakeRelative(leftSource.LastRootPath, sourcePath);
var rightRel = PathRules.MakeRelative(rightSource.LastRootPath, job.DestinationPath);
var left = await _store.Entries.GetByPathAsync(leftSource.Id, leftRel, cancellationToken).ConfigureAwait(false);
var right = await _store.Entries.GetByPathAsync(rightSource.Id, rightRel, cancellationToken).ConfigureAwait(false);
if (left is null || right is null)
{
return;
}
await _store.Relations.UpsertAsync(new FileRelation
{
LeftEntryId = left.Id,
RightEntryId = right.Id,
Kind = FileRelationKind.SyncCopy,
Origin = FileRelationOrigin.Sync,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
}
private void AttachVolumeGuids(SyncProfile profile)
{
var src = _volumes.Probe(profile.SourcePath);
var dst = _volumes.Probe(profile.DestPath);
profile.SourceVolumeGuid = src?.VolumeGuid ?? profile.SourceVolumeGuid;
profile.DestVolumeGuid = dst?.VolumeGuid ?? profile.DestVolumeGuid;
}
}

View File

@@ -0,0 +1,9 @@
using Explorer.Domain;
namespace Explorer.FileOperations;
public interface IOperationExecutor
{
bool CanExecute(TransferOp op);
Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,441 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class NativeFileOperationExecutor : IOperationExecutor
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration;
public NativeFileOperationExecutor(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null)
{
_shell = shell;
_enumerator = enumerator;
_archives = archives;
_hydration = hydration;
}
public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
or TransferOp.AddToArchive or TransferOp.VerifyArchive;
public async Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
{
switch (job.Op)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, pauseRequested, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, pauseRequested, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
case TransferOp.Rename:
Rename(job);
break;
case TransferOp.EmptyRecycleBin:
EmptyRecycleBin(job);
break;
case TransferOp.Extract:
case TransferOp.Compress:
case TransferOp.AddToArchive:
case TransferOp.VerifyArchive:
await ArchiveAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
default:
job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}";
break;
}
}
private async Task CopyOrMove(
TransferJob job,
bool move,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, pauseRequested, reportProgress, stoppingToken).ConfigureAwait(false);
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 */ }
}
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, pauseRequested, reportProgress, stoppingToken, out var error))
{
ApplyHalt(job, src, error, pauseRequested);
return;
}
job.BytesDone = job.BytesTotal ?? job.BytesDone;
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(
string src,
string dst,
bool move,
TransferJob job,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
job.FilesDone = 0;
job.FilesTotal = 0;
job.BytesDone = 0;
job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling || pauseRequested())
{
ApplyHalt(job, job.CurrentPath, pauseRequested() ? "Paused" : "Cancelled", pauseRequested);
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
continue;
}
job.FilesTotal++;
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var destExists = File.Exists(PathRules.ToExtended(childDest));
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
if (destExists && destLen == child.SizeBytes)
{
job.BytesDone += child.SizeBytes;
job.FilesDone++;
reportProgress?.Invoke();
continue;
}
job.CurrentPath = child.FullPath;
var committed = job.BytesDone;
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, pauseRequested, reportProgress, stoppingToken, out var err))
{
ApplyHalt(job, child.FullPath, err, pauseRequested);
return;
}
job.BytesDone = committed + child.SizeBytes;
job.FilesDone++;
reportProgress?.Invoke();
}
}
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private bool TransferFile(
string src,
string dst,
bool move,
bool resumePartial,
TransferJob job,
long committed,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken,
out string? error)
{
var progress = new Progress<long>(b =>
{
job.BytesDone = committed + b;
reportProgress?.Invoke();
});
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 static void ApplyHalt(TransferJob job, string? path, string? error, Func<bool> pauseRequested)
{
job.CurrentPath = path;
if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
{
job.Status = TransferStatus.Cancelling;
job.Error = error == "Cancelled" ? null : error;
return;
}
if (pauseRequested() || error == "Paused")
{
job.Status = TransferStatus.Paused;
job.Error = null;
return;
}
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(error) ? FileOperationErrors.FileInUse : error;
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
if (!_shell.Delete(paths, recycle, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(error) ? FileOperationErrors.FileInUse : error;
}
}
private static void Rename(TransferJob job)
{
var src = PathRules.ToExtended(job.SourcePath);
var dst = PathRules.ToExtended(job.DestinationPath ?? throw new InvalidOperationException("Missing destination"));
if (File.Exists(dst) || Directory.Exists(dst))
{
var same = job.SourcePath.Equals(job.DestinationPath, StringComparison.OrdinalIgnoreCase);
if (!same)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.NameExists;
return;
}
var parent = PathRules.Parent(job.SourcePath);
var temp = PathRules.Combine(parent, ".ew-rename-" + Guid.NewGuid().ToString("N"));
try
{
Move(src, PathRules.ToExtended(temp));
Move(PathRules.ToExtended(temp), dst);
}
catch (IOException ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
return;
}
try
{
Move(src, dst);
}
catch (IOException ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
}
private static void Move(string src, string dst)
{
if (Directory.Exists(src))
{
Directory.Move(src, dst);
}
else if (File.Exists(src))
{
File.Move(src, dst);
}
else
{
throw new FileNotFoundException("Source not found", src);
}
}
private void EmptyRecycleBin(TransferJob job)
{
if (!_shell.EmptyRecycleBin(out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private async Task ArchiveAsync(TransferJob job, Action? reportProgress, CancellationToken cancellationToken)
{
if (_archives is null || !_archives.IsAvailable)
{
job.Status = TransferStatus.Failed;
job.Error = _archives?.MissingHint ?? SevenZipLocator.MissingHint;
return;
}
var progress = new Progress<ArchiveProgress>(p =>
{
job.BytesTotal = 100;
job.BytesDone = p.Percent;
job.FilesDone = p.FilesDone;
if (!string.IsNullOrWhiteSpace(p.CurrentPath))
{
job.CurrentPath = p.CurrentPath;
}
reportProgress?.Invoke();
});
try
{
switch (job.Op)
{
case TransferOp.Extract:
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
await _archives.ExtractAsync(
job.SourcePath,
job.DestinationPath ?? throw new InvalidOperationException("Missing destination"),
progress,
cancellationToken)
.ConfigureAwait(false);
break;
case TransferOp.Compress:
var compressSources = ArchiveSources(job);
if (await AnyHydrateAsync(compressSources, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
var archive = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
if (File.Exists(PathRules.ToExtended(archive)) || Directory.Exists(PathRules.ToExtended(archive)))
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.NameExists;
return;
}
var format = ArchiveFormats.IsZipFamily(archive) ? ArchiveFormat.Zip : ArchiveFormat.SevenZip;
await _archives.CompressAsync(compressSources, archive, format, progress, cancellationToken)
.ConfigureAwait(false);
break;
case TransferOp.AddToArchive:
var addSources = ArchiveSources(job);
var addArchive = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
if (await AnyHydrateAsync(addSources.Append(addArchive), cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
if (!File.Exists(PathRules.ToExtended(addArchive)))
{
job.Status = TransferStatus.Failed;
job.Error = "Archive not found.";
return;
}
await _archives.AddAsync(addArchive, addSources, progress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.VerifyArchive:
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
await _archives.VerifyAsync(job.SourcePath, progress, cancellationToken).ConfigureAwait(false);
break;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
}
private static IReadOnlyList<string> ArchiveSources(TransferJob job)
=> job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
private static void FailHydration(TransferJob job)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.CloudHydration;
}
private async Task<bool> AnyHydrateAsync(IEnumerable<string> paths, CancellationToken cancellationToken)
{
foreach (var path in paths)
{
if (await WouldHydrateAsync(path, cancellationToken).ConfigureAwait(false))
{
return true;
}
}
return false;
}
private async Task<bool> WouldHydrateAsync(string path, CancellationToken cancellationToken)
{
if (_hydration is null)
{
return false;
}
var item = _enumerator.GetItem(path);
if (item is not null && _hydration.WouldHydrateOnRead(item))
{
return true;
}
return await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,88 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
internal static class OperationAvailability
{
public static bool IsReady(IVolumeService volumes, TransferJob job)
{
foreach (var path in PathsToCheck(job))
{
if (!IsVolumeReachable(volumes, path))
{
return false;
}
}
return true;
}
public static IEnumerable<string> PathsToCheck(TransferJob job)
{
switch (job.Op)
{
case TransferOp.Delete:
if (job.AdditionalSources.Count > 0)
{
foreach (var src in job.AdditionalSources)
{
yield return src;
}
yield break;
}
foreach (var src in job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
yield return src;
}
yield break;
case TransferOp.Copy:
case TransferOp.Move:
case TransferOp.Rename:
case TransferOp.Extract:
case TransferOp.Compress:
case TransferOp.AddToArchive:
if (!string.IsNullOrWhiteSpace(job.DestinationPath))
{
yield return PathRules.Parent(job.DestinationPath);
}
yield break;
case TransferOp.VerifyArchive:
yield return job.SourcePath;
yield break;
case TransferOp.EmptyRecycleBin:
yield break;
}
}
public static bool IsVolumeReachable(IVolumeService volumes, string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return true;
}
if (volumes.IsPathReachable(path))
{
return true;
}
var root = PathRules.VolumeRoot(path);
if (string.IsNullOrWhiteSpace(root))
{
return false;
}
if (!string.Equals(root, path, StringComparison.OrdinalIgnoreCase) && volumes.IsPathReachable(root))
{
return true;
}
var slashed = root.EndsWith('\\') ? root : root + "\\";
return volumes.IsPathReachable(slashed);
}
}

View File

@@ -0,0 +1,262 @@
using System.Collections.Concurrent;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class OperationProfileService
{
private readonly FileOperationProfilePlanner _planner;
private readonly IIndexStore _store;
private readonly FileOperationService _ops;
private readonly RenameBatchService _renames;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IGitStatusProvider _git;
private readonly IHydrationGuard _hydration;
private readonly IArchiveExecutor _archives;
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public OperationProfileService(
FileOperationProfilePlanner planner,
IIndexStore store,
FileOperationService ops,
RenameBatchService renames,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IGitStatusProvider git,
IHydrationGuard hydration,
IArchiveExecutor archives)
{
_planner = planner;
_store = store;
_ops = ops;
_renames = renames;
_volumes = volumes;
_enumerator = enumerator;
_git = git;
_hydration = hydration;
_archives = archives;
}
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await EnsureDefaultsAsync(cancellationToken).ConfigureAwait(false);
return await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<long> SaveAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{
if (profile.CreatedUtc == default)
{
profile.CreatedUtc = DateTimeOffset.UtcNow;
}
AttachVolumeGuids(profile);
profile.AutoRun = profile.CanAutoRun;
profile.Id = await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id;
}
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken);
public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{
var copy = Clone(profile);
copy.Id = 0;
copy.IsBuiltIn = false;
copy.Name = profile.Name + " copy";
copy.LastRunUtc = null;
copy.LastStatus = null;
copy.CreatedUtc = DateTimeOffset.UtcNow;
copy.Id = await SaveAsync(copy, cancellationToken).ConfigureAwait(false);
return copy;
}
public async Task<OperationPlan> PreviewAsync(
OperationProfile profile,
IReadOnlyList<string>? sourceOverride = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var sourcePath = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, known);
var destPath = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, known);
var working = Clone(profile);
working.Id = profile.Id;
working.SourcePath = sourcePath;
working.DestPath = destPath;
working.IsBuiltIn = profile.IsBuiltIn;
var sources = sourceOverride is { Count: > 0 }
? sourceOverride
: string.IsNullOrWhiteSpace(sourcePath) ? [] : new[] { sourcePath };
var gitPath = sources.Count > 0 ? sources[0] : sourcePath;
GitStatus? git = null;
if (working.RequireGitClean && !string.IsNullOrWhiteSpace(gitPath))
{
git = await _git.GetStatusAsync(gitPath, cancellationToken).ConfigureAwait(false);
}
return _planner.Build(
working,
sources,
_enumerator,
path => _volumes.IsPathReachable(path),
git,
_git.IsAvailable,
_archives.IsAvailable,
_archives.MissingHint,
RenameBatchService.PathExists,
item => _hydration.WouldHydrateOnRead(item));
}
public async Task<OperationPlan> EnqueueAsync(
OperationProfile profile,
OperationPlan? plan = null,
IReadOnlyList<string>? sourceOverride = null,
CancellationToken cancellationToken = default)
{
plan ??= await PreviewAsync(profile, sourceOverride, cancellationToken).ConfigureAwait(false);
if (!plan.CanEnqueue)
{
return plan;
}
var renames = plan.Operations.Where(o => o.Op == TransferOp.Rename).ToList();
if (renames.Count > 0)
{
await _renames.EnqueueAsync(new OperationPlan { Operations = renames }, cancellationToken)
.ConfigureAwait(false);
}
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Compress && op.DestinationPath is not null)
{
var parts = op.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
await _ops.CompressAsync(parts, op.DestinationPath, cancellationToken).ConfigureAwait(false);
}
else if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
}
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var compress = plan.Operations.Count(o => o.Op == TransferOp.Compress);
var renamed = renames.Count;
profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename";
await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return plan;
}
public async Task TryAutoRunAsync(CancellationToken cancellationToken = default)
{
var profiles = await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var profile in profiles)
{
if (!profile.CanAutoRun)
{
continue;
}
var dest = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
var source = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var ready = !string.IsNullOrWhiteSpace(source)
&& !string.IsNullOrWhiteSpace(dest)
&& _volumes.IsPathReachable(source)
&& (_volumes.IsPathReachable(dest) || _volumes.IsPathReachable(PathRules.Parent(dest)));
if (!_autoRunOnline.TryGetValue(profile.Id, out var wasReady))
{
_autoRunOnline[profile.Id] = ready;
continue;
}
if (!ready)
{
_autoRunOnline[profile.Id] = false;
continue;
}
if (wasReady)
{
continue;
}
_autoRunOnline[profile.Id] = true;
var plan = await PreviewAsync(profile, cancellationToken: cancellationToken).ConfigureAwait(false);
if (plan.CanEnqueue)
{
await EnqueueAsync(profile, plan, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private async Task EnsureDefaultsAsync(CancellationToken cancellationToken)
{
var existing = await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
if (existing.Count > 0)
{
return;
}
var now = DateTimeOffset.UtcNow;
await _store.OperationProfiles.UpsertAsync(new OperationProfile
{
Name = "Archive folder",
RequireGitClean = true,
DoCompress = true,
ArchiveFormat = ArchiveFormat.SevenZip,
Excludes = ".git\nbin\nobj\n.vs",
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
await _store.OperationProfiles.UpsertAsync(new OperationProfile
{
Name = "Copy to destination",
DoCopy = true,
AutoRun = true,
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
}
private void AttachVolumeGuids(OperationProfile profile)
{
if (!string.IsNullOrWhiteSpace(profile.SourcePath))
{
profile.SourceVolumeGuid = _volumes.Probe(profile.SourcePath)?.VolumeGuid ?? profile.SourceVolumeGuid;
}
if (!string.IsNullOrWhiteSpace(profile.DestPath))
{
profile.DestVolumeGuid = _volumes.Probe(profile.DestPath)?.VolumeGuid ?? profile.DestVolumeGuid;
}
}
private static OperationProfile Clone(OperationProfile profile)
=> new()
{
Name = profile.Name,
SourcePath = profile.SourcePath,
DestPath = profile.DestPath,
RequireGitClean = profile.RequireGitClean,
DoCompress = profile.DoCompress,
ArchiveFormat = profile.ArchiveFormat,
DoCopy = profile.DoCopy,
DoRename = profile.DoRename,
RenamePrefix = profile.RenamePrefix,
RenameSuffix = profile.RenameSuffix,
RenameSearch = profile.RenameSearch,
RenameReplace = profile.RenameReplace,
Excludes = profile.Excludes,
AutoRun = profile.AutoRun,
SourceVolumeGuid = profile.SourceVolumeGuid,
DestVolumeGuid = profile.DestVolumeGuid
};
}

View File

@@ -0,0 +1,79 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class RenameBatchService
{
private readonly RenamePlanner _planner;
private readonly IIndexStore _store;
private readonly FileOperationService _ops;
public RenameBatchService(RenamePlanner planner, IIndexStore store, FileOperationService ops)
{
_planner = planner;
_store = store;
_ops = ops;
}
public static bool PathExists(string path)
{
var ext = PathRules.ToExtended(path);
return File.Exists(ext) || Directory.Exists(ext);
}
public OperationPlan Preview(IReadOnlyList<RenameSubject> subjects, RenameRuleSet rules)
=> _planner.Build(subjects, rules, PathExists);
public async Task<long> EnqueueAsync(OperationPlan plan, CancellationToken cancellationToken = default)
{
if (!plan.CanEnqueue)
{
throw new InvalidOperationException(plan.Issues.FirstOrDefault()?.Message ?? "Rename plan has errors.");
}
var items = plan.Operations
.Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i))
.ToList();
var batchId = await _store.RenameBatches.CreateAsync(items, cancellationToken).ConfigureAwait(false);
await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken)
.ConfigureAwait(false);
return batchId;
}
public Task<RenameBatch?> GetUndoableAsync(CancellationToken cancellationToken = default)
=> _store.RenameBatches.GetLatestUndoableAsync(cancellationToken);
public async Task<OperationPlan> UndoLastAsync(CancellationToken cancellationToken = default)
{
var batch = await _store.RenameBatches.GetLatestUndoableAsync(cancellationToken).ConfigureAwait(false);
if (batch is null)
{
return new OperationPlan
{
Issues = [new PlanIssue(PlanIssueSeverity.Warning, "No rename batch to undo.")]
};
}
var plan = _planner.BuildUndo(batch, PathExists);
if (plan.HasErrors || plan.Operations.Count == 0)
{
if (!plan.HasErrors)
{
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
}
return plan;
}
await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken)
.ConfigureAwait(false);
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
return plan;
}
}

View File

@@ -0,0 +1,108 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class ReorganizeService
{
private readonly ReorganizePlanner _planner;
private readonly FileOperationService _ops;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly IGitStatusProvider _git;
private readonly UiPreferencesStore _preferences;
public ReorganizeService(
ReorganizePlanner planner,
FileOperationService ops,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration,
IGitStatusProvider git,
UiPreferencesStore preferences)
{
_planner = planner;
_ops = ops;
_volumes = volumes;
_enumerator = enumerator;
_hydration = hydration;
_git = git;
_preferences = preferences;
}
public OrganizeDestinations LoadDestinations()
{
var prefs = _preferences.Load();
return new OrganizeDestinations
{
Pictures = First(prefs.OrganizePictures, Known(Environment.SpecialFolder.MyPictures)),
Videos = First(prefs.OrganizeVideos, Known(Environment.SpecialFolder.MyVideos)),
Audio = First(prefs.OrganizeAudio, Known(Environment.SpecialFolder.MyMusic)),
Documents = First(prefs.OrganizeDocuments, Known(Environment.SpecialFolder.MyDocuments)),
Installers = prefs.OrganizeInstallers ?? "",
Archives = prefs.OrganizeArchives ?? "",
Development = prefs.OrganizeDevelopment ?? ""
};
}
public void SaveDestinations(OrganizeDestinations destinations)
{
var stored = _preferences.Load();
_preferences.Save(stored with
{
OrganizePictures = EmptyToNull(destinations.Pictures),
OrganizeVideos = EmptyToNull(destinations.Videos),
OrganizeAudio = EmptyToNull(destinations.Audio),
OrganizeDocuments = EmptyToNull(destinations.Documents),
OrganizeInstallers = EmptyToNull(destinations.Installers),
OrganizeArchives = EmptyToNull(destinations.Archives),
OrganizeDevelopment = EmptyToNull(destinations.Development)
});
}
public OperationPlan Preview(string sourceRoot, OrganizeDestinations destinations)
=> _planner.Build(
sourceRoot,
destinations,
_enumerator,
path => _volumes.IsPathReachable(path),
path => _git.IsRepoRoot(path),
item => _hydration.WouldHydrateOnRead(item),
RenameBatchService.PathExists);
public async Task<OperationPlan> EnqueueAsync(
string sourceRoot,
OrganizeDestinations destinations,
OperationPlan? plan = null,
CancellationToken cancellationToken = default)
{
plan ??= Preview(sourceRoot, destinations);
if (!plan.CanEnqueue)
{
return plan;
}
SaveDestinations(destinations);
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Move && op.DestinationPath is not null)
{
await _ops.MoveAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
}
return plan;
}
private static string Known(Environment.SpecialFolder folder)
=> Environment.GetFolderPath(folder);
private static string First(string? stored, string fallback)
=> string.IsNullOrWhiteSpace(stored) ? fallback : stored.Trim();
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -7,9 +7,9 @@ namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly ILogger<TransferQueue> _logger;
private readonly List<TransferJob> _jobs = [];
private readonly object _gate = new();
@@ -20,19 +20,20 @@ public sealed class TransferQueue : BackgroundService
private long? _holdJobId;
private CancellationTokenSource? _runningCts;
private DateTime _lastChangedUtc = DateTime.MinValue;
private int _restored;
public event EventHandler? Changed;
public event EventHandler<TransferJob>? JobFinished;
public TransferQueue(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IOperationExecutor executor,
IIndexStore store,
IVolumeService volumes,
ILogger<TransferQueue> logger)
{
_shell = shell;
_enumerator = enumerator;
_executor = executor;
_store = store;
_volumes = volumes;
_logger = logger;
_pauseRequested = () => _haltPause;
}
@@ -92,6 +93,69 @@ public sealed class TransferQueue : BackgroundService
}, cancellationToken).ConfigureAwait(false);
}
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
{
var dest = Path.Combine(PathRules.Parent(path), newName);
return EnqueueAsync(new TransferJob
{
Op = TransferOp.Rename,
SourcePath = path,
DestinationPath = dest,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
}
public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.EmptyRecycleBin,
SourcePath = LocationRoots.RecycleBin,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Extract,
SourcePath = archivePath,
DestinationPath = destinationDirectory,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Compress,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.AddToArchive,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.VerifyArchive,
SourcePath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public void PauseAll()
{
_queuePaused = true;
@@ -110,6 +174,7 @@ public sealed class TransferQueue : BackgroundService
&& (j.StartedUtc is not null || j.CurrentPath is not null)))
{
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
}
@@ -130,6 +195,7 @@ public sealed class TransferQueue : BackgroundService
if (job.Status == TransferStatus.Queued)
{
job.Status = TransferStatus.Paused;
QueuePersist(job);
}
else if (job.Status == TransferStatus.Running)
{
@@ -152,6 +218,7 @@ public sealed class TransferQueue : BackgroundService
}
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
_queuePaused = false;
@@ -165,6 +232,27 @@ public sealed class TransferQueue : BackgroundService
RaiseChanged();
}
public void Retry(long jobId)
{
lock (_gate)
{
var job = Find(jobId);
if (job is null || job.Status != TransferStatus.Failed)
{
return;
}
job.RetryCount++;
job.Status = TransferStatus.Queued;
job.Error = null;
job.WaitReason = null;
QueuePersist(job);
}
Pulse();
RaiseChanged();
}
public void Cancel(long jobId)
{
CancellationTokenSource? running = null;
@@ -176,9 +264,10 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Paused)
if (job.Status is TransferStatus.Queued or TransferStatus.Paused or TransferStatus.Waiting)
{
job.Status = TransferStatus.Cancelled;
QueuePersist(job);
_jobs.Remove(job);
if (_holdJobId == jobId)
{
@@ -195,6 +284,8 @@ public sealed class TransferQueue : BackgroundService
}
else
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
}
@@ -207,7 +298,15 @@ public sealed class TransferQueue : BackgroundService
{
lock (_gate)
{
_jobs.RemoveAll(j => j.Id == jobId && j.Status is TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed);
var job = Find(jobId);
if (job is null || job.Status is not (TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed))
{
return;
}
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
RaiseChanged();
@@ -218,7 +317,13 @@ public sealed class TransferQueue : BackgroundService
var removed = false;
lock (_gate)
{
removed = _jobs.RemoveAll(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled) > 0;
foreach (var job in _jobs.Where(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled).ToList())
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
removed = true;
}
}
if (removed)
@@ -255,6 +360,7 @@ public sealed class TransferQueue : BackgroundService
_jobs.RemoveAt(index);
_jobs.Insert(target, job);
PersistOrder();
}
Pulse();
@@ -262,12 +368,47 @@ public sealed class TransferQueue : BackgroundService
return true;
}
public void NotifyAvailability()
{
var resumed = false;
lock (_gate)
{
foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting))
{
if (!OperationAvailability.IsReady(_volumes, job))
{
continue;
}
job.Status = TransferStatus.Queued;
job.WaitReason = null;
QueuePersist(job);
resumed = true;
}
}
if (resumed)
{
Pulse();
RaiseChanged();
}
}
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
}
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
lock (_gate)
{
_jobs.Add(job);
if (_jobs.All(j => j.Id != job.Id))
{
_jobs.Add(job);
}
}
Pulse();
@@ -276,6 +417,7 @@ public sealed class TransferQueue : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RestoreAsync(stoppingToken).ConfigureAwait(false);
while (!stoppingToken.IsCancellationRequested)
{
TransferJob? job;
@@ -298,10 +440,71 @@ public sealed class TransferQueue : BackgroundService
continue;
}
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
continue;
}
await RunAsync(job, stoppingToken).ConfigureAwait(false);
}
}
private async Task RestoreAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _restored, 1) == 1)
{
return;
}
IReadOnlyList<TransferJob> incomplete;
try
{
incomplete = await _store.Transfers.GetIncompleteAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to restore file operations queue");
return;
}
var interrupted = new List<TransferJob>();
lock (_gate)
{
var known = _jobs.Select(j => j.Id).ToHashSet();
foreach (var job in incomplete)
{
if (!known.Add(job.Id))
{
continue;
}
if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
{
job.Status = TransferStatus.Paused;
job.Error = null;
interrupted.Add(job);
}
_jobs.Add(job);
}
}
foreach (var job in interrupted)
{
await Persist(job).ConfigureAwait(false);
}
if (incomplete.Count > 0)
{
Pulse();
RaiseChanged();
}
}
private bool CanStartNext()
{
if (_queuePaused)
@@ -324,20 +527,17 @@ public sealed class TransferQueue : BackgroundService
job.Status = TransferStatus.Running;
job.StartedUtc ??= DateTimeOffset.UtcNow;
job.Error = null;
job.WaitReason = null;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
try
{
switch (job.Op)
await _executor.ExecuteAsync(job, _pauseRequested, () => RaiseChanged(throttled: true), linked.Token)
.ConfigureAwait(false);
if (_haltPause && job.Status == TransferStatus.Paused)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
_haltPause = false;
}
if (job.Status == TransferStatus.Paused)
@@ -347,6 +547,13 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status == TransferStatus.Waiting)
{
await Persist(job).ConfigureAwait(false);
RaiseChanged();
return;
}
if (job.Status == TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelled;
@@ -369,7 +576,7 @@ public sealed class TransferQueue : BackgroundService
{
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
job.Status = TransferStatus.Failed;
job.Error = ex.Message;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
finally
{
@@ -389,166 +596,6 @@ public sealed class TransferQueue : BackgroundService
JobFinished?.Invoke(this, job);
}
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
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 */ }
}
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, stoppingToken, out var error))
{
ApplyHalt(job, src, error);
return;
}
job.BytesDone = job.BytesTotal ?? job.BytesDone;
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
job.FilesDone = 0;
job.FilesTotal = 0;
job.BytesDone = 0;
job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling || _haltPause)
{
ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
continue;
}
job.FilesTotal++;
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var destExists = File.Exists(PathRules.ToExtended(childDest));
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
if (destExists && destLen == child.SizeBytes)
{
job.BytesDone += child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
continue;
}
job.CurrentPath = child.FullPath;
var committed = job.BytesDone;
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, stoppingToken, out var err))
{
ApplyHalt(job, child.FullPath, err);
return;
}
job.BytesDone = committed + child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
}
}
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private bool TransferFile(
string src,
string dst,
bool move,
bool resumePartial,
TransferJob job,
long committed,
CancellationToken stoppingToken,
out string? error)
{
var progress = new Progress<long>(b =>
{
job.BytesDone = committed + b;
RaiseChanged(throttled: true);
});
var ok = move
? _shell.MoveFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested)
: _shell.CopyFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested);
return ok;
}
private void ApplyHalt(TransferJob job, string? path, string? error)
{
job.CurrentPath = path;
if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
{
job.Status = TransferStatus.Cancelling;
job.Error = error == "Cancelled" ? null : error;
return;
}
if (_haltPause || error == "Paused")
{
job.Status = TransferStatus.Paused;
job.Error = null;
_haltPause = false;
return;
}
job.Status = TransferStatus.Failed;
job.Error = error;
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
if (!_shell.Delete(paths, recycle, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId);
private void Pulse()
@@ -575,5 +622,28 @@ public sealed class TransferQueue : BackgroundService
Changed?.Invoke(this, EventArgs.Empty);
}
private void PersistOrder()
{
for (var i = 0; i < _jobs.Count; i++)
{
_jobs[i].SortOrder = i;
QueuePersist(_jobs[i]);
}
}
private void QueuePersist(TransferJob job) => _ = PersistSafe(job);
private async Task PersistSafe(TransferJob job)
{
try
{
await Persist(job).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist transfer job {Id}", job.Id);
}
}
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
}

View File

@@ -0,0 +1,10 @@
using Explorer.Presentation.ViewModels;
namespace Explorer.Presentation;
public interface IThumbnailService
{
void OnViewportChanged(ExplorerPaneViewModel pane, IReadOnlyList<FolderItemViewModel> visible);
void OnSessionChanged(ExplorerPaneViewModel pane, int generation);
void OnPreviewEnabledChanged(ExplorerPaneViewModel pane);
}

View File

@@ -0,0 +1,43 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Explorer.Presentation;
public sealed class RangeObservableCollection<T> : ObservableCollection<T>
{
public void AddRange(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
var list = items as IList<T> ?? items.ToList();
if (list.Count == 0)
{
return;
}
CheckReentrancy();
foreach (var item in list)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void ReplaceAll(IList<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}

View File

@@ -0,0 +1,123 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class BatchRenameViewModel : ObservableObject
{
private readonly RenamePlanner _planner;
private readonly RenameBatchService _batches;
private readonly IReadOnlyList<RenameSubject> _subjects;
[ObservableProperty] private string _search = "";
[ObservableProperty] private string _replace = "";
[ObservableProperty] private bool _useRegex;
[ObservableProperty] private bool _matchCase;
[ObservableProperty] private bool _includeExtensionInSearch;
[ObservableProperty] private string _prefix = "";
[ObservableProperty] private string _suffix = "";
[ObservableProperty] private bool _useCounter;
[ObservableProperty] private int _counterStart = 1;
[ObservableProperty] private int _counterStep = 1;
[ObservableProperty] private int _counterPadding;
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
[ObservableProperty] private bool _changeExtension;
[ObservableProperty] private string _newExtension = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public BatchRenameViewModel(
IReadOnlyList<RenameSubject> subjects,
RenamePlanner planner,
RenameBatchService batches)
{
_subjects = subjects;
_planner = planner;
_batches = batches;
Rows = [];
Rebuild();
}
public ObservableCollection<RenamePreviewRow> Rows { get; }
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
[
new("Leave case", RenameCaseMode.Unchanged),
new("lowercase", RenameCaseMode.Lower),
new("UPPERCASE", RenameCaseMode.Upper),
new("Title Case", RenameCaseMode.Title)
];
public event EventHandler? CloseRequested;
public RenameRuleSet Rules => new()
{
Search = Search,
Replace = Replace,
UseRegex = UseRegex,
MatchCase = MatchCase,
IncludeExtensionInSearch = IncludeExtensionInSearch,
Prefix = Prefix,
Suffix = Suffix,
UseCounter = UseCounter,
CounterStart = CounterStart,
CounterStep = Math.Max(1, CounterStep),
CounterPadding = Math.Max(0, CounterPadding),
CaseMode = CaseMode,
ChangeExtension = ChangeExtension,
NewExtension = NewExtension
};
[RelayCommand]
public async Task QueueAsync()
{
var plan = _batches.Preview(_subjects, Rules);
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
return;
}
await _batches.EnqueueAsync(plan).ConfigureAwait(true);
CloseRequested?.Invoke(this, EventArgs.Empty);
}
partial void OnSearchChanged(string value) => Rebuild();
partial void OnReplaceChanged(string value) => Rebuild();
partial void OnUseRegexChanged(bool value) => Rebuild();
partial void OnMatchCaseChanged(bool value) => Rebuild();
partial void OnIncludeExtensionInSearchChanged(bool value) => Rebuild();
partial void OnPrefixChanged(string value) => Rebuild();
partial void OnSuffixChanged(string value) => Rebuild();
partial void OnUseCounterChanged(bool value) => Rebuild();
partial void OnCounterStartChanged(int value) => Rebuild();
partial void OnCounterStepChanged(int value) => Rebuild();
partial void OnCounterPaddingChanged(int value) => Rebuild();
partial void OnCaseModeChanged(RenameCaseMode value) => Rebuild();
partial void OnChangeExtensionChanged(bool value) => Rebuild();
partial void OnNewExtensionChanged(string value) => Rebuild();
private void Rebuild()
{
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
Rows.Clear();
foreach (var row in plan.Preview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var unchanged = plan.Preview.Count(r => r.Unchanged);
Status = errors > 0
? $"{_subjects.Count} items · {errors} errors"
: plan.Operations.Count == 0
? $"{_subjects.Count} items · nothing to rename"
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
}
}
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -11,19 +12,32 @@ public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly AnalysisService _analysis;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexStore store, SourceManager sources)
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis)
{
_store = store;
_sources = sources;
_analysis = analysis;
Groups = [];
}
public ObservableCollection<string> Groups { get; }
public ObservableCollection<DuplicateGroupViewModel> Groups { get; }
public event EventHandler<string>? RevealPath;
[RelayCommand]
public Task CloseAsync()
{
IsOpen = false;
return Task.CompletedTask;
}
[RelayCommand]
public async Task OpenAsync()
@@ -34,38 +48,41 @@ public sealed partial class DuplicateViewModel : ObservableObject
Groups.Clear();
try
{
var lines = await Task.Run(async () =>
var groups = await Task.Run(async () =>
{
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
var result = new List<string>();
foreach (var g in groups)
{
if (g.SameFileId)
{
continue;
}
var paths = string.Join(" | ", g.Entries.Select(e =>
{
sources.TryGetValue(e.SourceId, out var s);
return PathRules.Combine(s?.LastRootPath ?? s?.DisplayName ?? "", e.PathRel);
}));
result.Add($"{Formatters.Size(g.SizeBytes)} · {g.Entries.Count} files · {paths}");
}
return result;
return classified
.Select(g => DuplicateGroupViewModel.From(g, sources))
.ToList();
}).ConfigureAwait(true);
foreach (var line in lines)
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
foreach (var group in groups)
{
Groups.Add(line);
if (group.Classification == DuplicateClass.Hardlink)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
continue;
}
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
{
if (!ShowIntentional)
{
hiddenIntentional++;
continue;
}
}
Groups.Add(group);
}
Status = Groups.Count == 0
? "No confirmed duplicates yet. Hashing continues in the background."
: $"{Groups.Count} duplicate groups";
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
catch (Exception)
{
@@ -76,4 +93,132 @@ public sealed partial class DuplicateViewModel : ObservableObject
IsBusy = false;
}
}
[RelayCommand]
public async Task MarkIntentionalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.IntentionalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public async Task MarkAccidentalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.AccidentalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Reveal(DuplicateFileViewModel? file)
{
if (file is null || string.IsNullOrWhiteSpace(file.FullPath))
{
return;
}
RevealPath?.Invoke(this, file.FullPath);
}
partial void OnShowIntentionalChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
partial void OnShowHardlinksChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
if (visible > 0)
{
var extra = hiddenIntentional + hiddenHardlinks;
return extra == 0
? $"{visible} duplicate groups"
: $"{visible} duplicate groups · {extra} hidden as intentional or hard links";
}
if (hiddenIntentional + hiddenHardlinks > 0)
{
return "No accidental duplicates. Turn on intentional or hard links to review those.";
}
return "No confirmed duplicates yet. Hashing continues in the background.";
}
}
public sealed class DuplicateGroupViewModel
{
public required IReadOnlyList<IndexEntry> Entries { get; init; }
public DuplicateClass Classification { get; init; }
public required string ClassLabel { get; init; }
public required string SizeLabel { get; init; }
public required string Summary { get; init; }
public required string WastedLabel { get; init; }
public bool CanMarkIntentional { get; init; }
public bool CanMarkAccidental { get; init; }
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
public static DuplicateGroupViewModel From(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources)
{
var files = classified.Group.Entries.Select(entry =>
{
sources.TryGetValue(entry.SourceId, out var source);
var root = source?.LastRootPath ?? source?.DisplayName ?? "";
var full = PathRules.Combine(root, entry.PathRel);
var hardlink = classified.Group.Entries.Count(e =>
e.Id != entry.Id && e.SourceId == entry.SourceId && e.FileId is > 0 && e.FileId == entry.FileId) > 0;
return new DuplicateFileViewModel
{
Name = entry.Name,
FullPath = full,
LocationLabel = hardlink ? $"{full} (hard link)" : full
};
}).ToList();
return new DuplicateGroupViewModel
{
Entries = classified.Group.Entries,
Classification = classified.Classification,
ClassLabel = DuplicateClassifier.Label(classified.Classification),
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
WastedLabel = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space",
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Accidental,
Files = files
};
}
}
public sealed class DuplicateFileViewModel
{
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string LocationLabel { get; init; }
}

View File

@@ -14,8 +14,16 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly IGitStatusProvider _git;
private readonly IThumbnailService? _thumbnails;
private readonly NavigationHistory _history = new();
private CancellationTokenSource? _loadCts;
private BrowseViewport? _viewport;
private bool _userChoseSort;
private bool _awaitingSizeSort;
private bool _didAutoSort;
private int _browseGeneration;
private Dictionary<string, FolderItemViewModel>? _rows;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
@@ -28,34 +36,53 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[ObservableProperty] private string _sortProperty = "Name";
[ObservableProperty] private bool _sortDescending;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _gitBadge = "";
[ObservableProperty] private bool _hasGitRepo;
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
Items = [];
_git = git;
_thumbnails = thumbnails;
Items = new RangeObservableCollection<FolderItemViewModel>();
SelectedItems = [];
}
public ObservableCollection<FolderItemViewModel> Items { get; }
partial void OnGitBadgeChanged(string value) => OnPropertyChanged(nameof(HasGitBadge));
public RangeObservableCollection<FolderItemViewModel> Items { get; }
public ObservableCollection<FolderItemViewModel> SelectedItems { get; }
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
public bool CanGoBack => _history.CanGoBack;
public bool CanGoForward => _history.CanGoForward;
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath) || CurrentPath is LocationRoots.Network or LocationRoots.Cloud;
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath)
|| CurrentPath is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin;
public async Task NavigateAsync(string path, bool addHistory = true)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
var generation = Interlocked.Increment(ref _browseGeneration);
_thumbnails?.OnSessionChanged(this, generation);
_userChoseSort = false;
_awaitingSizeSort = false;
_didAutoSort = false;
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
_viewport = new BrowseViewport();
IsBusy = true;
StatusMessage = null;
try
{
CurrentPath = path;
@@ -71,39 +98,86 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
if (LocationRoots.IsVirtual(path))
{
GitBadge = "";
HasGitRepo = false;
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
return;
}
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
IsOffline = listing.IsOffline;
StatusMessage = listing.Error;
Items.Clear();
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
foreach (var item in listing.Items)
var published = false;
await foreach (var delta in _browse.ListProgressiveAsync(path, _viewport, ct).ConfigureAwait(true))
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
if (generation != _browseGeneration || path != CurrentPath)
{
return;
}
IsOffline = delta.IsOffline;
if (delta.Error is not null)
{
StatusMessage = delta.Error;
}
if (delta.Added.Count > 0)
{
var rows = delta.Added
.Select(item => new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory))
.ToList();
foreach (var row in rows)
{
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
published = true;
IsBusy = false;
}
if (delta.Updated.Count > 0)
{
ApplyUpdates(delta.Updated, sizeFromIndex);
}
if (delta.EnumerationComplete)
{
IsBusy = false;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
TryAutoSort(sizeMetadataReady: CurrentSource is not { IsIndexed: true });
_ = ApplyGitAsync(path, ct);
KickThumbnails();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
}
}
if ((delta.CompletedStages & ItemHydrationFlags.Index) != 0
|| delta.HydrationComplete)
{
TryAutoSort(sizeMetadataReady: true);
}
}
ApplyCurrentSort();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
if (!published)
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
IsBusy = false;
}
}
catch (OperationCanceledException)
@@ -112,30 +186,172 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
}
finally
{
IsBusy = false;
if (generation == _browseGeneration)
{
IsBusy = false;
}
}
}
public int BrowseGeneration => _browseGeneration;
public void NotifyViewport(IReadOnlyList<string> visiblePaths)
{
_viewport?.SetVisible(visiblePaths);
if (_thumbnails is null || ViewMode != FolderViewMode.Preview || _rows is null)
{
return;
}
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
foreach (var path in visiblePaths)
{
if (!string.IsNullOrEmpty(path) && _rows.TryGetValue(path, out var item))
{
visible.Add(item);
}
}
_thumbnails.OnViewportChanged(this, visible);
}
public IReadOnlyList<FolderItemViewModel> SnapshotItems() => Items.ToList();
private void KickThumbnails()
{
if (ViewMode != FolderViewMode.Preview || _thumbnails is null || Items.Count == 0)
{
return;
}
_thumbnails.OnViewportChanged(this, Items.Take(40).ToList());
}
partial void OnViewModeChanged(FolderViewMode value)
=> _thumbnails?.OnPreviewEnabledChanged(this);
private void ApplyUpdates(IReadOnlyList<FileSystemItem> updated, bool sizeFromIndex)
{
if (updated.Count == 0 || _rows is null)
{
return;
}
foreach (var item in updated)
{
if (_rows.TryGetValue(item.FullPath, out var vm))
{
vm.Apply(item, sizeFromIndex && item.IsDirectory);
}
}
}
private void TryAutoSort(bool sizeMetadataReady)
{
if (_userChoseSort)
{
return;
}
if (FolderListingSort.ShouldDeferAutoSort(SortProperty, enumerationComplete: true, sizeMetadataReady))
{
_awaitingSizeSort = SortProperty == "Size";
return;
}
if (_didAutoSort && !_awaitingSizeSort)
{
return;
}
_awaitingSizeSort = false;
_didAutoSort = true;
ApplyCurrentSort();
}
private async Task LoadVirtualRootAsync(string path, CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
var listing = path switch
{
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.RecycleBin => _browse.ListRecycleBin(),
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
};
foreach (var item in listing.Items)
StatusMessage = listing.Error;
var rows = listing.Items.Select(item => new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0)).ToList();
foreach (var row in rows)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
ApplyCurrentSort();
}
private async Task ApplyGitAsync(string path, CancellationToken cancellationToken)
{
GitBadge = "";
HasGitRepo = false;
foreach (var item in Items)
{
item.SetGit(null);
}
if (LocationRoots.IsVirtual(path))
{
return;
}
var dirs = Items.Where(i => i.IsDirectory).Select(i => i.FullPath).Take(24).ToList();
try
{
var (folder, root, child) = await Task.Run(
async () =>
{
var status = await _git.GetStatusAsync(path, cancellationToken).ConfigureAwait(false);
var found = _git.FindRepoRoot(path);
var overlays = new List<(string Path, GitStatus? Status)>();
foreach (var dir in dirs)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_git.IsRepoRoot(dir))
{
continue;
}
overlays.Add((dir, await _git.GetStatusAsync(dir, cancellationToken).ConfigureAwait(false)));
}
return (status, found, overlays);
},
cancellationToken).ConfigureAwait(true);
if (cancellationToken.IsCancellationRequested || path != CurrentPath)
{
return;
}
GitBadge = folder?.Badge ?? "";
HasGitRepo = folder is not null || root is not null;
foreach (var (full, status) in child)
{
var vm = Items.FirstOrDefault(i =>
string.Equals(i.FullPath, full, StringComparison.OrdinalIgnoreCase));
vm?.SetGit(status);
}
}
catch (OperationCanceledException)
{
// superseded
}
}
[RelayCommand]
public Task BackAsync()
{
@@ -176,15 +392,23 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public Task OpenItemAsync(FolderItemViewModel item)
public async Task OpenItemAsync(FolderItemViewModel item)
{
if (item.Item.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(item.FullPath).ConfigureAwait(true);
var path = source?.LastRootPath ?? item.FullPath;
await NavigateAsync(path).ConfigureAwait(true);
return;
}
if (item.IsDirectory || _browse.CanBrowseArchive(item.Item.Name))
{
return NavigateAsync(item.FullPath);
await NavigateAsync(item.FullPath).ConfigureAwait(true);
return;
}
_ops.Open([item.FullPath]);
return Task.CompletedTask;
}
public void BuildIndex()
@@ -223,17 +447,15 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
SortDescending = property is "Size" or "Free" or "Modified";
}
_userChoseSort = true;
_awaitingSizeSort = false;
ApplyCurrentSort();
}
public void ApplyCurrentSort()
{
var ordered = OrderItems(Items, SortProperty, SortDescending).ToList();
Items.Clear();
foreach (var item in ordered)
{
Items.Add(item);
}
Items.ReplaceAll(OrderItems(Items, SortProperty, SortDescending).ToList());
_rows = Items.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
}
public static IEnumerable<FolderItemViewModel> OrderItems(
@@ -241,25 +463,10 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
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)
};
var map = items as IList<FolderItemViewModel> ?? items.ToList();
var ordered = FolderListingSort.Order(map.Select(i => i.Item), property, descending);
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
return ordered.Select(item => byPath[item.FullPath]);
}
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
@@ -269,7 +476,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
}
if (path is LocationRoots.Network or LocationRoots.Cloud)
if (path is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin)
{
return
[

View File

@@ -23,9 +23,11 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources, git, thumbnails);
Left = _paneFactory();
Right = _paneFactory();
_activePane = Left;

View File

@@ -29,14 +29,27 @@ public sealed partial class FolderItemViewModel : ObservableObject
EditName = Item.Name;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public FileSystemItem Item { get; private set; }
public bool SizeFromIndex { get; private set; }
public void Apply(FileSystemItem item, bool? sizeFromIndex = null)
{
Item = item;
if (sizeFromIndex is bool value)
{
SizeFromIndex = value;
}
OnPropertyChanged(string.Empty);
}
public string Name => Item.DisplayName ?? Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string TypeLabel => Item.AvailableToImport
? "Available in Windows"
: Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.SizeKnowledge switch
{
SizeKnowledge.Unknown when Item.Location.AccessDenied => "Access denied",
@@ -62,6 +75,22 @@ public sealed partial class FolderItemViewModel : ObservableObject
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
[ObservableProperty] private string _gitLabel = "";
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
[ObservableProperty] private object? _thumbnail;
public bool HasThumbnail => Thumbnail is not null;
public void SetGit(GitStatus? status)
{
GitLabel = status?.Badge ?? "";
}
public void SetThumbnail(object? image) => Thumbnail = image;
partial void OnGitLabelChanged(string value) => OnPropertyChanged(nameof(HasGitLabel));
partial void OnThumbnailChanged(object? value) => OnPropertyChanged(nameof(HasThumbnail));
public string SizeTooltip
{
get

View File

@@ -0,0 +1,228 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class FolderSyncViewModel : ObservableObject
{
private readonly FolderSyncService _sync;
private OperationPlan? _plan;
[ObservableProperty] private SyncProfile? _selected;
[ObservableProperty] private string _name = "Photos backup";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private SyncMode _mode = SyncMode.CopyUpdate;
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public FolderSyncViewModel(FolderSyncService sync)
{
_sync = sync;
Profiles = [];
Rows = [];
Modes =
[
new SyncModeOption("Copy / Update", SyncMode.CopyUpdate),
new SyncModeOption("Mirror", SyncMode.Mirror)
];
}
public ObservableCollection<SyncProfile> Profiles { get; }
public ObservableCollection<SyncPreviewRow> Rows { get; }
public IReadOnlyList<SyncModeOption> Modes { get; }
public bool AutoRunEnabled => Mode == SyncMode.CopyUpdate;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _sync.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
partial void OnSelectedChanged(SyncProfile? value)
{
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
Mode = value.Mode;
Excludes = value.Excludes;
AutoRun = value.AutoRun && value.Mode == SyncMode.CopyUpdate;
ClearPlan("Profile loaded. Preview to see what would change.");
}
partial void OnModeChanged(SyncMode value)
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (value != SyncMode.CopyUpdate)
{
AutoRun = false;
}
}
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New sync";
SourcePath = "";
DestPath = "";
Mode = SyncMode.CopyUpdate;
Excludes = "";
AutoRun = false;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(SourcePath) || string.IsNullOrWhiteSpace(DestPath))
{
Status = "Choose a source folder and a destination folder.";
return;
}
var profile = CurrentProfile();
profile.Id = await _sync.SaveAsync(profile).ConfigureAwait(true);
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _sync.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _sync.PreviewAsync(profile)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _sync.EnqueueAsync(profile, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public SyncProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Sync" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
Mode = Mode,
Excludes = Excludes ?? "",
AutoRun = AutoRun && Mode == SyncMode.CopyUpdate,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.SyncPreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Folders already match."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
}
public sealed record SyncModeOption(string Label, SyncMode Mode);

View File

@@ -22,6 +22,14 @@ public sealed partial class MainViewModel : ObservableObject
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly RenamePlanner _renamePlanner;
private readonly RenameBatchService _renameBatches;
private readonly FolderSyncService _folderSync;
private readonly OperationProfileService _operationProfiles;
private readonly ReorganizeService _reorganize;
private readonly IGitStatusProvider _git;
private readonly IWorkspaceLauncher _workspace;
private readonly IThumbnailService? _thumbnails;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -33,6 +41,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
[ObservableProperty] private bool _showForgetSource;
[ObservableProperty] private bool _showEmptyRecycleBin;
[ObservableProperty] private bool _showImportWindowsLocation;
[ObservableProperty] private bool _showBatchRename;
[ObservableProperty] private bool _showRunProfile;
[ObservableProperty] private bool _showOrganizeFolder;
[ObservableProperty] private bool _canUndoRenameBatch;
[ObservableProperty] private bool _showExtractArchive;
[ObservableProperty] private bool _showCompress;
[ObservableProperty] private bool _showAddToArchive;
[ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showGitActions;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
@@ -51,7 +72,15 @@ public sealed partial class MainViewModel : ObservableObject
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IVolumeService volumes)
IVolumeService volumes,
RenamePlanner renamePlanner,
RenameBatchService renameBatches,
FolderSyncService folderSync,
OperationProfileService operationProfiles,
ReorganizeService reorganize,
IGitStatusProvider git,
IWorkspaceLauncher workspace,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
@@ -61,13 +90,22 @@ public sealed partial class MainViewModel : ObservableObject
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
_renamePlanner = renamePlanner;
_renameBatches = renameBatches;
_folderSync = folderSync;
_operationProfiles = operationProfiles;
_reorganize = reorganize;
_git = git;
_workspace = workspace;
_thumbnails = thumbnails;
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Duplicates = new DuplicateViewModel(store, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(transfers, preferences);
Tabs = [];
Clipboard = clipboard;
@@ -133,7 +171,7 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
@@ -170,6 +208,24 @@ public sealed partial class MainViewModel : ObservableObject
PathText = ActivePane.CurrentPath;
}
public async Task RevealDuplicateAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return;
}
var folder = Directory.Exists(path) ? path : PathRules.Parent(path);
if (string.IsNullOrWhiteSpace(folder) || LocationRoots.IsVirtual(folder))
{
return;
}
await ActivePane.NavigateAsync(folder).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Footer = folder;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
@@ -232,6 +288,17 @@ public sealed partial class MainViewModel : ObservableObject
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (node.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(node.Path).ConfigureAwait(true);
var path = source?.LastRootPath ?? node.Path;
await Tree.ReloadAsync(path).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = path;
Footer = "Added Windows location. Indexing is optional.";
return;
}
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
@@ -363,6 +430,86 @@ public sealed partial class MainViewModel : ObservableObject
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
}
public BatchRenameViewModel? CreateBatchRenameViewModel()
{
var items = ActivePane.SelectedItems
.Where(i => !LocationRoots.IsVirtual(i.FullPath))
.Select(i => new RenameSubject(i.FullPath, i.Item.Name, i.IsDirectory))
.ToList();
if (items.Count == 0)
{
Footer = "Select files or folders to rename.";
return null;
}
return new BatchRenameViewModel(items, _renamePlanner, _renameBatches);
}
public async Task RefreshUndoRenameAsync()
=> CanUndoRenameBatch = await _renameBatches.GetUndoableAsync().ConfigureAwait(true) is not null;
public FolderSyncViewModel CreateFolderSyncViewModel()
=> new(_folderSync);
public OperationProfilesViewModel CreateOperationProfilesViewModel()
=> new(_operationProfiles);
public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath());
public string? OrganizeSourcePath() => WorkspaceDirectory();
public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync()
=> _operationProfiles.ListAsync();
public IReadOnlyList<string> SelectedRealPaths()
=> RealSelected().Select(i => i.FullPath).ToList();
[RelayCommand]
public void OpenTerminal()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open a terminal.";
return;
}
_workspace.OpenTerminal(directory);
}
[RelayCommand]
public void OpenInCursor()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open in Cursor.";
return;
}
if (!_workspace.TryOpenInCursor(directory))
{
Footer = "Cursor is not installed.";
}
}
[RelayCommand]
public async Task UndoRenameBatchAsync()
{
var plan = await _renameBatches.UndoLastAsync().ConfigureAwait(true);
await RefreshUndoRenameAsync().ConfigureAwait(true);
if (plan.HasErrors)
{
Footer = plan.Issues[0].Message;
return;
}
Footer = plan.Operations.Count == 0
? plan.Issues.FirstOrDefault()?.Message ?? "Nothing to undo."
: $"Undo rename queued ({plan.Operations.Count}).";
}
[RelayCommand]
public async Task BuildIndexAsync()
{
@@ -401,6 +548,23 @@ public sealed partial class MainViewModel : ObservableObject
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
ShowEmptyRecycleBin = ActivePane.CurrentPath == LocationRoots.RecycleBin
|| ActivePane.SelectedItems.Any(i => i.FullPath == LocationRoots.RecycleBin);
ShowImportWindowsLocation = ActivePane.SelectedItems.Count == 1
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
ShowBatchRename = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
ShowRunProfile = ShowBatchRename;
ShowOrganizeFolder = OrganizeSourcePath() is not null;
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name));
ShowCompress = real.Count > 0;
ShowAddToArchive = real.Any(i => i.IsDirectory || !ArchiveFormats.IsArchive(i.Item.Name));
ShowVerifyArchive = ShowExtractArchive;
var target = WorkspaceDirectory();
ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
_ = RefreshForgetActionAsync();
}
@@ -411,6 +575,117 @@ public sealed partial class MainViewModel : ObservableObject
&& await _sources.CanForgetPathAsync(ActivePane.SelectedItems[0].FullPath).ConfigureAwait(true);
}
public async Task ExtractSelectedAsync(string? destinationDirectory)
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to extract.";
return;
}
var root = destinationDirectory;
if (string.IsNullOrWhiteSpace(root))
{
root = ActivePane.CurrentPath;
}
if (string.IsNullOrWhiteSpace(root) || LocationRoots.IsVirtual(root))
{
Footer = "Choose a folder to extract to.";
return;
}
foreach (var archive in archives)
{
var dest = Path.Combine(root, ArchiveFormats.Stem(archive.Item.Name));
await _ops.ExtractAsync(archive.FullPath, dest).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Extract queued." : $"{archives.Count} extracts queued.";
}
public async Task CompressSelectedAsync(ArchiveFormat format)
{
var items = RealSelected();
if (items.Count == 0)
{
Footer = "Select files or folders to compress.";
return;
}
var folder = ActivePane.CurrentPath;
if (LocationRoots.IsVirtual(folder))
{
folder = PathRules.Parent(items[0].FullPath);
}
var stem = items.Count == 1
? ArchiveFormats.Stem(items[0].Item.Name)
: (string.IsNullOrWhiteSpace(PathRules.GetFileName(folder)) ? "Archive" : PathRules.GetFileName(folder));
var ext = format == ArchiveFormat.SevenZip ? "7z" : "zip";
var archive = FileOperationService.UniqueArchivePath(folder, stem, ext);
await _ops.CompressAsync(items.Select(i => i.FullPath).ToList(), archive).ConfigureAwait(true);
Footer = $"Compress queued → {PathRules.GetFileName(archive)}.";
}
public async Task AddSelectedToArchiveAsync(string archivePath)
{
var items = RealSelected().Where(i => !ArchiveFormats.IsArchive(i.Item.Name) || i.IsDirectory).ToList();
if (items.Count == 0 || string.IsNullOrWhiteSpace(archivePath))
{
Footer = "Select files to add to an archive.";
return;
}
await _ops.AddToArchiveAsync(archivePath, items.Select(i => i.FullPath).ToList()).ConfigureAwait(true);
Footer = "Add to archive queued.";
}
public async Task VerifySelectedAsync()
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to verify.";
return;
}
foreach (var archive in archives)
{
await _ops.VerifyArchiveAsync(archive.FullPath).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Verify queued." : $"{archives.Count} verifies queued.";
}
private List<FolderItemViewModel> RealSelected()
=> ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
private static bool IsRealFileSystemItem(FolderItemViewModel item)
{
if (LocationRoots.IsVirtual(item.FullPath))
{
return false;
}
return item.IsDirectory
? Directory.Exists(item.FullPath)
: File.Exists(item.FullPath);
}
private string? WorkspaceDirectory()
{
if (ActivePane.SelectedItems.Count == 1 && ActivePane.SelectedItems[0].IsDirectory
&& IsRealFileSystemItem(ActivePane.SelectedItems[0]))
{
return ActivePane.SelectedItems[0].FullPath;
}
var path = ActivePane.CurrentPath;
return LocationRoots.IsVirtual(path) || !Directory.Exists(path) ? null : path;
}
public async Task<bool> ForgetSourceAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -456,6 +731,12 @@ public sealed partial class MainViewModel : ObservableObject
return true;
}
public Task EmptyRecycleBinAsync()
{
Footer = "Empty Recycle Bin queued.";
return _ops.EmptyRecycleBinAsync();
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
@@ -752,6 +1033,11 @@ public sealed partial class MainViewModel : ObservableObject
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge)
or nameof(ExplorerPaneViewModel.HasGitRepo))
{
RefreshCloudActions();
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
@@ -774,7 +1060,7 @@ public sealed partial class MainViewModel : ObservableObject
}
Add(job.SourcePath);
if (job.Op != TransferOp.Delete)
if (job.Op != TransferOp.Delete && job.Op != TransferOp.EmptyRecycleBin)
{
Add(job.DestinationPath);
}
@@ -784,6 +1070,8 @@ public sealed partial class MainViewModel : ObservableObject
Add(extra);
}
_ = _folderSync.TryMarkRelationAsync(job);
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
@@ -793,6 +1081,10 @@ public sealed partial class MainViewModel : ObservableObject
{
Footer = job.Error ?? "Delete failed.";
}
else if (job.Op == TransferOp.EmptyRecycleBin)
{
Footer = "Recycle Bin emptied.";
}
else if (job.Op == TransferOp.Delete)
{
Footer = string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)

View File

@@ -21,6 +21,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public bool AvailableToImport { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
}
@@ -83,11 +84,22 @@ public sealed class NavigationTreeViewModel
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
thisPc.Children.Add(new NavNodeViewModel
{
Label = LocationRoots.RecycleBin,
Path = LocationRoots.RecycleBin,
Glyph = "\uE74D",
ChildrenLoaded = true
});
var untracked = (await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(true))
.Where(fp => fp.Kind.IsNetwork())
.ToList();
var network = sources.Where(s => s.Kind.IsNetwork())
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
if (prefs.GroupNetworkPlaces && network.Count > 0)
if (prefs.GroupNetworkPlaces && (network.Count > 0 || untracked.Count > 0))
{
var group = new NavNodeViewModel
{
@@ -103,6 +115,11 @@ public sealed class NavigationTreeViewModel
group.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
group.Children.Add(CreateImportNode(fp));
}
Roots.Add(group);
}
else
@@ -111,6 +128,11 @@ public sealed class NavigationTreeViewModel
{
Roots.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
Roots.Add(CreateImportNode(fp));
}
}
var places = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
@@ -404,6 +426,17 @@ public sealed class NavigationTreeViewModel
return node;
}
private static NavNodeViewModel CreateImportNode(VolumeFingerprint fingerprint)
=> new()
{
Label = (fingerprint.DisplayName ?? fingerprint.RootPath) + " (Windows)",
Path = fingerprint.RootPath,
Status = "Available in Windows",
Glyph = "\uE968",
ChildrenLoaded = true,
AvailableToImport = true
};
private static int ProviderOrder(string providerId) => providerId switch
{
"onedrive" => 0,

View File

@@ -0,0 +1,320 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class OperationProfilesViewModel : ObservableObject
{
private readonly OperationProfileService _profiles;
private OperationPlan? _plan;
private IReadOnlyList<string>? _sourceOverride;
[ObservableProperty] private OperationProfile? _selected;
[ObservableProperty] private string _name = "New profile";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private bool _requireGitClean;
[ObservableProperty] private bool _doCompress;
[ObservableProperty] private ArchiveFormat _archiveFormat = ArchiveFormat.SevenZip;
[ObservableProperty] private bool _doCopy = true;
[ObservableProperty] private bool _doRename;
[ObservableProperty] private string _renamePrefix = "";
[ObservableProperty] private string _renameSuffix = "";
[ObservableProperty] private string _renameSearch = "";
[ObservableProperty] private string _renameReplace = "";
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public OperationProfilesViewModel(OperationProfileService profiles)
{
_profiles = profiles;
Profiles = [];
Rows = [];
Formats =
[
new ArchiveFormatOption("7-Zip (.7z)", ArchiveFormat.SevenZip),
new ArchiveFormatOption("ZIP", ArchiveFormat.Zip)
];
}
public ObservableCollection<OperationProfile> Profiles { get; }
public ObservableCollection<ProfilePreviewRow> Rows { get; }
public IReadOnlyList<ArchiveFormatOption> Formats { get; }
public bool AutoRunEnabled => DoCopy && !DoCompress && !HasRenameText;
public bool CompressOptionsEnabled => DoCompress;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _profiles.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
public async Task RunOnAsync(long profileId, IReadOnlyList<string> sources)
{
var match = Profiles.FirstOrDefault(p => p.Id == profileId);
if (match is null)
{
Status = "That profile is no longer available.";
return;
}
Selected = match;
_sourceOverride = sources;
await AnalyzeAsync().ConfigureAwait(true);
}
partial void OnSelectedChanged(OperationProfile? value)
{
_sourceOverride = null;
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
RequireGitClean = value.RequireGitClean;
DoCompress = value.DoCompress;
ArchiveFormat = value.ArchiveFormat;
DoCopy = value.DoCopy;
DoRename = value.DoRename;
RenamePrefix = value.RenamePrefix;
RenameSuffix = value.RenameSuffix;
RenameSearch = value.RenameSearch;
RenameReplace = value.RenameReplace;
Excludes = value.Excludes;
AutoRun = value.CanAutoRun;
ClearPlan("Profile loaded. Preview to see what would run.");
}
partial void OnDoCopyChanged(bool value) => RefreshAutoRun();
partial void OnDoCompressChanged(bool value)
{
OnPropertyChanged(nameof(CompressOptionsEnabled));
RefreshAutoRun();
}
partial void OnDoRenameChanged(bool value) => RefreshAutoRun();
partial void OnRenamePrefixChanged(string value) => RefreshAutoRun();
partial void OnRenameSuffixChanged(string value) => RefreshAutoRun();
partial void OnRenameSearchChanged(string value) => RefreshAutoRun();
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New profile";
SourcePath = "";
DestPath = "";
RequireGitClean = false;
DoCompress = false;
ArchiveFormat = ArchiveFormat.SevenZip;
DoCopy = true;
DoRename = false;
RenamePrefix = "";
RenameSuffix = "";
RenameSearch = "";
RenameReplace = "";
Excludes = "";
AutoRun = false;
_sourceOverride = null;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
var profile = CurrentProfile();
profile.Id = await _profiles.SaveAsync(profile).ConfigureAwait(true);
ReplaceInList(profile);
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _profiles.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task DuplicateAsync()
{
var profile = CurrentProfile();
if (profile.Id <= 0)
{
Status = "Save the profile first.";
return;
}
var copy = await _profiles.DuplicateAsync(profile).ConfigureAwait(true);
Profiles.Add(copy);
Selected = copy;
Status = "Duplicated.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _profiles.PreviewAsync(profile, _sourceOverride)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _profiles.EnqueueAsync(profile, _plan, _sourceOverride).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
ReplaceInList(profile);
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public OperationProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Profile" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
RequireGitClean = RequireGitClean,
DoCompress = DoCompress,
ArchiveFormat = ArchiveFormat,
DoCopy = DoCopy,
DoRename = DoRename,
RenamePrefix = RenamePrefix ?? "",
RenameSuffix = RenameSuffix ?? "",
RenameSearch = RenameSearch ?? "",
RenameReplace = RenameReplace ?? "",
Excludes = Excludes ?? "",
AutoRun = AutoRun && AutoRunEnabled,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
IsBuiltIn = Selected?.IsBuiltIn ?? false,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.ProfilePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
if (_sourceOverride is { Count: > 0 } sources)
{
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: $"{plan.Operations.Count} operations on {sources.Count} item(s)"
+ (warnings > 0 ? $" · {warnings} skipped" : "");
return;
}
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Nothing to queue."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
private void ReplaceInList(OperationProfile profile)
{
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
}
private bool HasRenameText
=> DoRename && (!string.IsNullOrWhiteSpace(RenamePrefix)
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
private void RefreshAutoRun()
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (!AutoRunEnabled)
{
AutoRun = false;
}
}
}
public sealed record ArchiveFormatOption(string Label, ArchiveFormat Format);

View File

@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ReorganizeViewModel : ObservableObject
{
private readonly ReorganizeService _organize;
private OperationPlan? _plan;
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _picturesPath = "";
[ObservableProperty] private string _videosPath = "";
[ObservableProperty] private string _audioPath = "";
[ObservableProperty] private string _documentsPath = "";
[ObservableProperty] private string _installersPath = "";
[ObservableProperty] private string _archivesPath = "";
[ObservableProperty] private string _developmentPath = "";
[ObservableProperty] private string _status = "Preview to see proposed moves. Nothing is moved until you Queue.";
[ObservableProperty] private bool _canQueue;
public ReorganizeViewModel(ReorganizeService organize, string? sourcePath)
{
_organize = organize;
var dest = organize.LoadDestinations();
SourcePath = string.IsNullOrWhiteSpace(sourcePath) ? SuggestDownloads() : sourcePath;
PicturesPath = dest.Pictures;
VideosPath = dest.Videos;
AudioPath = dest.Audio;
DocumentsPath = dest.Documents;
InstallersPath = dest.Installers;
ArchivesPath = dest.Archives;
DevelopmentPath = dest.Development;
Rows = [];
}
public ObservableCollection<OrganizePreviewRow> Rows { get; }
[RelayCommand]
public async Task AnalyzeAsync()
{
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _organize.Preview(SourcePath.Trim(), CurrentDestinations())).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var dest = CurrentDestinations();
var plan = await _organize.EnqueueAsync(SourcePath.Trim(), dest, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = $"Queued {plan.Operations.Count} move(s).";
CanQueue = false;
}
}
[RelayCommand]
public void SaveDestinations()
{
_organize.SaveDestinations(CurrentDestinations());
Status = "Destinations saved.";
}
public OrganizeDestinations CurrentDestinations()
=> new()
{
Pictures = PicturesPath.Trim(),
Videos = VideosPath.Trim(),
Audio = AudioPath.Trim(),
Documents = DocumentsPath.Trim(),
Installers = InstallersPath.Trim(),
Archives = ArchivesPath.Trim(),
Development = DevelopmentPath.Trim()
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.OrganizePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
var moves = plan.Operations.Count;
var skipped = plan.OrganizePreview.Count(r => r.Action == "Skip");
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: moves == 0
? skipped > 0
? $"Nothing to queue · {skipped} left in place"
: "Nothing to queue."
: $"{moves} move(s) · {skipped} left in place"
+ (warnings > 0 ? $" · {warnings} warning(s)" : "");
}
private static string SuggestDownloads()
{
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads) ? downloads : "";
}
}

View File

@@ -22,6 +22,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
[ObservableProperty] private bool _hasProgress;
[ObservableProperty] private bool _canPause;
[ObservableProperty] private bool _canResume;
[ObservableProperty] private bool _canRetry;
[ObservableProperty] private bool _canRemove;
[ObservableProperty] private bool _canMoveUp;
[ObservableProperty] private bool _canMoveDown;
@@ -54,10 +55,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
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;
CanRetry = job.Status == TransferStatus.Failed;
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;
IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling;
IsFailed = job.Status == TransferStatus.Failed;
}
@@ -71,6 +74,16 @@ public sealed partial class TransferJobViewModel : ObservableObject
return count <= 1 ? FileName(job.SourcePath) : $"{count} items";
}
if (job.Op == TransferOp.EmptyRecycleBin)
{
return LocationRoots.RecycleBin;
}
if (job.Op is TransferOp.Compress or TransferOp.AddToArchive)
{
return FileName(job.DestinationPath);
}
return FileName(job.SourcePath);
}
@@ -79,6 +92,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
{
TransferOp.Copy => $"Copy to {FolderName(job.DestinationPath)}",
TransferOp.Move => $"Move to {FolderName(job.DestinationPath)}",
TransferOp.Rename => $"Rename to {FileName(job.DestinationPath)}",
TransferOp.Extract => $"Extract to {FileName(job.DestinationPath)}",
TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}",
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
: "Move to Recycle Bin",
@@ -93,6 +112,9 @@ public sealed partial class TransferJobViewModel : ObservableObject
? $"{OpWord(job.Op)} {job.FilesDone:N0} of {job.FilesTotal:N0}"
: "Working…",
TransferStatus.Paused => "Paused",
TransferStatus.Waiting => string.IsNullOrWhiteSpace(job.WaitReason)
? "Waiting for destination"
: job.WaitReason,
TransferStatus.Cancelling => "Cancelling…",
TransferStatus.Cancelled => "Cancelled",
TransferStatus.Failed => string.IsNullOrWhiteSpace(job.Error) ? "Failed" : job.Error,
@@ -152,6 +174,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
}
@@ -229,6 +257,15 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
[RelayCommand]
public void Retry(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.Retry(job.Id);
}
}
[RelayCommand]
public void Remove(TransferJobViewModel? job)
{
@@ -237,7 +274,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -270,7 +308,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -336,8 +375,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
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)
var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling).ToList();
var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Waiting)
?? active.FirstOrDefault();
HasJobs = Jobs.Count > 0;
HasActiveJobs = active.Count > 0;
@@ -397,6 +437,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return "";
}
var waitingDest = active.Count(j => j.Status == TransferStatus.Waiting);
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)
@@ -404,6 +445,13 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting > 0 ? $"Paused · {name} · {waiting} waiting" : $"Paused · {name}";
}
if (current?.Status == TransferStatus.Waiting)
{
return waitingDest > 1
? $"Waiting for destination · {waitingDest} items"
: $"Waiting for destination · {name}";
}
if (current?.Status == TransferStatus.Running)
{
return waiting > 0 ? $"{OpVerb(current.Op)} {name} · {waiting} waiting" : $"{OpVerb(current.Op)} {name}";
@@ -411,7 +459,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting == active.Count
? $"{active.Count} queued"
: $"{active.Count} transfers";
: waitingDest == active.Count
? "Waiting for destination"
: $"{active.Count} transfers";
}
private static string OpVerb(TransferOp op)
@@ -420,11 +470,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Waiting or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Cancelled;
}

View File

@@ -320,13 +320,12 @@ internal sealed class HashStore : IHashStore
.Select(g =>
{
var list = g.ToList();
var fileIds = list.Select(e => e.FileId).Where(id => id is > 0).Distinct().ToList();
return new DuplicateGroup
{
SizeBytes = list[0].SizeBytes,
Hash = list[0].ContentHash,
Entries = list,
SameFileId = fileIds.Count == 1 && list.All(e => e.FileId == fileIds[0])
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
};
})
.ToList();

View File

@@ -0,0 +1,94 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class FileRelationStore : IFileRelationStore
{
private readonly SqliteIndexStore _store;
public FileRelationStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<FileRelation>> GetAmongAsync(
IReadOnlyList<long> entryIds,
CancellationToken cancellationToken = default)
{
if (entryIds.Count == 0)
{
return [];
}
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<RelationRow>("""
SELECT id, left_entry_id, right_entry_id, kind, origin, created_utc
FROM file_relations
WHERE left_entry_id IN @ids AND right_entry_id IN @ids
""", new { ids = entryIds }).ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public Task UpsertAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn =>
{
var left = Math.Min(relation.LeftEntryId, relation.RightEntryId);
var right = Math.Max(relation.LeftEntryId, relation.RightEntryId);
if (left == right)
{
return Task.CompletedTask;
}
return conn.ExecuteAsync("""
INSERT INTO file_relations (left_entry_id, right_entry_id, kind, origin, created_utc)
VALUES (@left, @right, @kind, @origin, @utc)
ON CONFLICT(left_entry_id, right_entry_id, kind) DO UPDATE SET origin = excluded.origin
""", new
{
left,
right,
kind = relation.Kind.ToString(),
origin = relation.Origin.ToString(),
utc = relation.CreatedUtc == default
? DateTimeOffset.UtcNow.ToString("O")
: relation.CreatedUtc.ToString("O")
});
}, cancellationToken);
public Task DeleteAmongAsync(
IReadOnlyList<long> entryIds,
IReadOnlyList<FileRelationKind> kinds,
CancellationToken cancellationToken = default)
{
if (entryIds.Count == 0 || kinds.Count == 0)
{
return Task.CompletedTask;
}
return _store.WriteAsync(conn => conn.ExecuteAsync("""
DELETE FROM file_relations
WHERE left_entry_id IN @ids AND right_entry_id IN @ids
AND kind IN @kinds
""", new { ids = entryIds, kinds = kinds.Select(k => k.ToString()).ToList() }), cancellationToken);
}
private static FileRelation ToModel(RelationRow row)
=> new()
{
Id = row.id,
LeftEntryId = row.left_entry_id,
RightEntryId = row.right_entry_id,
Kind = Enum.Parse<FileRelationKind>(row.kind),
Origin = Enum.Parse<FileRelationOrigin>(row.origin),
CreatedUtc = DateTimeOffset.Parse(row.created_utc)
};
private sealed class RelationRow
{
public long id { get; set; }
public long left_entry_id { get; set; }
public long right_entry_id { get; set; }
public string kind { get; set; } = "";
public string origin { get; set; } = "";
public string created_utc { get; set; } = "";
}
}

View File

@@ -169,37 +169,129 @@ internal sealed class TransferStore : ITransferStore
private readonly SqliteIndexStore _store;
public TransferStore(SqliteIndexStore store) => _store = store;
private const string SelectSql = """
SELECT id AS Id, op AS Op, src AS Src, dst AS Dst, status AS Status,
bytes_total AS BytesTotal, bytes_done AS BytesDone, files_done AS FilesDone,
files_total AS FilesTotal, current_path AS CurrentPath, created_utc AS CreatedUtc,
started_utc AS StartedUtc, error AS Error, retry_count AS RetryCount,
wait_reason AS WaitReason, sort_order AS SortOrder, dismissed AS Dismissed
FROM transfer_jobs
""";
public Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var nextOrder = await conn.ExecuteScalarAsync<long>(
"SELECT IFNULL(MAX(sort_order), 0) + 1 FROM transfer_jobs").ConfigureAwait(false);
job.SortOrder = (int)nextOrder;
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO transfer_jobs (op, src, dst, status, bytes_total, bytes_done, created_utc, error)
VALUES (@Op, @Src, @Dst, @Status, @BytesTotal, @BytesDone, @CreatedUtc, @Error);
""", new
{
Op = job.Op.ToString(),
Src = job.SourcePath,
Dst = job.DestinationPath,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
CreatedUtc = job.CreatedUtc.ToString("O"),
job.Error
}).ConfigureAwait(false);
INSERT INTO transfer_jobs (op, src, dst, status, bytes_total, bytes_done, files_done, files_total,
current_path, created_utc, started_utc, error, retry_count, wait_reason, sort_order, dismissed)
VALUES (@Op, @Src, @Dst, @Status, @BytesTotal, @BytesDone, @FilesDone, @FilesTotal,
@CurrentPath, @CreatedUtc, @StartedUtc, @Error, @RetryCount, @WaitReason, @SortOrder, @Dismissed);
""", Args(job)).ConfigureAwait(false);
job.Id = id;
return id;
}, cancellationToken);
public Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
UPDATE transfer_jobs SET status=@Status, bytes_total=@BytesTotal, bytes_done=@BytesDone, error=@Error
UPDATE transfer_jobs SET op=@Op, src=@Src, dst=@Dst, status=@Status, bytes_total=@BytesTotal,
bytes_done=@BytesDone, files_done=@FilesDone, files_total=@FilesTotal, current_path=@CurrentPath,
started_utc=@StartedUtc, error=@Error, retry_count=@RetryCount, wait_reason=@WaitReason,
sort_order=@SortOrder, dismissed=@Dismissed
WHERE id=@Id
""", new
""", Args(job)), cancellationToken);
public async Task<IReadOnlyList<TransferJob>> GetIncompleteAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<TransferJobRow>(SelectSql + """
WHERE dismissed = 0 AND status IN ('Queued','Running','Paused','Waiting','Cancelling','Failed')
ORDER BY sort_order, id
""").ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task<IReadOnlyList<TransferJob>> GetHistoryAsync(int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<TransferJobRow>(SelectSql + """
WHERE status IN ('Done','Cancelled') OR dismissed = 1
ORDER BY id DESC
LIMIT @take
""", new { take }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
private static object Args(TransferJob job) => new
{
job.Id,
Op = job.Op.ToString(),
Src = job.SourcePath,
Dst = job.DestinationPath,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
job.FilesDone,
job.FilesTotal,
job.CurrentPath,
CreatedUtc = job.CreatedUtc.ToString("O"),
StartedUtc = job.StartedUtc?.ToString("O"),
job.Error,
job.RetryCount,
job.WaitReason,
job.SortOrder,
Dismissed = job.Dismissed ? 1 : 0
};
private static TransferJob Map(TransferJobRow row)
{
var op = Enum.Parse<TransferOp>(row.Op, ignoreCase: true);
var sources = op is TransferOp.Delete or TransferOp.Compress or TransferOp.AddToArchive
? row.Src.Split('|', StringSplitOptions.RemoveEmptyEntries)
: [];
return new TransferJob
{
job.Id,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
job.Error
}), cancellationToken);
Id = row.Id,
Op = op,
SourcePath = row.Src,
DestinationPath = row.Dst,
Status = Enum.Parse<TransferStatus>(row.Status, ignoreCase: true),
BytesTotal = row.BytesTotal,
BytesDone = row.BytesDone,
FilesDone = row.FilesDone,
FilesTotal = row.FilesTotal,
CurrentPath = row.CurrentPath,
CreatedUtc = DateTimeOffset.Parse(row.CreatedUtc),
StartedUtc = string.IsNullOrWhiteSpace(row.StartedUtc) ? null : DateTimeOffset.Parse(row.StartedUtc),
Error = row.Error,
RetryCount = row.RetryCount,
WaitReason = row.WaitReason,
SortOrder = row.SortOrder,
Dismissed = row.Dismissed != 0,
AdditionalSources = sources
};
}
private sealed class TransferJobRow
{
public long Id { get; set; }
public string Op { get; set; } = "";
public string Src { get; set; } = "";
public string? Dst { get; set; }
public string Status { get; set; } = "";
public long? BytesTotal { get; set; }
public long BytesDone { get; set; }
public long FilesDone { get; set; }
public long FilesTotal { get; set; }
public string? CurrentPath { get; set; }
public string CreatedUtc { get; set; } = "";
public string? StartedUtc { get; set; }
public string? Error { get; set; }
public int RetryCount { get; set; }
public string? WaitReason { get; set; }
public int SortOrder { get; set; }
public int Dismissed { get; set; }
}
}

View File

@@ -0,0 +1,131 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class OperationProfileStore : IOperationProfileStore
{
private readonly SqliteIndexStore _store;
public OperationProfileStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<Row>("SELECT * FROM operation_profiles ORDER BY name COLLATE NOCASE, id")
.ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public async Task<OperationProfile?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QueryFirstOrDefaultAsync<Row>("SELECT * FROM operation_profiles WHERE id=@id", new { id })
.ConfigureAwait(false);
return row is null ? null : ToModel(row);
}
public Task<long> UpsertAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (profile.Id <= 0)
{
return await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO operation_profiles (name, source_path, dest_path, require_git_clean, do_compress,
archive_format, do_copy, do_rename, rename_prefix, rename_suffix, rename_search, rename_replace,
excludes, auto_run, source_volume_guid, dest_volume_guid, is_builtin, created_utc, last_run_utc,
last_status)
VALUES (@name, @src, @dst, @git, @compress, @fmt, @copy, @rename, @prefix, @suffix, @search, @replace,
@excludes, @auto, @sg, @dg, @builtin, @created, @run, @status);
""", Args(profile)).ConfigureAwait(false);
}
await conn.ExecuteAsync("""
UPDATE operation_profiles SET name=@name, source_path=@src, dest_path=@dst, require_git_clean=@git,
do_compress=@compress, archive_format=@fmt, do_copy=@copy, do_rename=@rename, rename_prefix=@prefix,
rename_suffix=@suffix, rename_search=@search, rename_replace=@replace, excludes=@excludes, auto_run=@auto,
source_volume_guid=@sg, dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
WHERE id=@id
""", Args(profile)).ConfigureAwait(false);
return profile.Id;
}, cancellationToken);
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM operation_profiles WHERE id=@id", new { id }), cancellationToken);
private static object Args(OperationProfile profile) => new
{
id = profile.Id,
name = profile.Name,
src = profile.SourcePath ?? "",
dst = profile.DestPath ?? "",
git = profile.RequireGitClean ? 1 : 0,
compress = profile.DoCompress ? 1 : 0,
fmt = profile.ArchiveFormat.ToString(),
copy = profile.DoCopy ? 1 : 0,
rename = profile.DoRename ? 1 : 0,
prefix = profile.RenamePrefix ?? "",
suffix = profile.RenameSuffix ?? "",
search = profile.RenameSearch ?? "",
replace = profile.RenameReplace ?? "",
excludes = profile.Excludes ?? "",
auto = profile.AutoRun ? 1 : 0,
sg = profile.SourceVolumeGuid,
dg = profile.DestVolumeGuid,
builtin = profile.IsBuiltIn ? 1 : 0,
created = (profile.CreatedUtc == default ? DateTimeOffset.UtcNow : profile.CreatedUtc).ToString("O"),
run = profile.LastRunUtc?.ToString("O"),
status = profile.LastStatus
};
private static OperationProfile ToModel(Row row) => new()
{
Id = row.id,
Name = row.name,
SourcePath = row.source_path ?? "",
DestPath = row.dest_path ?? "",
RequireGitClean = row.require_git_clean != 0,
DoCompress = row.do_compress != 0,
ArchiveFormat = Enum.TryParse<ArchiveFormat>(row.archive_format, true, out var fmt) ? fmt : ArchiveFormat.SevenZip,
DoCopy = row.do_copy != 0,
DoRename = row.do_rename != 0,
RenamePrefix = row.rename_prefix ?? "",
RenameSuffix = row.rename_suffix ?? "",
RenameSearch = row.rename_search ?? "",
RenameReplace = row.rename_replace ?? "",
Excludes = row.excludes ?? "",
AutoRun = row.auto_run != 0,
SourceVolumeGuid = row.source_volume_guid,
DestVolumeGuid = row.dest_volume_guid,
IsBuiltIn = row.is_builtin != 0,
CreatedUtc = DateTimeOffset.Parse(row.created_utc),
LastRunUtc = string.IsNullOrWhiteSpace(row.last_run_utc) ? null : DateTimeOffset.Parse(row.last_run_utc),
LastStatus = row.last_status
};
private sealed class Row
{
public long id { get; set; }
public string name { get; set; } = "";
public string source_path { get; set; } = "";
public string dest_path { get; set; } = "";
public int require_git_clean { get; set; }
public int do_compress { get; set; }
public string archive_format { get; set; } = "";
public int do_copy { get; set; }
public int do_rename { get; set; }
public string rename_prefix { get; set; } = "";
public string rename_suffix { get; set; } = "";
public string rename_search { get; set; } = "";
public string rename_replace { get; set; } = "";
public string excludes { get; set; } = "";
public int auto_run { get; set; }
public string? source_volume_guid { get; set; }
public string? dest_volume_guid { get; set; }
public int is_builtin { get; set; }
public string created_utc { get; set; } = "";
public string? last_run_utc { get; set; }
public string? last_status { get; set; }
}
}

View File

@@ -0,0 +1,67 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class RenameBatchStore : IRenameBatchStore
{
private readonly SqliteIndexStore _store;
public RenameBatchStore(SqliteIndexStore store) => _store = store;
public Task<long> CreateAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO rename_batches (created_utc, undone) VALUES (@utc, 0);
""", new { utc = DateTimeOffset.UtcNow.ToString("O") }).ConfigureAwait(false);
foreach (var item in items)
{
await conn.ExecuteAsync("""
INSERT INTO rename_batch_items (batch_id, old_path, new_path, sort_order)
VALUES (@id, @old, @newPath, @order)
""", new { id, old = item.OldPath, newPath = item.NewPath, order = item.SortOrder })
.ConfigureAwait(false);
}
return id;
}, cancellationToken);
public async Task<RenameBatch?> GetLatestUndoableAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var header = await conn.QueryFirstOrDefaultAsync<BatchHeader>(
"SELECT id, created_utc, undone FROM rename_batches WHERE undone = 0 ORDER BY id DESC LIMIT 1")
.ConfigureAwait(false);
if (header is null)
{
return null;
}
var items = (await conn.QueryAsync<(string old_path, string new_path, int sort_order)>(
"SELECT old_path, new_path, sort_order FROM rename_batch_items WHERE batch_id=@id ORDER BY sort_order",
new { id = header.id })
.ConfigureAwait(false))
.Select(r => new RenameBatchItem(r.old_path, r.new_path, r.sort_order))
.ToList();
return new RenameBatch
{
Id = header.id,
CreatedUtc = DateTimeOffset.Parse(header.created_utc),
Undone = header.undone != 0,
Items = items
};
}
public Task MarkUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync(
"UPDATE rename_batches SET undone=1 WHERE id=@id", new { id }), cancellationToken);
}
file sealed class BatchHeader
{
public long id { get; set; }
public string created_utc { get; set; } = "";
public int undone { get; set; }
}

View File

@@ -151,11 +151,20 @@ internal static class SchemaScript
dst TEXT,
status TEXT NOT NULL,
bytes_total INTEGER,
bytes_done INTEGER,
bytes_done INTEGER NOT NULL DEFAULT 0,
files_done INTEGER NOT NULL DEFAULT 0,
files_total INTEGER NOT NULL DEFAULT 0,
current_path TEXT,
created_utc TEXT NOT NULL,
error TEXT
started_utc TEXT,
error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
wait_reason TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
dismissed INTEGER NOT NULL DEFAULT 0
)
""",
"CREATE INDEX IF NOT EXISTS ix_transfer_jobs_status ON transfer_jobs(status, dismissed, sort_order, id)",
"""
CREATE TABLE hash_queue (
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
@@ -191,6 +200,78 @@ internal static class SchemaScript
value TEXT NOT NULL
)
""",
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')"
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')",
"""
CREATE TABLE file_relations (
id INTEGER PRIMARY KEY,
left_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
right_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
origin TEXT NOT NULL,
created_utc TEXT NOT NULL,
CHECK (left_entry_id < right_entry_id),
UNIQUE (left_entry_id, right_entry_id, kind)
)
""",
"CREATE INDEX ix_file_relations_left ON file_relations(left_entry_id)",
"CREATE INDEX ix_file_relations_right ON file_relations(right_entry_id)",
"""
CREATE TABLE rename_batches (
id INTEGER PRIMARY KEY,
created_utc TEXT NOT NULL,
undone INTEGER NOT NULL DEFAULT 0
)
""",
"""
CREATE TABLE rename_batch_items (
id INTEGER PRIMARY KEY,
batch_id INTEGER NOT NULL REFERENCES rename_batches(id) ON DELETE CASCADE,
old_path TEXT NOT NULL,
new_path TEXT NOT NULL,
sort_order INTEGER NOT NULL
)
""",
"CREATE INDEX ix_rename_batches_undo ON rename_batches(undone, id DESC)",
"""
CREATE TABLE sync_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL,
dest_path TEXT NOT NULL,
mode TEXT NOT NULL,
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""",
"""
CREATE TABLE operation_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL DEFAULT '',
dest_path TEXT NOT NULL DEFAULT '',
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',
rename_suffix TEXT NOT NULL DEFAULT '',
rename_search TEXT NOT NULL DEFAULT '',
rename_replace TEXT NOT NULL DEFAULT '',
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
"""
];
}

View File

@@ -29,6 +29,10 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
Analysis = new AnalysisStore(this);
History = new HistoryStore(this);
Hashes = new HashStore(this);
Relations = new FileRelationStore(this);
RenameBatches = new RenameBatchStore(this);
SyncProfiles = new SyncProfileStore(this);
OperationProfiles = new OperationProfileStore(this);
}
public ISourceStore Sources { get; }
@@ -40,6 +44,10 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public IAnalysisStore Analysis { get; }
public IHistoryStore History { get; }
public IHashStore Hashes { get; }
public IFileRelationStore Relations { get; }
public IRenameBatchStore RenameBatches { get; }
public ISyncProfileStore SyncProfiles { get; }
public IOperationProfileStore OperationProfiles { get; }
internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open.");
@@ -250,7 +258,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
cmd.ExecuteNonQuery();
}
SetUserVersion(conn, 3);
if (ReadUserVersion(conn) < 3)
{
SetUserVersion(conn, 3);
}
Volatile.Write(ref _analysisIndexesReady, 1);
_logger.LogInformation("Migrated SQLite schema to v3 (analysis indexes)");
}
@@ -336,6 +348,155 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
throw new InvalidOperationException(
"Index database is incomplete. Delete index.db under LocalAppData\\ExplorerWorkbench and restart.");
}
if (TableExists(conn, "transfer_jobs") && version < 4)
{
EnsureColumn(conn, "transfer_jobs", "files_done", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "files_total", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "current_path", "TEXT");
EnsureColumn(conn, "transfer_jobs", "started_utc", "TEXT");
EnsureColumn(conn, "transfer_jobs", "retry_count", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "wait_reason", "TEXT");
EnsureColumn(conn, "transfer_jobs", "sort_order", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "dismissed", "INTEGER NOT NULL DEFAULT 0");
using var idx = conn.CreateCommand();
idx.CommandText = "CREATE INDEX IF NOT EXISTS ix_transfer_jobs_status ON transfer_jobs(status, dismissed, sort_order, id)";
idx.ExecuteNonQuery();
SetUserVersion(conn, 4);
_logger.LogInformation("Migrated SQLite schema to v4 (transfer queue persistence)");
version = 4;
}
if (version < 5)
{
if (!TableExists(conn, "file_relations"))
{
using var table = conn.CreateCommand();
table.CommandText = """
CREATE TABLE file_relations (
id INTEGER PRIMARY KEY,
left_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
right_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
origin TEXT NOT NULL,
created_utc TEXT NOT NULL,
CHECK (left_entry_id < right_entry_id),
UNIQUE (left_entry_id, right_entry_id, kind)
)
""";
table.ExecuteNonQuery();
using var left = conn.CreateCommand();
left.CommandText = "CREATE INDEX IF NOT EXISTS ix_file_relations_left ON file_relations(left_entry_id)";
left.ExecuteNonQuery();
using var right = conn.CreateCommand();
right.CommandText = "CREATE INDEX IF NOT EXISTS ix_file_relations_right ON file_relations(right_entry_id)";
right.ExecuteNonQuery();
}
SetUserVersion(conn, 5);
_logger.LogInformation("Migrated SQLite schema to v5 (file relations)");
version = 5;
}
if (version < 6)
{
if (!TableExists(conn, "rename_batches"))
{
using var batches = conn.CreateCommand();
batches.CommandText = """
CREATE TABLE rename_batches (
id INTEGER PRIMARY KEY,
created_utc TEXT NOT NULL,
undone INTEGER NOT NULL DEFAULT 0
)
""";
batches.ExecuteNonQuery();
using var items = conn.CreateCommand();
items.CommandText = """
CREATE TABLE rename_batch_items (
id INTEGER PRIMARY KEY,
batch_id INTEGER NOT NULL REFERENCES rename_batches(id) ON DELETE CASCADE,
old_path TEXT NOT NULL,
new_path TEXT NOT NULL,
sort_order INTEGER NOT NULL
)
""";
items.ExecuteNonQuery();
using var idx = conn.CreateCommand();
idx.CommandText = "CREATE INDEX IF NOT EXISTS ix_rename_batches_undo ON rename_batches(undone, id DESC)";
idx.ExecuteNonQuery();
}
SetUserVersion(conn, 6);
_logger.LogInformation("Migrated SQLite schema to v6 (rename batch journal)");
version = 6;
}
if (version < 7)
{
if (!TableExists(conn, "sync_profiles"))
{
using var profiles = conn.CreateCommand();
profiles.CommandText = """
CREATE TABLE sync_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL,
dest_path TEXT NOT NULL,
mode TEXT NOT NULL,
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""";
profiles.ExecuteNonQuery();
}
SetUserVersion(conn, 7);
_logger.LogInformation("Migrated SQLite schema to v7 (folder sync profiles)");
version = 7;
}
if (version < 8)
{
if (!TableExists(conn, "operation_profiles"))
{
using var profiles = conn.CreateCommand();
profiles.CommandText = """
CREATE TABLE operation_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL DEFAULT '',
dest_path TEXT NOT NULL DEFAULT '',
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',
rename_suffix TEXT NOT NULL DEFAULT '',
rename_search TEXT NOT NULL DEFAULT '',
rename_replace TEXT NOT NULL DEFAULT '',
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""";
profiles.ExecuteNonQuery();
}
SetUserVersion(conn, 8);
_logger.LogInformation("Migrated SQLite schema to v8 (operation profiles)");
}
}
private static void EnsureColumn(SqliteConnection conn, string table, string column, string type)

View File

@@ -0,0 +1,99 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class SyncProfileStore : ISyncProfileStore
{
private readonly SqliteIndexStore _store;
public SyncProfileStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<Row>("SELECT * FROM sync_profiles ORDER BY name COLLATE NOCASE, id")
.ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public async Task<SyncProfile?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QueryFirstOrDefaultAsync<Row>("SELECT * FROM sync_profiles WHERE id=@id", new { id })
.ConfigureAwait(false);
return row is null ? null : ToModel(row);
}
public Task<long> UpsertAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (profile.Id <= 0)
{
return await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO sync_profiles (name, source_path, dest_path, mode, excludes, auto_run,
source_volume_guid, dest_volume_guid, created_utc, last_run_utc, last_status)
VALUES (@name, @src, @dst, @mode, @excludes, @auto, @sg, @dg, @created, @run, @status);
""", Args(profile)).ConfigureAwait(false);
}
await conn.ExecuteAsync("""
UPDATE sync_profiles SET name=@name, source_path=@src, dest_path=@dst, mode=@mode, excludes=@excludes,
auto_run=@auto, source_volume_guid=@sg, dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
WHERE id=@id
""", Args(profile)).ConfigureAwait(false);
return profile.Id;
}, cancellationToken);
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM sync_profiles WHERE id=@id", new { id }), cancellationToken);
private static object Args(SyncProfile profile) => new
{
id = profile.Id,
name = profile.Name,
src = profile.SourcePath,
dst = profile.DestPath,
mode = profile.Mode.ToString(),
excludes = profile.Excludes ?? "",
auto = profile.AutoRun ? 1 : 0,
sg = profile.SourceVolumeGuid,
dg = profile.DestVolumeGuid,
created = (profile.CreatedUtc == default ? DateTimeOffset.UtcNow : profile.CreatedUtc).ToString("O"),
run = profile.LastRunUtc?.ToString("O"),
status = profile.LastStatus
};
private static SyncProfile ToModel(Row row) => new()
{
Id = row.id,
Name = row.name,
SourcePath = row.source_path,
DestPath = row.dest_path,
Mode = Enum.Parse<SyncMode>(row.mode, ignoreCase: true),
Excludes = row.excludes ?? "",
AutoRun = row.auto_run != 0,
SourceVolumeGuid = row.source_volume_guid,
DestVolumeGuid = row.dest_volume_guid,
CreatedUtc = DateTimeOffset.Parse(row.created_utc),
LastRunUtc = string.IsNullOrWhiteSpace(row.last_run_utc) ? null : DateTimeOffset.Parse(row.last_run_utc),
LastStatus = row.last_status
};
private sealed class Row
{
public long id { get; set; }
public string name { get; set; } = "";
public string source_path { get; set; } = "";
public string dest_path { get; set; } = "";
public string mode { get; set; } = "";
public string excludes { get; set; } = "";
public int auto_run { get; set; }
public string? source_volume_guid { get; set; }
public string? dest_volume_guid { get; set; }
public string created_utc { get; set; } = "";
public string? last_run_utc { get; set; }
public string? last_status { get; set; }
}
}

View File

@@ -175,6 +175,24 @@ internal static partial class NativeMethods
public ushort MaxMajorVersion;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern int SHQueryRecycleBin(string? pszRootPath, ref ShQueryRbInfo pSHQueryRBInfo);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern int SHEmptyRecycleBin(nint hwnd, string? pszRootPath, uint dwFlags);
public const uint SherbNoConfirmation = 0x00000001;
public const uint SherbNoProgressUi = 0x00000002;
public const uint SherbNoSound = 0x00000004;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct ShQueryRbInfo
{
public int cbSize;
public long i64Size;
public long i64NumItems;
}
public static string FromCharBuffer(char[] buffer)
{
var n = Array.IndexOf(buffer, '\0');

View File

@@ -0,0 +1,155 @@
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Windows;
public sealed class SevenZipArchiveExecutor : IArchiveExecutor
{
private static readonly Regex Percent = new(@"(\d{1,3})\s*%", RegexOptions.CultureInvariant);
private readonly Func<string?> _configuredPath;
public SevenZipArchiveExecutor(UiPreferencesStore preferences)
=> _configuredPath = () => preferences.Load().SevenZipPath;
public bool IsAvailable => SevenZipLocator.Find(_configuredPath()) is not null;
public string MissingHint => SevenZipLocator.MissingHint;
public Task ExtractAsync(
string archivePath,
string destinationDirectory,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(PathRules.ToExtended(destinationDirectory));
return RunAsync(
["x", archivePath, "-o" + destinationDirectory, "-y", "-aoa", "-bb1", "-bsp1"],
PathRules.Parent(archivePath),
progress,
cancellationToken);
}
public Task CompressAsync(
IReadOnlyList<string> sources,
string archivePath,
ArchiveFormat format,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken)
{
var type = format == ArchiveFormat.SevenZip ? "-t7z" : "-tzip";
var args = new List<string> { "a", type, "-y", "-bb1", "-bsp1", archivePath };
args.AddRange(RelativeSources(sources, out var workDir));
return RunAsync(args, workDir, progress, cancellationToken);
}
public Task AddAsync(
string archivePath,
IReadOnlyList<string> sources,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken)
{
var args = new List<string> { "a", "-y", "-bb1", "-bsp1", archivePath };
args.AddRange(RelativeSources(sources, out var workDir));
return RunAsync(args, workDir, progress, cancellationToken);
}
public Task VerifyAsync(
string archivePath,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken)
=> RunAsync(
["t", archivePath, "-bb1", "-bsp1"],
PathRules.Parent(archivePath),
progress,
cancellationToken);
private async Task RunAsync(
IReadOnlyList<string> arguments,
string? workingDirectory,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken)
{
var exe = SevenZipLocator.Find(_configuredPath())
?? throw new InvalidOperationException(MissingHint);
var psi = new ProcessStartInfo
{
FileName = exe,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
foreach (var argument in arguments)
{
psi.ArgumentList.Add(argument);
}
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
var errors = new StringBuilder();
var files = 0L;
process.OutputDataReceived += (_, e) =>
{
if (string.IsNullOrEmpty(e.Data))
{
return;
}
var match = Percent.Match(e.Data);
if (match.Success && int.TryParse(match.Groups[1].Value, out var pct))
{
progress?.Report(new ArchiveProgress(Math.Clamp(pct, 0, 100), files, null));
}
if (e.Data.StartsWith('+') || e.Data.StartsWith('-') || e.Data.StartsWith('T'))
{
files++;
progress?.Report(new ArchiveProgress(0, files, e.Data.Trim()));
}
};
process.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
errors.AppendLine(e.Data);
}
};
if (!process.Start())
{
throw new IOException("7-Zip could not be started.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await using var kill = cancellationToken.Register(() =>
{
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
});
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode > 1)
{
var detail = errors.ToString().Trim();
throw new IOException(string.IsNullOrEmpty(detail) ? $"7-Zip failed ({process.ExitCode})." : detail);
}
}
private static IEnumerable<string> RelativeSources(IReadOnlyList<string> sources, out string workDir)
{
if (sources.All(s => PathRules.Parent(s).Equals(PathRules.Parent(sources[0]), StringComparison.OrdinalIgnoreCase)))
{
workDir = PathRules.Parent(sources[0]);
return sources.Select(PathRules.GetFileName).ToList();
}
workDir = PathRules.Parent(sources[0]);
return sources;
}
}

Some files were not shown because too many files have changed in this diff Show More