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

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