Add Explorer Workbench with hierarchical, off-UI Storage analysis.

Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-22 12:43:05 +02:00
commit e9aba73552
130 changed files with 15110 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
namespace Explorer.Analysis;
internal sealed class AnalysisResultCache
{
private const int MaxEntries = 32;
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(20);
private readonly object _gate = new();
private readonly Dictionary<string, Entry> _items = new(StringComparer.Ordinal);
public bool TryGet<T>(string key, long stamp, out T value)
{
lock (_gate)
{
if (_items.TryGetValue(key, out var entry)
&& entry.Stamp == stamp
&& entry.Utc + Ttl > DateTime.UtcNow
&& entry.Value is T typed)
{
value = typed;
return true;
}
}
value = default!;
return false;
}
public void Set<T>(string key, long stamp, T value)
{
lock (_gate)
{
_items[key] = new Entry(stamp, value!, DateTime.UtcNow);
if (_items.Count <= MaxEntries)
{
return;
}
var oldest = _items.OrderBy(p => p.Value.Utc).First().Key;
_items.Remove(oldest);
}
}
private readonly record struct Entry(long Stamp, object Value, DateTime Utc);
}

View File

@@ -0,0 +1,117 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Analysis;
public sealed class AnalysisService
{
private readonly IIndexStore _store;
private readonly AnalysisResultCache _cache = new();
private readonly SemaphoreSlim _ready = new(1, 1);
private bool _readyDone;
public AnalysisService(IIndexStore store) => _store = store;
public Task EnsureReadyAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
if (_readyDone)
{
return 0;
}
await _ready.WaitAsync(ct).ConfigureAwait(false);
try
{
if (!_readyDone)
{
await _store.Analysis.EnsureReadyAsync(CancellationToken.None).ConfigureAwait(false);
_readyDone = true;
}
}
finally
{
_ready.Release();
}
return 0;
}, cancellationToken);
public Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Analysis.GetIndexStampAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default)
=> CachedAsync("roots", ct => _store.Analysis.GetDirectoryRootsAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(
long? sourceId,
long? parentId,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"dirs:{sourceId}:{parentId}:{take}",
ct => _store.Analysis.LargestDirectoriesAsync(sourceId, parentId, take, ct),
cancellationToken);
public Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(
long? sourceId,
string? pathRelPrefix,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"files:{sourceId}:{pathRelPrefix}:{take}",
ct => _store.Analysis.LargestFilesAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(
long? sourceId,
string? pathRelPrefix,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"types:{sourceId}:{pathRelPrefix}:{take}",
ct => _store.Analysis.UsageByExtensionAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
=> CachedAsync("sources", ct => _store.Analysis.UsageBySourceAsync(ct), cancellationToken);
public Task<IReadOnlyList<IndexEntry>> DrilldownAsync(
long parentId,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"children:{parentId}:{take}",
ct => _store.Analysis.ChildrenBySizeAsync(parentId, take, ct),
cancellationToken);
public Task<IReadOnlyList<Source>> GetKnownSourcesAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken);
private async Task<T> CachedAsync<T>(string key, Func<CancellationToken, Task<T>> query, CancellationToken cancellationToken)
{
await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);
var stamp = await GetIndexStampAsync(cancellationToken).ConfigureAwait(false);
if (_cache.TryGet<T>(key, stamp, out var hit))
{
return hit;
}
var value = await RunOffUiAsync(query, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
_cache.Set(key, stamp, value);
return value;
}
public static async Task<T> RunOffUiAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (SynchronizationContext.Current is null)
{
return await work(cancellationToken).ConfigureAwait(false);
}
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,158 @@
using System.Security.Cryptography;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Analysis;
public sealed class DuplicateHashWorker : BackgroundService
{
private readonly IIndexStore _store;
private readonly IHydrationGuard _hydration;
private readonly ILogger<DuplicateHashWorker> _logger;
private volatile bool _paused;
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
{
_store = store;
_hydration = hydration;
_logger = logger;
}
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
{
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
foreach (var item in batch)
{
if (_paused || cancellationToken.IsCancellationRequested)
{
break;
}
if (item.RootPath is null)
{
continue;
}
var path = PathRules.Combine(item.RootPath, item.PathRel);
try
{
if (_hydration.WouldHydrateOnRead(item.Attributes, item.CloudAvailability)
|| await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false))
{
await _store.Hashes.MarkSkippedAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
continue;
}
if (item.State == "Pending")
{
var hash = await HashAsync(path, AppConstants.PartialHashBytes, cancellationToken).ConfigureAwait(false);
if (hash is not null)
{
await _store.Hashes.CompletePartialAsync(item.EntryId, hash, cancellationToken).ConfigureAwait(false);
}
}
else if (item.State == "PartialDone")
{
if (!await _store.Hashes.HasPartialCollisionAsync(item.EntryId, item.SizeBytes, cancellationToken)
.ConfigureAwait(false))
{
await _store.Hashes.MarkUniquePartialAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
continue;
}
var hash = await HashAsync(path, null, cancellationToken).ConfigureAwait(false);
if (hash is not null)
{
await _store.Hashes.CompleteFullAsync(item.EntryId, hash, cancellationToken).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Hash failed for {Path}", path);
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
if (_paused)
{
continue;
}
try
{
await ProcessPendingAsync(stoppingToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Hash worker loop error");
}
}
}
private static async Task<byte[]?> HashAsync(string path, int? limit, CancellationToken cancellationToken)
{
var ext = PathRules.ToExtended(path);
await using var stream = new FileStream(ext, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
if (limit is int n)
{
var buffer = new byte[n];
var read = await stream.ReadAsync(buffer.AsMemory(0, n), cancellationToken).ConfigureAwait(false);
return SHA256.HashData(buffer.AsSpan(0, read));
}
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
}
}
public sealed class HistoryRollupService : BackgroundService
{
private readonly IIndexStore _store;
private DateTime _last = DateTime.MinValue;
public HistoryRollupService(IIndexStore store) => _store = store;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
await CaptureAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await CaptureAsync(stoppingToken).ConfigureAwait(false);
}
}
private async Task CaptureAsync(CancellationToken cancellationToken)
{
if ((DateTime.UtcNow - _last).TotalHours < 20)
{
return;
}
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var utc = DateTimeOffset.UtcNow;
foreach (var source in sources.Where(s => s.IsIndexed))
{
await _store.History.CaptureSourceSnapshotAsync(source.Id, utc, cancellationToken).ConfigureAwait(false);
await _store.History.CaptureDirectorySnapshotsAsync(
source.Id, utc, AppConstants.DirectoryHistoryThresholdBytes, AppConstants.DirectoryHistoryTopN, cancellationToken)
.ConfigureAwait(false);
}
var days = AppConstants.DefaultTombstoneRetentionDays;
await _store.Entries.DeleteExpiredTombstonesAsync(utc.AddDays(-days), cancellationToken).ConfigureAwait(false);
_last = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Analysis</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

850
src/Explorer.App/App.xaml Normal file
View File

@@ -0,0 +1,850 @@
<Application x:Class="Explorer.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Explorer.App"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Dark.xaml"/>
</ResourceDictionary.MergedDictionaries>
<FontFamily x:Key="Symbol">Segoe MDL2 Assets</FontFamily>
<BooleanToVisibilityConverter x:Key="BoolVis"/>
<local:ActiveThicknessConverter x:Key="ActiveThickness"/>
<local:FractionWidthConverter x:Key="FractionWidth"/>
<local:IndentConverter x:Key="Indent"/>
<local:ViewModeToVisibilityConverter x:Key="ViewDetails" Match="Details"/>
<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"/>
<DataTemplate x:Key="NameWithIcon">
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Converter={StaticResource ShellIcon}}"/>
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasCloudStatus, 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"
Source="{Binding Converter={StaticResource ShellIcon}, ConverterParameter=large}">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImage}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
</Grid>
<TextBlock Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}"/>
</StackPanel>
</DataTemplate>
<Style x:Key="CrumbButton" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="8,3"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<DataTemplate x:Key="PaneAddressBar">
<DockPanel LastChildFill="True" Margin="6,6,6,4">
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
Style="{StaticResource CrumbButton}"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}"
ToolTip="Up one folder"/>
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" CornerRadius="4" Padding="4,0" MinHeight="28">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Focusable="False">
<ItemsControl ItemsSource="{Binding Breadcrumb}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource CrumbButton}"
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
ToolTip="{Binding Path}">
<StackPanel Orientation="Horizontal">
<TextBlock FontFamily="{StaticResource Symbol}" Text="&#xE977;" FontSize="14"
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Label}" Value="This PC">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
</StackPanel>
</Button>
<TextBlock Text="" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLast}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</DataTemplate>
<Style x:Key="PaneChrome" TargetType="Border">
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Padding" Value="0"/>
<Style.Triggers>
<DataTrigger Binding="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}}" Value="True">
<Setter Property="BorderThickness" Value="1"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsActive}" Value="True"/>
<Condition Binding="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}}" Value="True"/>
</MultiDataTrigger.Conditions>
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter Property="BorderThickness" Value="3,1,1,1"/>
<Setter Property="Background" Value="{DynamicResource ActivePaneFill}"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ExpandToggle" TargetType="ToggleButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent" Padding="2">
<Path x:Name="Arrow"
Data="M 3 1 L 9 7 L 3 13 Z"
Fill="{DynamicResource FgMuted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Arrow" Property="Data" Value="M 1 3 L 13 3 L 7 11 Z"/>
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Fg}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Fg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource Fill}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Fg}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Padding" Value="8,4"/>
<Style.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource ListSelection}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
</Style.Triggers>
</Style>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="Toggle" ClickMode="Press" Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="22"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="1" Data="M 0 0 L 8 0 L 4 5 Z" Fill="{DynamicResource Fg}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter Margin="8,0,28,0" VerticalAlignment="Center" HorizontalAlignment="Left"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
IsHitTestVisible="False"/>
<Popup x:Name="PART_Popup" IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom" AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1">
<ScrollViewer>
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PathComboBox" TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
<Setter Property="IsEditable" Value="True"/>
<Setter Property="IsTextSearchEnabled" Value="False"/>
<Setter Property="StaysOpenOnEdit" Value="True"/>
<Setter Property="MaxDropDownHeight" Value="280"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" CornerRadius="4"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="26"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="PART_EditableTextBox"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource Fg}"
CaretBrush="{DynamicResource Fg}"
Padding="8,6"
VerticalContentAlignment="Center"
FontSize="13"/>
<ToggleButton Grid.Column="1" Focusable="False" ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent">
<Path Data="M 0 0 L 8 0 L 4 5 Z" Fill="{DynamicResource Fg}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</Grid>
<Popup x:Name="PART_Popup" IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom"
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1">
<ScrollViewer>
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Separator">
<Setter Property="Background" Value="{DynamicResource Stroke}"/>
<Setter Property="Margin" Value="8,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Height="1" Background="{TemplateBinding Background}" Margin="{TemplateBinding Margin}"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" 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>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>
<Setter Property="Foreground" Value="{DynamicResource Accent}"/>
<Setter Property="Background" Value="{DynamicResource Fill}"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<Style x:Key="StorageSizeBar" TargetType="ProgressBar" BasedOn="{StaticResource {x:Type ProgressBar}}">
<Setter Property="Height" Value="12"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="1"/>
<Setter Property="Margin" Value="8,0"/>
</Style>
<Style x:Key="CaptionButton" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="40"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="shell:WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="CaptionCloseButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Danger}"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PlainListItem" TargetType="ListViewItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="6,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
</Style>
<Style TargetType="ListView">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
</Style>
<Style TargetType="ListViewItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<Grid>
<GridViewRowPresenter x:Name="GridRow"
Content="{TemplateBinding Content}"
VerticalAlignment="Center"/>
<ContentPresenter x:Name="Content"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="GridView.ColumnCollection" Value="{x:Null}">
<Setter TargetName="Content" Property="Visibility" Value="Visible"/>
<Setter TargetName="GridRow" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="IconListItem" TargetType="ListViewItem" BasedOn="{StaticResource {x:Type ListViewItem}}">
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Top"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Margin" Value="2"/>
</Style>
<Style x:Key="ColumnHeaderGripper" TargetType="Thumb">
<Setter Property="Width" Value="8"/>
<Setter Property="Cursor" Value="SizeWE"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="Transparent">
<Border Width="1" HorizontalAlignment="Center" Background="{DynamicResource Stroke}"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="GridViewColumnHeader">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<Setter Property="BorderThickness" Value="0,0,1,1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<Grid>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"/>
</Border>
<Thumb x:Name="PART_HeaderGripper" Style="{StaticResource ColumnHeaderGripper}"
HorizontalAlignment="Right"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<Trigger Property="Role" Value="Padding">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="0,0,0,1"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="TreeView">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="4,0,4,8"/>
</Style>
<Style TargetType="TreeViewItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="4,3"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeViewItem">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ToggleButton x:Name="Expander"
Style="{StaticResource ExpandToggle}"
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"/>
<Border x:Name="Bd"
Grid.Column="1"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
CornerRadius="3"
SnapsToDevicePixels="True">
<ContentPresenter x:Name="PART_Header"
ContentSource="Header"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</Border>
<ItemsPresenter x:Name="ItemsHost" Grid.Row="1" Grid.Column="1"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="False">
<Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="HasItems" Value="False">
<Setter TargetName="Expander" Property="Visibility" Value="Hidden"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsSelectionActive" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</MultiTrigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
</MultiTrigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding IsPlaceholder}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style TargetType="TabControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TabPanel Grid.Row="0" IsItemsHost="True" Margin="4,4,4,0"/>
<Border Grid.Row="1" Background="{DynamicResource Bg}">
<ContentPresenter x:Name="PART_SelectedContentHost"
ContentSource="SelectedContent"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="TabItem">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="12,8"/>
<Setter Property="MinWidth" Value="140"/>
<Setter Property="MinHeight" Value="32"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabItem">
<Border x:Name="Bd"
Padding="{TemplateBinding Padding}"
BorderBrush="Transparent"
BorderThickness="0,0,0,2"
Background="Transparent"
Margin="0,0,4,0">
<ContentPresenter ContentSource="Header" HorizontalAlignment="Left" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarThumb" TargetType="Thumb">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd"
Background="{DynamicResource ScrollThumb}"
CornerRadius="4"
Margin="3,2"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbHover}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbPressed}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarThumbHorizontal" TargetType="Thumb" BasedOn="{StaticResource ScrollBarThumb}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd"
Background="{DynamicResource ScrollThumb}"
CornerRadius="4"
Margin="2,3"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbHover}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbPressed}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarPageButton" TargetType="RepeatButton">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="IsTabStop" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="VerticalScrollBar" TargetType="ScrollBar">
<Border Background="{DynamicResource ScrollTrack}" SnapsToDevicePixels="True">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand" Style="{StaticResource ScrollBarPageButton}"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumb}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand" Style="{StaticResource ScrollBarPageButton}"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
<ControlTemplate x:Key="HorizontalScrollBar" TargetType="ScrollBar">
<Border Background="{DynamicResource ScrollTrack}" SnapsToDevicePixels="True">
<Track x:Name="PART_Track" IsDirectionReversed="False">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageLeftCommand" Style="{StaticResource ScrollBarPageButton}"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumbHorizontal}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageRightCommand" Style="{StaticResource ScrollBarPageButton}"/>
</Track.IncreaseRepeatButton>
</Track>
</Border>
</ControlTemplate>
<Style TargetType="ScrollBar">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="False"/>
<Setter Property="Width" Value="12"/>
<Setter Property="MinWidth" Value="12"/>
<Setter Property="Template" Value="{StaticResource VerticalScrollBar}"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="MinWidth" Value="0"/>
<Setter Property="Height" Value="12"/>
<Setter Property="MinHeight" Value="12"/>
<Setter Property="Template" Value="{StaticResource HorizontalScrollBar}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,56 @@
using System.IO;
using System.Windows;
using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
namespace Explorer.App;
public partial class App : System.Windows.Application
{
private IHost? _host;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
DispatcherUnhandledException += (_, args) =>
{
Log.Error(args.Exception, "Unhandled UI exception");
args.Handled = true;
};
var env = new Windows.WindowsAppEnvironment();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(env.LogDirectory, "explorer-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14)
.CreateLogger();
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) => services.AddExplorer())
.Build();
var vm = _host.Services.GetRequiredService<MainViewModel>();
await vm.InitializeAsync().ConfigureAwait(true);
await _host.StartAsync().ConfigureAwait(true);
var window = _host.Services.GetRequiredService<MainWindow>();
window.DataContext = vm;
window.Show();
}
protected override async void OnExit(ExitEventArgs e)
{
if (_host is not null)
{
await _host.StopAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(true);
_host.Dispose();
}
Log.CloseAndFlush();
base.OnExit(e);
}
}

View File

@@ -0,0 +1,86 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App;
public static class AppServices
{
public static IServiceCollection AddExplorer(this IServiceCollection services)
{
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<BrowseService>();
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
}
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
public WatcherHostedService(DirectoryWatcherHub hub, SourceManager sources)
{
_hub = hub;
_sources = sources;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,107 @@
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;
public sealed class ActiveThicknessConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is true ? new Thickness(2) : new Thickness(0);
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class FractionWidthConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var fraction = value is double d ? d : 0;
return Math.Max(2, fraction * 360);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class IndentConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var depth = value is double d ? d : value is int i ? i : 0;
return new Thickness(depth, 0, 8, 0);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class ViewModeToVisibilityConverter : IValueConverter
{
public FolderViewMode Match { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is FolderViewMode mode && mode == Match ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> 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)
=> value is TransferStatus.Failed ? "Dismiss" : "Cancel";
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,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\..\explorer-workbench-icons\explorer-workbench.ico</ApplicationIcon>
<RootNamespace>Explorer.App</RootNamespace>
<AssemblyName>Explorer.App</AssemblyName>
<ApplicationTitle>Explorer Workbench</ApplicationTitle>
</PropertyGroup>
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using System.Windows;
using System.Windows.Controls;
namespace Explorer.App;
/// <summary>
/// Makes the first GridView column consume leftover width so Details/List fill the pane.
/// </summary>
public static class ListViewLayout
{
public static readonly DependencyProperty StretchFirstColumnProperty =
DependencyProperty.RegisterAttached(
"StretchFirstColumn",
typeof(bool),
typeof(ListViewLayout),
new PropertyMetadata(false, OnStretchChanged));
public static void SetStretchFirstColumn(ListView element, bool value) => element.SetValue(StretchFirstColumnProperty, value);
public static bool GetStretchFirstColumn(ListView element) => (bool)element.GetValue(StretchFirstColumnProperty);
private static void OnStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ListView list)
{
return;
}
if (e.NewValue is true)
{
list.SizeChanged += OnListSizeChanged;
list.Loaded += OnListSizeChanged;
}
else
{
list.SizeChanged -= OnListSizeChanged;
list.Loaded -= OnListSizeChanged;
}
}
private static void OnListSizeChanged(object sender, RoutedEventArgs e)
{
if (sender is ListView list)
{
Stretch(list);
}
}
public static void Stretch(ListView list)
{
if (list.View is not GridView view || view.Columns.Count == 0 || list.ActualWidth <= 0)
{
return;
}
double reserved = 8 + SystemParameters.VerticalScrollBarWidth;
for (var i = 1; i < view.Columns.Count; i++)
{
var width = view.Columns[i].Width;
reserved += double.IsNaN(width) ? 120 : width;
}
view.Columns[0].Width = Math.Max(96, list.ActualWidth - reserved);
}
}

View File

@@ -0,0 +1,630 @@
<Window x:Class="Explorer.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Explorer.Presentation.ViewModels;assembly=Explorer.Presentation"
xmlns:local="clr-namespace:Explorer.App"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Explorer Workbench"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="820" Width="1280"
MinHeight="500" MinWidth="800"
Background="{DynamicResource Bg}"
Foreground="{DynamicResource Fg}"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
PreviewKeyDown="OnPreviewKeyDown">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="40"
ResizeBorderThickness="6"
GlassFrameThickness="0"
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<DockPanel>
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False">
<Image Width="20" Height="20" Margin="0,0,8,0" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<TextBlock Text="Explorer Workbench" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CaptionButton}" Content="─" Click="OnMinimize" ToolTip="Minimize"/>
<Button x:Name="MaxRestoreButton" Style="{StaticResource CaptionButton}" Content="☐" Click="OnMaxRestore" ToolTip="Maximize"/>
<Button Style="{StaticResource CaptionCloseButton}" Content="✕" Click="OnCloseWindow" ToolTip="Close"/>
</StackPanel>
</Grid>
</Border>
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<Button Content="←" Command="{Binding BackCommand}" Width="36" Margin="0,0,4,0" ToolTip="Back (Alt+Left)"/>
<Button Content="→" Command="{Binding ForwardCommand}" Width="36" Margin="0,0,4,0" ToolTip="Forward (Alt+Right)"/>
<Button Content="↑" Command="{Binding UpCommand}" Width="36" Margin="0,0,4,0" ToolTip="Up (Alt+Up)"/>
<Button Content="↻" Command="{Binding RefreshCommand}" Width="36" Margin="0,0,12,0" ToolTip="Refresh (F5)"/>
<Button Content="New tab" Command="{Binding NewTabCommand}" Margin="0,0,4,0"/>
<Button Content="Split" Command="{Binding SplitCommand}" Margin="0,0,4,0"/>
<Button Content="Index" Command="{Binding BuildIndexCommand}" Margin="0,0,4,0"/>
<Button Content="Details" Command="{Binding SetViewCommand}" CommandParameter="Details" Margin="8,0,4,0"/>
<Button Content="List" Command="{Binding SetViewCommand}" CommandParameter="List" Margin="0,0,4,0"/>
<Button Content="Preview" Command="{Binding SetViewCommand}" CommandParameter="Preview"/>
</StackPanel>
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
ItemsSource="{Binding PathHistory}"
Text="{Binding PathText, UpdateSourceTrigger=PropertyChanged}"
PreviewKeyDown="OnPathKeyDown"
SelectionChanged="OnPathHistorySelected"/>
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
KeyDown="OnSearchKeyDown"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
<Button Content="Search" Command="{Binding SearchCommand}" Margin="0,0,4,0"/>
<Button Content="Theme" Command="{Binding ToggleThemeCommand}"/>
</StackPanel>
</Grid>
</Border>
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
<Grid>
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}" Margin="0,0,12,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,0,10,0">
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource FgMuted}"
Text="{Binding Op, StringFormat={}{0}:}"/>
<TextBlock VerticalAlignment="Center" FontSize="11" Margin="4,0,0,0" Text="{Binding Status}"/>
<Button Margin="6,0,0,0" Padding="6,2" FontSize="11"
Content="{Binding Status, Converter={StaticResource TransferAction}}"
Command="{Binding DataContext.CancelTransferCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
<Button Content="Add OneDrive" Click="OnAddOneDrive"/>
</StackPanel>
</Grid>
</Border>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" MinWidth="160"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0" Background="{DynamicResource Panel}">
<TextBlock DockPanel.Dock="Top" Margin="12,10,12,6" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" Text="Locations"/>
<TreeView x:Name="NavTree" ItemsSource="{Binding Tree.Roots}"
SelectedItemChanged="OnTreeSelected"
TreeViewItem.Expanded="OnTreeExpanded"
HorizontalContentAlignment="Stretch">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal">
<TextBlock FontFamily="{StaticResource Symbol}" Text="{Binding Glyph}" Margin="0,0,8,0" Foreground="{DynamicResource Accent}"/>
<TextBlock Text="{Binding Label}" Foreground="{DynamicResource Fg}"/>
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="{DynamicResource Stroke}"/>
<DockPanel Grid.Column="2" LastChildFill="True">
<TabControl x:Name="Tabs" ItemsSource="{Binding Tabs}" SelectedItem="{Binding ActiveTab}"
SelectionChanged="OnTabChanged"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<TabControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" MinWidth="120">
<TextBlock Text="⋮" Margin="0,0,8,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" Margin="0,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" MaxWidth="160"/>
<Button Content="×" Padding="4,0" MinWidth="22" Click="OnCloseTab" Tag="{Binding}"/>
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
<TabControl.ContentTemplate>
<DataTemplate>
<Grid Loaded="OnPaneSplitLoaded"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="0"/>
<ColumnDefinition Width="0" MinWidth="0"/>
<ColumnDefinition Width="0" MinWidth="0"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" DataContext="{Binding Left}" Style="{StaticResource PaneChrome}"
PreviewMouseDown="OnPaneChromeMouseDown">
<DockPanel LastChildFill="True">
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
Focusable="False"/>
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="Build index" Click="OnBuildIndex"/>
<TextBlock Text="{Binding IndexBannerText}" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource Fg}"/>
</DockPanel>
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
GotFocus="OnPaneFocus"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
</GridView>
</ListView.View>
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator/>
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Paste" Command="{Binding DataContext.PasteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Delete" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<Separator/>
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyPathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator/>
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Free up space"
Command="{Binding DataContext.FreeUpCloudSpaceCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ShowCloudDehydrate, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
</ContextMenu>
</ListView.ContextMenu>
</ListView>
<ListView ItemsSource="{Binding Items}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
GotFocus="OnPaneFocus"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
</Grid>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ResizeBehavior="PreviousAndNext"
Background="{DynamicResource Stroke}"
Visibility="{Binding IsSplit, Converter={StaticResource BoolVis}}"
DragCompleted="OnSplitDragCompleted"/>
<Border Grid.Column="2"
DataContext="{Binding Right}"
Style="{StaticResource PaneChrome}"
PreviewMouseDown="OnPaneChromeMouseDown"
Visibility="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}, Converter={StaticResource BoolVis}}">
<DockPanel LastChildFill="True">
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
Focusable="False"/>
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="Build index" Click="OnBuildIndex"/>
<TextBlock Text="{Binding IndexBannerText}" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource Fg}"/>
</DockPanel>
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
GotFocus="OnPaneFocus"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
GotFocus="OnPaneFocus"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
PreviewMouseLeftButtonDown="OnListMouseDown"
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
</Grid>
</DockPanel>
</Border>
</Grid>
</DataTemplate>
</TabControl.ContentTemplate>
</TabControl>
</DockPanel>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Search.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="40" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" LastChildFill="True" Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseSearch" Margin="8,0,0,0"/>
<TextBlock Text="Search" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
</DockPanel>
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="220"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,8"/>
<TextBox Grid.Column="1" Margin="0,0,16,8" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
KeyDown="OnSearchKeyDown"/>
<TextBlock Grid.Column="2" Text="Scope" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,8"/>
<ComboBox Grid.Column="3" Margin="0,0,0,8"
ItemsSource="{Binding Search.ScopeChoices}"
DisplayMemberPath="Label"
SelectedValuePath="Kind"
SelectedValue="{Binding Search.Scope}"/>
<TextBlock Grid.Row="1" Text="Extension" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,16,0">
<TextBox Width="90" Text="{Binding Search.Extension, UpdateSourceTrigger=PropertyChanged}"/>
<CheckBox Content="Files" Margin="16,0,8,0" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
IsChecked="{Binding Search.FilesOnly}"/>
<CheckBox Content="Folders" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
IsChecked="{Binding Search.FoldersOnly}"/>
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="2" Text="Size" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<StackPanel Grid.Row="1" Grid.Column="3" Orientation="Horizontal">
<TextBox Width="90" Margin="0,0,8,0" Text="{Binding Search.MinSizeText, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Minimum size, e.g. 10MB"/>
<TextBox Width="90" Text="{Binding Search.MaxSizeText, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Maximum size, e.g. 1GB"/>
</StackPanel>
</Grid>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Search" Command="{Binding SearchCommand}"/>
<TextBlock Text="{Binding Search.Status}" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
</DockPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
Visibility="{Binding Search.IsBusy, Converter={StaticResource BoolVis}}"/>
<ListView ItemsSource="{Binding Search.Results}" MouseDoubleClick="OnSearchDoubleClick"
VirtualizingPanel.IsVirtualizing="True"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Border>
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Analysis.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="24" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Analysis.CloseCommand}"/>
<TextBlock Text="Storage" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left" Text="Location" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox DockPanel.Dock="Left" ItemsSource="{Binding Analysis.Scopes}" SelectedItem="{Binding Analysis.SelectedScope}"
DisplayMemberPath="Label" Width="220" Margin="0,0,16,0"/>
<TextBlock DockPanel.Dock="Left" Text="View" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox DockPanel.Dock="Left" ItemsSource="{Binding Analysis.Pages}" SelectedItem="{Binding Analysis.Page}" Width="150" Margin="0,0,16,0"/>
<TextBlock Text="{Binding Analysis.Status}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
</DockPanel>
<WrapPanel DockPanel.Dock="Top" Margin="0,0,0,10">
<Button Content="Open" Command="{Binding OpenStorageHereCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
<Button Content="Open in other pane" Command="{Binding OpenStorageOtherCommand}" Margin="0,0,6,0"
IsEnabled="{Binding Analysis.CanAct}"
Visibility="{Binding ActiveTab.IsSplit, Converter={StaticResource BoolVis}}"/>
<Button Content="Open in new tab" Command="{Binding OpenStorageTabCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
<Button Content="Search here" Command="{Binding SearchStorageCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
<Button Content="Rescan" Command="{Binding RescanStorageCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
<Button Content="Copy path" Command="{Binding CopyStoragePathCommand}" IsEnabled="{Binding Analysis.CanAct}"/>
</WrapPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" Height="4" IsIndeterminate="True"
IsHitTestVisible="False"
Visibility="{Binding Analysis.IsBusy, Converter={StaticResource BoolVis}}"/>
<Grid>
<Grid Visibility="{Binding Analysis.IsTree, Converter={StaticResource BoolVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Margin="6,0,22,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="88"/>
<ColumnDefinition Width="72"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="1" Text="Contents" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="2" Text="Relative size" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
<TextBlock Grid.Column="3" Text="Size" HorizontalAlignment="Right" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="4" Text="State" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}"/>
</Grid>
<ListView Grid.Row="1" ItemsSource="{Binding Analysis.VisibleNodes}"
SelectedItem="{Binding Analysis.SelectedNode}"
MouseDoubleClick="OnStorageTreeDoubleClick"
ItemContainerStyle="{StaticResource PlainListItem}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="88"/>
<ColumnDefinition Width="72"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Margin="{Binding Indent, Converter={StaticResource Indent}}">
<Grid Width="18" Height="18" Margin="0,0,6,0">
<Button Width="18" Height="18" Padding="0"
Command="{Binding DataContext.Analysis.ToggleNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanExpand, Converter={StaticResource BoolVis}}">
<TextBlock FontSize="11" Foreground="{DynamicResource FgMuted}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="▸"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsExpanded}" Value="True">
<Setter Property="Text" Value="▾"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Button>
</Grid>
<TextBlock FontFamily="{StaticResource Symbol}" Text="{Binding Glyph}" FontSize="14"
Foreground="{DynamicResource Accent}" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Fg}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" ToolTip="{Binding FullPath}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding CountLabel}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
<ProgressBar Grid.Column="2" Style="{StaticResource StorageSizeBar}" Value="{Binding Fraction}"
ToolTip="{Binding SizeLabel}"/>
<TextBlock Grid.Column="3" Text="{Binding SizeLabel}" HorizontalAlignment="Right" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding State}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
Visibility="{Binding HasState, Converter={StaticResource BoolVis}}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Command="{Binding DataContext.OpenStorageHereCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Open in other pane" Command="{Binding DataContext.OpenStorageOtherCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ActiveTab.IsSplit, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in new tab" Command="{Binding DataContext.OpenStorageTabCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Search within this location" Command="{Binding DataContext.SearchStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rescan this location" Command="{Binding DataContext.RescanStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyStoragePathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</ContextMenu>
</ListView.ContextMenu>
</ListView>
</Grid>
<Grid Visibility="{Binding Analysis.IsRanking, Converter={StaticResource BoolVis}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Margin="6,0,22,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="88"/>
<ColumnDefinition Width="72"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="1" Text="Location" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="Contents" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="3" Text="Relative size" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
<TextBlock Grid.Column="4" Text="Size" HorizontalAlignment="Right" Foreground="{DynamicResource FgMuted}"/>
<TextBlock Grid.Column="5" Text="State" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}"/>
</Grid>
<ListView Grid.Row="1" ItemsSource="{Binding Analysis.Rows}"
SelectedItem="{Binding Analysis.SelectedRow}"
MouseDoubleClick="OnStorageRankDoubleClick"
ItemContainerStyle="{StaticResource PlainListItem}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="88"/>
<ColumnDefinition Width="72"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Fg}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" ToolTip="{Binding Path}"/>
<TextBlock Grid.Column="1" Text="{Binding ShortPath}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Margin="8,0" ToolTip="{Binding Path}"/>
<TextBlock Grid.Column="2" Text="{Binding CountLabel}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
<ProgressBar Grid.Column="3" Style="{StaticResource StorageSizeBar}" Value="{Binding Fraction}"
ToolTip="{Binding SizeLabel}"/>
<TextBlock Grid.Column="4" Text="{Binding SizeLabel}" HorizontalAlignment="Right" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Text="{Binding State}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
Visibility="{Binding HasState, Converter={StaticResource BoolVis}}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Command="{Binding DataContext.OpenStorageHereCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Open in other pane" Command="{Binding DataContext.OpenStorageOtherCommand, RelativeSource={RelativeSource AncestorType=Window}}"
Visibility="{Binding DataContext.ActiveTab.IsSplit, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in new tab" Command="{Binding DataContext.OpenStorageTabCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Search within this location" Command="{Binding DataContext.SearchStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rescan this location" Command="{Binding DataContext.RescanStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyStoragePathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
</ContextMenu>
</ListView.ContextMenu>
</ListView>
</Grid>
</Grid>
</DockPanel>
</Border>
</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">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseDuplicates"/>
<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"/>
<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>
</DockPanel>
</Border>
</Border>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,525 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using Explorer.Domain;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class MainWindow : Window
{
private Point _dragStart;
private bool _dragPending;
public MainWindow()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (DataContext is MainViewModel vm)
{
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MainViewModel.Theme))
{
ApplyTheme(vm.Theme);
}
};
}
};
}
private MainViewModel Vm => (MainViewModel)DataContext;
private void ApplyTheme(string theme)
{
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
dicts.Clear();
var uri = theme == "Light"
? new Uri("Themes/Light.xaml", UriKind.Relative)
: new Uri("Themes/Dark.xaml", UriKind.Relative);
dicts.Add(new ResourceDictionary { Source = uri });
}
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Left)
{
return;
}
if (e.ClickCount == 2)
{
ToggleMaximized();
return;
}
DragMove();
}
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
private void OnCloseWindow(object sender, RoutedEventArgs e) => Close();
private void ToggleMaximized()
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
}
private void OnPaneChromeMouseDown(object sender, MouseButtonEventArgs e)
{
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
{
Vm.ActiveTab.Activate(pane);
}
}
private void OnPathKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
Vm.GoCommand.Execute(null);
e.Handled = true;
}
}
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
{
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
{
return;
}
if (NavigationTreeViewModel.PathsEqual(path, Vm.ActivePane.CurrentPath))
{
return;
}
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
{
Vm.PathText = path;
Vm.GoCommand.Execute(null);
}
}
private void OnSearchKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
Vm.SearchCommand.Execute(null);
}
}
private async void OnTreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (Vm.Tree.IsRevealing)
{
return;
}
if (e.NewValue is NavNodeViewModel node)
{
await Vm.TreeSelectAsync(node).ConfigureAwait(true);
}
}
private async void OnTreeExpanded(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is TreeViewItem { DataContext: NavNodeViewModel node })
{
await Vm.Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
}
}
private void OnTabChanged(object sender, SelectionChangedEventArgs e)
{
if (Tabs.SelectedItem is ExplorerTabViewModel tab)
{
Vm.ActiveTab = tab;
}
}
private void OnPaneSplitLoaded(object sender, RoutedEventArgs e)
{
if (sender is not Grid grid)
{
return;
}
ApplySplitFromGrid(grid);
if (Equals(grid.Tag, "split-wired"))
{
return;
}
grid.Tag = "split-wired";
ExplorerTabViewModel? current = null;
PropertyChangedEventHandler? handler = null;
void Wire(ExplorerTabViewModel? tab)
{
if (current is not null && handler is not null)
{
current.PropertyChanged -= handler;
}
current = tab;
if (tab is null)
{
return;
}
handler = (_, args) =>
{
if (args.PropertyName is nameof(ExplorerTabViewModel.IsSplit)
or nameof(ExplorerTabViewModel.SplitRatio))
{
ApplySplitLayout(grid, tab);
}
};
tab.PropertyChanged += handler;
ApplySplitLayout(grid, tab);
}
Wire(grid.DataContext as ExplorerTabViewModel);
grid.DataContextChanged += (_, _) => Wire(grid.DataContext as ExplorerTabViewModel);
grid.Unloaded += (_, _) => Wire(null);
}
private void OnSplitDragCompleted(object sender, DragCompletedEventArgs e)
{
if (sender is not GridSplitter splitter || splitter.Parent is not Grid grid)
{
return;
}
if (grid.DataContext is not ExplorerTabViewModel tab || !tab.IsSplit)
{
return;
}
var leftWidth = grid.ColumnDefinitions[0].ActualWidth;
var rightWidth = grid.ColumnDefinitions[2].ActualWidth;
var total = leftWidth + rightWidth;
if (total < 8)
{
return;
}
tab.SetSplitRatio(leftWidth / total);
ApplySplitLayout(grid, tab);
}
private static void ApplySplitFromGrid(Grid grid)
{
if (grid.DataContext is ExplorerTabViewModel tab)
{
ApplySplitLayout(grid, tab);
}
}
private static void ApplySplitLayout(Grid grid, ExplorerTabViewModel tab)
{
var left = grid.ColumnDefinitions[0];
var mid = grid.ColumnDefinitions[1];
var right = grid.ColumnDefinitions[2];
if (tab.IsSplit)
{
var ratio = Math.Clamp(tab.SplitRatio, ExplorerTabViewModel.MinSplitRatio, ExplorerTabViewModel.MaxSplitRatio);
left.Width = new GridLength(ratio, GridUnitType.Star);
left.MinWidth = 140;
mid.Width = new GridLength(6);
mid.MinWidth = 6;
right.Width = new GridLength(1.0 - ratio, GridUnitType.Star);
right.MinWidth = 140;
}
else
{
left.Width = new GridLength(1, GridUnitType.Star);
left.MinWidth = 0;
mid.Width = new GridLength(0);
mid.MinWidth = 0;
right.Width = new GridLength(0);
right.MinWidth = 0;
}
foreach (var splitter in grid.Children.OfType<GridSplitter>())
{
splitter.IsHitTestVisible = tab.IsSplit;
}
}
private void OnCloseTab(object sender, RoutedEventArgs e)
{
if (sender is Button { Tag: ExplorerTabViewModel tab })
{
Vm.CloseTab(tab);
}
}
private async void OnItemDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is ListView { SelectedItem: FolderItemViewModel item })
{
ActivatePaneFromList((ListView)sender);
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
Vm.PathText = Vm.ActivePane.CurrentPath;
}
}
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView list)
{
return;
}
ActivatePaneFromList(list);
Vm.ActivePane.SelectedItems.Clear();
foreach (FolderItemViewModel item in list.SelectedItems)
{
Vm.ActivePane.SelectedItems.Add(item);
}
Vm.RefreshCloudActions();
}
private void OnPaneFocus(object sender, RoutedEventArgs e)
{
if (sender is ListView list)
{
ActivatePaneFromList(list);
}
}
private void ActivatePaneFromList(ListView list)
{
var tab = Vm.ActiveTab;
if (list.ItemsSource == tab.Right.Items)
{
tab.Activate(tab.Right);
}
else
{
tab.Activate(tab.Left);
}
}
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
{
_dragStart = e.GetPosition(null);
_dragPending = true;
}
private void OnListDragOver(object sender, DragEventArgs e)
{
e.Effects = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 ? DragDropEffects.Move : DragDropEffects.Copy;
e.Handled = true;
}
private async void OnListDrop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop) || sender is not ListView list)
{
return;
}
ActivatePaneFromList(list);
var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
var move = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 || e.AllowedEffects == DragDropEffects.Move;
await Vm.DropAsync(files, Vm.ActivePane.CurrentPath, move).ConfigureAwait(true);
await Vm.RefreshAsync().ConfigureAwait(true);
}
protected override void OnPreviewMouseMove(MouseEventArgs e)
{
base.OnPreviewMouseMove(e);
if (!_dragPending || e.LeftButton != MouseButtonState.Pressed)
{
return;
}
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
{
return;
}
_dragPending = false;
var paths = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToArray();
if (paths.Length == 0)
{
return;
}
var data = new DataObject(DataFormats.FileDrop, paths);
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move);
}
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is ListView { SelectedItem: FolderItemViewModel item })
{
Vm.Search.IsOpen = false;
if (item.IsDirectory)
{
await Vm.ActivePane.NavigateAsync(item.FullPath).ConfigureAwait(true);
}
else
{
await Vm.ActivePane.NavigateAsync(PathRules.Parent(item.FullPath)).ConfigureAwait(true);
}
Vm.PathText = Vm.ActivePane.CurrentPath;
}
}
private async void OnStorageTreeDoubleClick(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
{
return;
}
if (Vm.Analysis.CanAct)
{
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
}
}
private static bool IsInsideButton(DependencyObject origin)
{
for (var current = origin; current is not null;)
{
if (current is Button)
{
return true;
}
current = current is System.Windows.Media.Visual
? System.Windows.Media.VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return false;
}
private async void OnStorageRankDoubleClick(object sender, MouseButtonEventArgs e)
{
if (Vm.Analysis.CanAct)
{
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
}
}
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);
private void OnCtxOpen(object sender, RoutedEventArgs e) => Vm.OpenSelectedCommand.Execute(null);
private void OnCtxRename(object sender, RoutedEventArgs e)
{
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
var name = PromptWindow.Ask(this, "Rename", "New name:", item.Name);
if (!string.IsNullOrWhiteSpace(name) && name != item.Name)
{
Vm.RenameSelected(name);
}
}
private void OnAddNetwork(object sender, RoutedEventArgs e)
{
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
if (!string.IsNullOrWhiteSpace(path) && path != @"\\")
{
Vm.PromptUnc = path;
Vm.AddNetworkCommand.Execute(null);
}
}
private async void OnAddOneDrive(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Add OneDrive folder",
Multiselect = false
};
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
{
return;
}
await Vm.AddCloudFolderAsync(picker.FolderName).ConfigureAwait(true);
}
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
if (e.Key == Key.F5)
{
await Vm.RefreshAsync().ConfigureAwait(true);
e.Handled = true;
}
else if (e.Key == Key.F2)
{
OnCtxRename(sender, e);
e.Handled = true;
}
else if (ctrl && e.Key == Key.C)
{
Vm.Copy();
}
else if (ctrl && e.Key == Key.X)
{
Vm.Cut();
}
else if (ctrl && e.Key == Key.V)
{
await Vm.PasteAsync().ConfigureAwait(true);
}
else if (e.Key == Key.Delete)
{
await Vm.DeleteAsync().ConfigureAwait(true);
}
else if (ctrl && e.Key == Key.T)
{
await Vm.NewTabAsync().ConfigureAwait(true);
}
else if (ctrl && e.Key == Key.W)
{
Vm.CloseTab(Vm.ActiveTab);
}
else if (alt && e.Key == Key.Left)
{
await Vm.BackAsync().ConfigureAwait(true);
}
else if (alt && e.Key == Key.Right)
{
await Vm.ForwardAsync().ConfigureAwait(true);
}
else if (alt && e.Key == Key.Up)
{
await Vm.UpAsync().ConfigureAwait(true);
}
else if (e.Key == Key.Enter)
{
await Vm.OpenSelectedAsync().ConfigureAwait(true);
}
}
}

View File

@@ -0,0 +1,21 @@
<Window x:Class="Explorer.App.PromptWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Explorer Workbench"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="220" Width="520"
MinHeight="200" MinWidth="420"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
<DockPanel Margin="20">
<TextBlock x:Name="Message" DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,16"
FontSize="13" Foreground="{DynamicResource Fg}"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Content="OK" MinWidth="88" Height="32" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
<Button Content="Cancel" MinWidth="88" Height="32" IsCancel="True"/>
</StackPanel>
<TextBox x:Name="Input" MinHeight="36" FontSize="14" VerticalContentAlignment="Center"
Padding="10,8"/>
</DockPanel>
</Window>

View File

@@ -0,0 +1,30 @@
using System.Windows;
namespace Explorer.App;
public partial class PromptWindow : Window
{
public PromptWindow(string title, string message, string initial)
{
InitializeComponent();
Title = title;
Message.Text = message;
Input.Text = initial;
Input.SelectAll();
Loaded += (_, _) => Input.Focus();
}
public string Value => Input.Text;
private void OnOk(object sender, RoutedEventArgs e)
{
DialogResult = true;
Close();
}
public static string? Ask(Window owner, string title, string message, string initial)
{
var dlg = new PromptWindow(title, message, initial) { Owner = owner };
return dlg.ShowDialog() == true ? dlg.Value : null;
}
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Specialized;
using System.IO;
using System.Windows;
using Explorer.Domain.Abstractions;
namespace Explorer.App.Services;
public sealed class WpfClipboard : IOsClipboard
{
public void SetFiles(IReadOnlyList<string> paths, bool cut)
{
var list = new StringCollection();
foreach (var p in paths)
{
list.Add(p);
}
var data = new DataObject();
data.SetFileDropList(list);
data.SetData("Preferred DropEffect", new MemoryStream([(byte)(cut ? DragDropEffects.Move : DragDropEffects.Copy), 0, 0, 0]));
Clipboard.SetDataObject(data, copy: true);
}
public bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut)
{
paths = [];
cut = false;
if (!Clipboard.ContainsFileDropList())
{
return false;
}
var list = Clipboard.GetFileDropList().Cast<string>().Where(s => s is not null).Cast<string>().ToList();
paths = list;
try
{
if (Clipboard.GetData("Preferred DropEffect") is MemoryStream ms)
{
var b = ms.ToArray();
cut = b.Length > 0 && b[0] == (byte)DragDropEffects.Move;
}
}
catch
{
cut = false;
}
return list.Count > 0;
}
}

View File

@@ -0,0 +1,159 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Data;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Explorer.Domain;
using Explorer.Presentation;
namespace Explorer.App;
public sealed class ShellIconConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var large = parameter is string p && p.Equals("large", StringComparison.OrdinalIgnoreCase);
return value switch
{
FolderItemViewModel item => ShellIconCache.Get(item.FullPath, item.IsDirectory, large, item.MayHydrateOnRead),
FileSystemItem fs => ShellIconCache.Get(fs.FullPath, fs.IsDirectory, large, AttributeFlags.MayHydrateOnRead(fs.Attributes)),
string path => ShellIconCache.Get(path, Directory.Exists(path), large),
_ => ShellIconCache.Get(null, true, large)
};
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
internal static class ShellIconCache
{
private static readonly ConcurrentDictionary<string, ImageSource> Cache = new(StringComparer.OrdinalIgnoreCase);
public static ImageSource Get(string? path, bool isDirectory, bool large = false, bool avoidHydration = false)
{
var key = (large ? "L:" : "S:") + (avoidHydration ? "A:" : "") + CacheKey(path, isDirectory);
return Cache.GetOrAdd(key, _ => Load(path, isDirectory, large, avoidHydration));
}
private static string CacheKey(string? path, bool isDirectory)
{
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
return isDirectory ? "dir:generic" : "file:generic";
}
if (isDirectory)
{
return PathRules.IsDriveRoot(path) || (path.Length <= 3 && path.Contains(':', StringComparison.Ordinal))
? "drive:" + path.TrimEnd('\\').ToUpperInvariant()
: "dir:generic";
}
var ext = Path.GetExtension(path);
if (ext.Equals(".exe", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".ico", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".dll", StringComparison.OrdinalIgnoreCase))
{
return "path:" + path;
}
return "ext:" + (string.IsNullOrEmpty(ext) ? ".file" : ext.ToLowerInvariant());
}
private static ImageSource Load(string? path, bool isDirectory, bool large, bool avoidHydration = false)
{
try
{
var info = new ShFileInfo();
uint flags = ShgfiIcon | (large ? ShgfiLargeIcon : ShgfiSmallIcon);
uint attrs = isDirectory ? FileAttributeDirectory : FileAttributeNormal;
string psz;
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
psz = isDirectory ? "folder" : "file";
flags |= ShgfiUseFileAttributes;
}
else if (isDirectory && PathRules.IsDriveRoot(path))
{
psz = PathRules.EnsureDirectoryTrailingSlashIfRoot(path);
}
else if (isDirectory)
{
psz = path;
flags |= ShgfiUseFileAttributes;
}
else
{
psz = path;
var ext = Path.GetExtension(path);
if (avoidHydration
|| !ext.Equals(".exe", StringComparison.OrdinalIgnoreCase)
&& !ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase)
&& !ext.Equals(".ico", StringComparison.OrdinalIgnoreCase)
&& File.Exists(path) == false)
{
flags |= ShgfiUseFileAttributes;
}
}
SHGetFileInfo(psz, attrs, ref info, (uint)Marshal.SizeOf<ShFileInfo>(), flags);
if (info.hIcon == 0)
{
return Fallback();
}
try
{
var source = Imaging.CreateBitmapSourceFromHIcon(info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
source.Freeze();
return source;
}
finally
{
DestroyIcon(info.hIcon);
}
}
catch
{
return Fallback();
}
}
private static ImageSource Fallback()
{
var bmp = BitmapSource.Create(16, 16, 96, 96, PixelFormats.Bgra32, null, new byte[16 * 16 * 4], 16 * 4);
bmp.Freeze();
return bmp;
}
private const uint ShgfiIcon = 0x100;
private const uint ShgfiLargeIcon = 0x0;
private const uint ShgfiSmallIcon = 0x1;
private const uint ShgfiUseFileAttributes = 0x10;
private const uint FileAttributeDirectory = 0x10;
private const uint FileAttributeNormal = 0x80;
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern nint SHGetFileInfo(string pszPath, uint dwFileAttributes, ref ShFileInfo psfi, uint cbFileInfo, uint uFlags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(nint hIcon);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct ShFileInfo
{
public nint hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
public string szTypeName;
}
}

View File

@@ -0,0 +1,22 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="Bg" Color="#1C1C1C"/>
<SolidColorBrush x:Key="Panel" Color="#252526"/>
<SolidColorBrush x:Key="Fill" Color="#2D2D30"/>
<SolidColorBrush x:Key="FillHover" Color="#3E3E42"/>
<SolidColorBrush x:Key="InputBg" Color="#1E1E1E"/>
<SolidColorBrush x:Key="Fg" Color="#F3F3F3"/>
<SolidColorBrush x:Key="FgMuted" Color="#C8C8C8"/>
<SolidColorBrush x:Key="Stroke" Color="#3F3F46"/>
<SolidColorBrush x:Key="Accent" Color="#60CDFF"/>
<SolidColorBrush x:Key="Danger" Color="#E81123"/>
<SolidColorBrush x:Key="Banner" Color="#264F78"/>
<SolidColorBrush x:Key="ActivePane" Color="#60CDFF"/>
<SolidColorBrush x:Key="ActivePaneFill" Color="#1E2A32"/>
<SolidColorBrush x:Key="ListSelection" Color="#264F78"/>
<SolidColorBrush x:Key="TreeSelection" Color="#2B4B63"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#252526"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#5A5A5E"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#7A7A7E"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#9A9A9E"/>
</ResourceDictionary>

View File

@@ -0,0 +1,22 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="Bg" Color="#F3F3F3"/>
<SolidColorBrush x:Key="Panel" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Fill" Color="#E8E8E8"/>
<SolidColorBrush x:Key="FillHover" Color="#DADADA"/>
<SolidColorBrush x:Key="InputBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Fg" Color="#1A1A1A"/>
<SolidColorBrush x:Key="FgMuted" Color="#5A5A5A"/>
<SolidColorBrush x:Key="Stroke" Color="#D0D0D0"/>
<SolidColorBrush x:Key="Accent" Color="#0078D4"/>
<SolidColorBrush x:Key="Danger" Color="#C42B1C"/>
<SolidColorBrush x:Key="Banner" Color="#D6E8F7"/>
<SolidColorBrush x:Key="ActivePane" Color="#0078D4"/>
<SolidColorBrush x:Key="ActivePaneFill" Color="#E8F4FC"/>
<SolidColorBrush x:Key="ListSelection" Color="#CDE6F7"/>
<SolidColorBrush x:Key="TreeSelection" Color="#D6E8F7"/>
<SolidColorBrush x:Key="ScrollTrack" Color="#EDEDED"/>
<SolidColorBrush x:Key="ScrollThumb" Color="#B0B0B0"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#8A8A8A"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#6A6A6A"/>
</ResourceDictionary>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Explorer.App"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,10 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed class AppOptions
{
public int TombstoneRetentionDays { get; set; } = AppConstants.DefaultTombstoneRetentionDays;
public bool SkipHidden { get; set; } = true;
public bool SkipSystem { get; set; } = true;
}

View File

@@ -0,0 +1,151 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class BrowseService
{
private readonly IFileSystemEnumerator _enumerator;
private readonly IVolumeService _volumes;
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers;
public BrowseService(
IFileSystemEnumerator enumerator,
IVolumeService volumes,
IIndexStore store,
SourceManager sources,
StorageProviderRegistry providers)
{
_enumerator = enumerator;
_volumes = volumes;
_store = store;
_sources = sources;
_providers = providers;
}
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
{
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
var items = new List<FileSystemItem>();
foreach (var source in sources.OrderBy(s => s.Kind).ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
IndexEntry? root = null;
if (source.IsIndexed)
{
root = await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
items.Add(new FileSystemItem
{
FullPath = source.LastRootPath ?? source.DisplayName,
Name = source.Status == SourceStatus.Offline
? $"{source.DisplayName} (Offline)"
: source.DisplayName,
IsDirectory = true,
SizeBytes = root?.AggregateSize ?? 0,
Attributes = AttributeFlags.Directory
});
}
return new FolderListing { Path = "This PC", Items = items };
}
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
var reachable = _volumes.IsPathReachable(path);
if (reachable)
{
var items = _enumerator.EnumerateChildrenSafe(path, out var error);
var listing = (await _providers.EnrichAsync(items.ToList(), cancellationToken).ConfigureAwait(false)).ToList();
if (source is { IsIndexed: true })
{
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
}
return new FolderListing { Path = path, IsOffline = false, Items = listing, Error = error };
}
if (source is { IsIndexed: true })
{
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
if (dir is null)
{
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
}
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
.ConfigureAwait(false);
var items = children
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
.Select(c => new FileSystemItem
{
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, c.PathRel),
Name = c.Name,
IsDirectory = c.IsDirectory,
SizeBytes = c.IsDirectory ? c.AggregateSize : c.SizeBytes,
CreatedUtc = c.CreatedUtc,
ModifiedUtc = c.ModifiedUtc,
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes,
Cloud = c.CloudAvailability is { } availability
? new CloudPresence(null, availability, c.SizeBytes, c.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null
})
.ToList();
return new FolderListing { Path = path, IsOffline = true, Items = items };
}
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
Source source,
string path,
List<FileSystemItem> listing,
CancellationToken cancellationToken)
{
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? (string.IsNullOrEmpty(rel)
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
: null);
if (indexed is null)
{
return listing;
}
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var byName = children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
return listing.Select(i =>
{
if (!i.IsDirectory || !byName.TryGetValue(NameNormalizer.Normalize(i.Name), out var e))
{
return i;
}
return new FileSystemItem
{
FullPath = i.FullPath,
Name = i.Name,
IsDirectory = true,
SizeBytes = e.AggregateSize,
CreatedUtc = i.CreatedUtc,
ModifiedUtc = i.ModifiedUtc,
Attributes = i.Attributes,
FileId = i.FileId,
ReparseTag = i.ReparseTag,
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
Cloud = i.Cloud
};
}).ToList();
}
}

View File

@@ -0,0 +1,115 @@
using Explorer.Domain.Abstractions;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public sealed class CloudPlaceStore
{
public const string FileName = "cloud-places.txt";
private readonly IAppEnvironment _env;
public CloudPlaceStore(IAppEnvironment env) => _env = env;
public IReadOnlyList<ProviderPlace> Load()
{
var file = Path.Combine(_env.DataDirectory, FileName);
try
{
if (!File.Exists(file))
{
return [];
}
return Parse(File.ReadAllLines(file));
}
catch
{
return [];
}
}
public IReadOnlyList<ProviderPlace> Add(string providerId, string path, string? displayName = null)
{
var places = Load().ToList();
var trimmed = path.Trim().TrimEnd('\\');
if (string.IsNullOrWhiteSpace(trimmed))
{
return places;
}
places.RemoveAll(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
var label = string.IsNullOrWhiteSpace(displayName) ? Path.GetFileName(trimmed) : displayName.Trim();
if (string.IsNullOrWhiteSpace(label))
{
label = "OneDrive";
}
places.Add(new ProviderPlace(providerId, label, trimmed));
Save(places);
return places;
}
public void Save(IEnumerable<ProviderPlace> places)
{
try
{
Directory.CreateDirectory(_env.DataDirectory);
File.WriteAllLines(
Path.Combine(_env.DataDirectory, FileName),
places.Select(p => $"{p.ProviderId}|{Escape(p.DisplayName)}|{p.Path}"));
}
catch
{
// places are convenience-only
}
}
public static IReadOnlyList<ProviderPlace> Merge(IEnumerable<ProviderPlace> discovered, IEnumerable<ProviderPlace> manual)
{
var list = new List<ProviderPlace>();
foreach (var place in discovered.Concat(manual))
{
if (string.IsNullOrWhiteSpace(place.Path))
{
continue;
}
var path = place.Path.TrimEnd('\\');
if (list.Exists(p => p.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
list.Add(place with { Path = path });
}
return list;
}
internal static IReadOnlyList<ProviderPlace> Parse(IEnumerable<string> lines)
{
var list = new List<ProviderPlace>();
foreach (var raw in lines)
{
var line = raw.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
var parts = line.Split('|', 3);
if (parts.Length < 3 || string.IsNullOrWhiteSpace(parts[2]))
{
continue;
}
list.Add(new ProviderPlace(parts[0], Unescape(parts[1]), parts[2].TrimEnd('\\')));
}
return list;
}
private static string Escape(string value) => value.Replace('|', '/');
private static string Unescape(string value) => value;
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,49 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public interface IHydrationGuard
{
bool WouldHydrateOnRead(FileSystemItem item);
bool WouldHydrateOnRead(int attributes, Domain.CloudAvailability? availability);
Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default);
}
public sealed class HydrationGuard : IHydrationGuard
{
private readonly StorageProviderRegistry _registry;
public HydrationGuard(StorageProviderRegistry registry) => _registry = registry;
public bool WouldHydrateOnRead(FileSystemItem item)
{
if (item.Cloud is { MayHydrateOnRead: true } or { Availability: Domain.CloudAvailability.OnlineOnly })
{
return true;
}
return WouldHydrateOnRead(item.Attributes, item.Cloud?.Availability);
}
public bool WouldHydrateOnRead(int attributes, Domain.CloudAvailability? availability)
{
if (availability is Domain.CloudAvailability.OnlineOnly or Domain.CloudAvailability.Syncing)
{
return true;
}
return AttributeFlags.MayHydrateOnRead(attributes);
}
public async Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
{
var state = await _registry.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return false;
}
return state.MayHydrateOnRead || state.Availability == Plugin.Abstractions.CloudAvailability.OnlineOnly;
}
}

View File

@@ -0,0 +1,77 @@
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class PathHistoryStore
{
public const int MaxEntries = 25;
public const string FileName = "path-history.txt";
private readonly IAppEnvironment _env;
public PathHistoryStore(IAppEnvironment env) => _env = env;
public IReadOnlyList<string> Load()
{
var file = Path.Combine(_env.DataDirectory, FileName);
try
{
if (!File.Exists(file))
{
return [];
}
return RememberMany(File.ReadAllLines(file));
}
catch
{
return [];
}
}
public void Save(IEnumerable<string> paths)
{
try
{
Directory.CreateDirectory(_env.DataDirectory);
File.WriteAllLines(Path.Combine(_env.DataDirectory, FileName), RememberMany(paths));
}
catch
{
// history is convenience-only
}
}
public static IReadOnlyList<string> Remember(IEnumerable<string> existing, string path)
{
var list = existing.ToList();
return RememberMany(list.Prepend(path));
}
public static IReadOnlyList<string> RememberMany(IEnumerable<string> paths)
{
var list = new List<string>();
foreach (var raw in paths)
{
var path = raw.Trim();
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
continue;
}
if (list.Exists(p => p.Equals(path, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
list.Add(path);
}
if (list.Count > MaxEntries)
{
list.RemoveRange(MaxEntries, list.Count - MaxEntries);
}
return list;
}
}

View File

@@ -0,0 +1,295 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class SourceManager
{
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly IAppEnvironment _env;
private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger;
public SourceManager(
IIndexStore store,
IVolumeService volumes,
IAppEnvironment env,
IClock clock,
ILogger<SourceManager> logger)
{
_store = store;
_volumes = volumes;
_env = env;
_clock = clock;
_logger = logger;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(CancellationToken cancellationToken = default)
{
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes();
var seenIds = new HashSet<long>();
foreach (var fp in online)
{
var match = VolumeIdentityMatcher.Match(fp, known);
Source source;
if (match.Source is not null && !match.Ambiguous)
{
source = match.Source;
source.LastRootPath = fp.RootPath;
source.DisplayName = fp.DisplayName ?? source.DisplayName;
source.Label = fp.Label ?? source.Label;
source.Filesystem = fp.Filesystem ?? source.Filesystem;
source.CapacityBytes = fp.CapacityBytes ?? source.CapacityBytes;
source.VolumeGuid = fp.VolumeGuid ?? source.VolumeGuid;
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
source.Kind = fp.Kind;
source.LastSeenUtc = _clock.UtcNow;
source.Status = source.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online;
source.LastError = null;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
if (source.IsIndexed)
{
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
else
{
source = new Source
{
StableKey = Guid.NewGuid().ToString("N"),
Kind = fp.Kind,
DisplayName = fp.DisplayName ?? fp.RootPath,
VolumeGuid = fp.VolumeGuid,
VolumeSerial = fp.VolumeSerial,
Filesystem = fp.Filesystem,
Label = fp.Label,
CapacityBytes = fp.CapacityBytes,
DeviceInstanceId = fp.DeviceInstanceId,
LastRootPath = fp.RootPath,
Status = SourceStatus.Online,
LastSeenUtc = _clock.UtcNow
};
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
known.Add(source);
}
seenIds.Add(source.Id);
}
foreach (var source in known)
{
if (seenIds.Contains(source.Id))
{
continue;
}
var reachable = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
if (reachable)
{
continue;
}
if (source.Status != SourceStatus.Offline)
{
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken)
.ConfigureAwait(false);
await _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
foreach (var unc in LoadRecents())
{
if (known.Any(s => s.LastRootPath is not null
&& PathRules.CanonicalUncRoot(s.LastRootPath)
.Equals(PathRules.CanonicalUncRoot(unc), StringComparison.OrdinalIgnoreCase)))
{
continue;
}
await AddUncAsync(unc, cancellationToken).ConfigureAwait(false);
}
return await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
{
var root = PathRules.CanonicalUncRoot(path);
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var fp = new VolumeFingerprint
{
Kind = SourceKind.Smb,
RootPath = root,
DisplayName = root,
Filesystem = "SMB"
};
var match = VolumeIdentityMatcher.Match(fp, known);
if (match.Source is not null)
{
var existing = match.Source;
existing.LastRootPath = root;
existing.LastSeenUtc = _clock.UtcNow;
existing.Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
RememberUnc(root);
return existing;
}
var source = new Source
{
StableKey = Guid.NewGuid().ToString("N"),
Kind = SourceKind.Smb,
DisplayName = root,
Filesystem = "SMB",
LastRootPath = root,
Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline,
LastSeenUtc = _clock.UtcNow
};
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
RememberUnc(root);
return source;
}
public async Task<Source?> FindByPathAsync(string path, CancellationToken cancellationToken = default)
{
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var normalized = PathRules.FromExtended(path);
Source? best = null;
var bestLen = -1;
foreach (var source in sources)
{
if (source.LastRootPath is null)
{
continue;
}
var root = PathRules.FromExtended(source.LastRootPath).TrimEnd('\\');
var candidate = normalized.TrimEnd('\\');
if (candidate.Equals(root, StringComparison.OrdinalIgnoreCase)
|| candidate.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase)
|| (root.Length == 2 && root[1] == ':' && candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)))
{
if (root.Length > bestLen)
{
best = source;
bestLen = root.Length;
}
}
}
return best;
}
public async Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
return null;
}
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
existing.LastSeenUtc = _clock.UtcNow;
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
? (existing.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online)
: SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
return existing;
}
var fp = _volumes.Probe(path);
if (fp is null)
{
return null;
}
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var match = VolumeIdentityMatcher.Match(fp, known);
if (match.Source is not null && !match.Ambiguous)
{
var source = match.Source;
source.LastRootPath = fp.RootPath;
source.DisplayName = fp.DisplayName ?? source.DisplayName;
source.Kind = fp.Kind;
source.Filesystem = fp.Filesystem ?? source.Filesystem;
source.Label = fp.Label ?? source.Label;
source.LastSeenUtc = _clock.UtcNow;
source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
return source;
}
if (fp.Kind == SourceKind.Smb && PathRules.IsUnc(fp.RootPath))
{
return await AddUncAsync(fp.RootPath, cancellationToken).ConfigureAwait(false);
}
var created = new Source
{
StableKey = Guid.NewGuid().ToString("N"),
Kind = fp.Kind,
DisplayName = fp.DisplayName ?? fp.RootPath,
VolumeGuid = fp.VolumeGuid,
VolumeSerial = fp.VolumeSerial,
Filesystem = fp.Filesystem,
Label = fp.Label,
CapacityBytes = fp.CapacityBytes,
LastRootPath = fp.RootPath,
Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline,
LastSeenUtc = _clock.UtcNow
};
created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false);
return created;
}
private IReadOnlyList<string> LoadRecents()
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");
if (!File.Exists(file))
{
return [];
}
try
{
return File.ReadAllLines(file)
.Where(l => !string.IsNullOrWhiteSpace(l))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed reading recents");
return [];
}
}
private void RememberUnc(string root)
{
try
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");
var lines = LoadRecents().ToList();
lines.RemoveAll(l => l.Equals(root, StringComparison.OrdinalIgnoreCase));
lines.Insert(0, root);
File.WriteAllLines(file, lines.Take(30));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed writing recents");
}
}
}

View File

@@ -0,0 +1,230 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class StorageProviderRegistry
{
private readonly IReadOnlyList<IStorageProvider> _providers;
private readonly HashSet<string> _disabled = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<StorageProviderRegistry> _logger;
public StorageProviderRegistry(IEnumerable<IStorageProvider> providers, ILogger<StorageProviderRegistry> logger)
{
_providers = providers.ToList();
_logger = logger;
}
public IReadOnlyList<IStorageProvider> Providers => _providers;
public void SetEnabled(string providerId, bool enabled)
{
if (enabled)
{
_disabled.Remove(providerId);
}
else
{
_disabled.Add(providerId);
}
}
public IStorageProvider? Find(string path)
{
foreach (var provider in _providers)
{
if (_disabled.Contains(provider.Manifest.Id))
{
continue;
}
try
{
if (provider.TryMatchRoot(path))
{
return provider;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed matching {Path}", provider.Manifest.Id, path);
}
}
return null;
}
public async Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default)
{
if (items.Count == 0 || _providers.Count == 0)
{
return items;
}
var groups = new Dictionary<IStorageProvider, List<int>>();
for (var i = 0; i < items.Count; i++)
{
var provider = Find(items[i].FullPath);
if (provider is null)
{
continue;
}
if (!groups.TryGetValue(provider, out var list))
{
list = [];
groups[provider] = list;
}
list.Add(i);
}
if (groups.Count == 0)
{
return items;
}
var copy = items.ToArray();
foreach (var (provider, indexes) in groups)
{
try
{
var paths = indexes.Select(i => copy[i].FullPath).ToList();
var states = await provider.GetItemStatesAsync(paths, cancellationToken).ConfigureAwait(false);
var byPath = states.ToDictionary(s => s.Path, StringComparer.OrdinalIgnoreCase);
foreach (var index in indexes)
{
if (byPath.TryGetValue(copy[index].FullPath, out var state))
{
copy[index] = CloudPresenceMapper.Apply(copy[index], state);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed enriching items", provider.Manifest.Id);
}
}
return copy;
}
public bool HasCapability(string path, ProviderCapability capability)
{
var provider = Find(path);
return provider is not null && (provider.GetCapabilities() & capability) != 0;
}
public IReadOnlyList<ProviderPlace> GetPlaces()
{
var places = new List<ProviderPlace>();
foreach (var provider in _providers)
{
if (_disabled.Contains(provider.Manifest.Id))
{
continue;
}
try
{
places.AddRange(provider.GetPlaces());
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed listing places", provider.Manifest.Id);
}
}
return places;
}
public async Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
{
if (paths.Count == 0)
{
return new ProviderActionResult(ProviderActionStatus.Failed, "Nothing selected.");
}
var provider = Find(paths[0]);
if (provider is null)
{
return new ProviderActionResult(ProviderActionStatus.Unsupported, "No cloud provider is available for this location.");
}
try
{
return await provider.TryInvokeAsync(new ProviderActionRequest(action, paths), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed invoking {Action}", provider.Manifest.Id, action);
return new ProviderActionResult(ProviderActionStatus.Failed, "The cloud provider could not complete this action.");
}
}
public async Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
{
var provider = Find(path);
if (provider is null)
{
return null;
}
try
{
var states = await provider.GetItemStatesAsync([path], cancellationToken).ConfigureAwait(false);
return states.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed reading state for {Path}", provider.Manifest.Id, path);
return null;
}
}
}
public static class CloudPresenceMapper
{
public static FileSystemItem Apply(FileSystemItem item, ProviderItemState state)
=> new()
{
FullPath = item.FullPath,
Name = item.Name,
IsDirectory = item.IsDirectory,
SizeBytes = item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
AllocatedSizeBytes = state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
Cloud = ToPresence(state)
};
public static CloudPresence ToPresence(ProviderItemState state)
=> new(
state.ProviderId,
(Domain.CloudAvailability)(int)state.Availability,
state.LogicalSizeBytes,
state.AllocatedSizeBytes,
state.MayHydrateOnRead,
state.StatusText);
public static void ApplyToEntry(IndexEntry entry, CloudPresence? cloud)
{
if (cloud is null)
{
return;
}
entry.AllocatedSizeBytes = cloud.AllocatedSizeBytes;
entry.CloudAvailability = cloud.Availability;
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Contracts;
public sealed record SourceDto(
long Id,
string DisplayName,
string? RootPath,
string Kind,
string Status,
DateTimeOffset? LastSeenUtc,
DateTimeOffset? LastIndexedUtc,
long? CapacityBytes,
bool IsIndexed);
public static class SourceMapping
{
public static SourceDto ToDto(Source source) => new(
source.Id,
source.DisplayName,
source.LastRootPath,
source.Kind.ToString(),
source.Status.ToString(),
source.LastSeenUtc,
source.LastIndexedUtc,
source.CapacityBytes,
source.IsIndexed);
}

View File

@@ -0,0 +1,180 @@
namespace Explorer.Domain.Abstractions;
public interface IIndexStore
{
Task OpenAsync(CancellationToken cancellationToken = default);
Task CloseAsync();
Task<string> QuickCheckAsync(CancellationToken cancellationToken = default);
ISourceStore Sources { get; }
IEntryStore Entries { get; }
IExcludeStore Excludes { get; }
IScanJobStore ScanJobs { get; }
ITransferStore Transfers { get; }
ISearchStore Search { get; }
IAnalysisStore Analysis { get; }
IHistoryStore History { get; }
IHashStore Hashes { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
}
public interface ISourceStore
{
Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default);
Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default);
Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default);
Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default);
Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default);
}
public interface IEntryStore
{
Task<IndexEntry?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<IndexEntry?> GetRootAsync(long sourceId, CancellationToken cancellationToken = default);
Task<IndexEntry?> GetByPathAsync(long sourceId, string pathRel, CancellationToken cancellationToken = default);
Task<IndexEntry?> GetByParentNameAsync(long sourceId, long? parentId, string nameNorm, CancellationToken cancellationToken = default);
Task<IndexEntry?> GetByFileIdAsync(long sourceId, long fileId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<IndexEntry>> GetChildrenAsync(long sourceId, long? parentId, EntryStatus? status = EntryStatus.Present, CancellationToken cancellationToken = default);
Task UpsertBatchAsync(IReadOnlyList<IndexEntry> entries, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(IndexEntry entry, CancellationToken cancellationToken = default);
Task UpdateAggregatesAsync(long id, long aggregateSize, int files, int dirs, CancellationToken cancellationToken = default);
Task ApplySizeDeltaToAncestorsAsync(long? parentId, long sizeDelta, int fileDelta, int dirDelta, CancellationToken cancellationToken = default);
Task MarkMissingAsDeletedAsync(long sourceId, long generation, DateTimeOffset utc, string? pathRelPrefix, CancellationToken cancellationToken = default);
Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default);
Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default);
Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default);
Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default);
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);
}
public interface IExcludeStore
{
Task<IReadOnlyList<ExcludeRule>> GetAllAsync(CancellationToken cancellationToken = default);
Task<long> AddAsync(ExcludeRule rule, CancellationToken cancellationToken = default);
Task RemoveAsync(long id, CancellationToken cancellationToken = default);
Task EnsureDefaultsAsync(IReadOnlyList<ExcludeRule> defaults, CancellationToken cancellationToken = default);
}
public interface IScanJobStore
{
Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default);
}
public interface ITransferStore
{
Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default);
}
public interface ISearchStore
{
Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default);
}
public sealed class SearchRequest
{
public string? Name { get; init; }
public string? Extension { get; init; }
public bool? IsDirectory { get; init; }
public long? MinSize { get; init; }
public long? MaxSize { get; init; }
public DateTimeOffset? CreatedAfter { get; init; }
public DateTimeOffset? CreatedBefore { get; init; }
public DateTimeOffset? ModifiedAfter { get; init; }
public DateTimeOffset? ModifiedBefore { get; init; }
public IReadOnlyList<long>? SourceIds { get; init; }
public string? PathRelPrefix { get; init; }
public bool DirectChildrenOnly { get; init; }
public bool IncludeOffline { get; init; } = true;
public bool IncludeDeleted { get; init; }
public int Skip { get; init; }
public int Take { get; init; } = 500;
}
public interface IAnalysisStore
{
Task EnsureReadyAsync(CancellationToken cancellationToken = default);
Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(long? sourceId, long? parentId, int take, CancellationToken cancellationToken = default);
Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<IndexEntry>> ChildrenBySizeAsync(long parentId, int take, CancellationToken cancellationToken = default);
}
public sealed class ExtensionUsage
{
public required string Extension { get; init; }
public long TotalSize { get; init; }
public long FileCount { get; init; }
}
public sealed class SourceUsage
{
public long SourceId { get; init; }
public required string DisplayName { get; init; }
public long TotalSize { get; init; }
public long FileCount { get; init; }
public SourceStatus Status { get; init; }
}
public interface IHistoryStore
{
Task CaptureSourceSnapshotAsync(long sourceId, DateTimeOffset utc, CancellationToken cancellationToken = default);
Task CaptureDirectorySnapshotsAsync(long sourceId, DateTimeOffset utc, long minSize, int topN, CancellationToken cancellationToken = default);
Task<IReadOnlyList<SourceSnapshot>> GetSourceHistoryAsync(long sourceId, CancellationToken cancellationToken = default);
}
public sealed class SourceSnapshot
{
public DateTimeOffset CapturedUtc { get; init; }
public long TotalSize { get; init; }
public long FileCount { get; init; }
public long DirCount { get; init; }
}
public interface IHashStore
{
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default);
Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default);
Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
}
public sealed class HashWorkItem
{
public long EntryId { get; init; }
public long SizeBytes { get; init; }
public required string State { get; init; }
public long SourceId { get; init; }
public required string PathRel { get; init; }
public string? RootPath { get; init; }
public long? FileId { get; init; }
public int Attributes { get; init; }
public CloudAvailability? CloudAvailability { get; init; }
}
public sealed class DuplicateGroup
{
public long SizeBytes { get; init; }
public byte[]? Hash { get; init; }
public IReadOnlyList<IndexEntry> Entries { get; init; } = [];
public bool SameFileId { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace Explorer.Domain.Abstractions;
public interface IOsClipboard
{
void SetFiles(IReadOnlyList<string> paths, bool cut);
bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut);
}

View File

@@ -0,0 +1,95 @@
namespace Explorer.Domain.Abstractions;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public interface IAppEnvironment
{
string DataDirectory { get; }
string DatabasePath { get; }
string LogDirectory { get; }
}
public interface IVolumeService
{
IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes();
VolumeFingerprint? Probe(string path);
bool IsPathReachable(string path);
}
public interface IFileSystemEnumerator
{
IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath);
FileSystemItem? GetItem(string path);
IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error);
}
public interface IUsnJournal
{
bool TryQuery(string rootPath, out UsnJournalState state, out string? error);
IReadOnlyList<UsnRecord> Read(string rootPath, UsnJournalState from, int maxRecords, out UsnJournalState next, out UsnReadStatus status);
}
public sealed class UsnJournalState
{
public long JournalId { get; init; }
public long NextUsn { get; init; }
}
public enum UsnReadStatus
{
Ok,
Unavailable,
JournalReset,
AccessDenied,
Error
}
public sealed class UsnRecord
{
public long FileReferenceNumber { get; init; }
public long ParentFileReferenceNumber { get; init; }
public long Usn { get; init; }
public required string FileName { get; init; }
public int Reason { get; init; }
public int FileAttributes { get; init; }
public bool IsDirectory => (FileAttributes & AttributeFlags.Directory) != 0;
public bool IsCreate => (Reason & UsnReasons.FileCreate) != 0;
public bool IsDelete => (Reason & UsnReasons.FileDelete) != 0;
public bool IsRenameOld => (Reason & UsnReasons.RenameOldName) != 0;
public bool IsRenameNew => (Reason & UsnReasons.RenameNewName) != 0;
public bool IsDataChange => (Reason & (UsnReasons.DataOverwrite | UsnReasons.DataExtend | UsnReasons.DataTruncation | UsnReasons.BasicInfoChange)) != 0;
}
public static class UsnReasons
{
public const int DataOverwrite = 0x00000001;
public const int DataExtend = 0x00000002;
public const int DataTruncation = 0x00000004;
public const int FileCreate = 0x00000100;
public const int FileDelete = 0x00000200;
public const int RenameOldName = 0x00001000;
public const int RenameNewName = 0x00002000;
public const int BasicInfoChange = 0x00008000;
public const int Close = unchecked((int)0x80000000);
}
public interface IShellFileOperations
{
void Open(string path);
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
}
public interface IIconService
{
byte[]? GetIconPng(string path, bool isDirectory, int size);
}

View File

@@ -0,0 +1,21 @@
namespace Explorer.Domain;
public static class AppConstants
{
public const string ProductFolderName = "ExplorerWorkbench";
public const string DatabaseFileName = "index.db";
public const string LogFolderName = "logs";
public const int SchemaVersion = 3;
public const int DefaultTombstoneRetentionDays = 30;
public const int ScanBatchSize = 3000;
public const int SearchPageSize = 500;
public const int SearchHardLimit = 10_000;
public const int AnalysisTopN = 100;
public const int AnalysisTreeChildTake = 500;
public const long DirectoryHistoryThresholdBytes = 1L * 1024 * 1024 * 1024;
public const int DirectoryHistoryTopN = 200;
public const int PartialHashBytes = 64 * 1024;
public const int LocalScanParallelism = 4;
public const int NetworkScanParallelism = 1;
public const int ProgressHzMilliseconds = 100;
}

View File

@@ -0,0 +1,16 @@
namespace Explorer.Domain;
public static class DefaultExcludes
{
public static IReadOnlyList<ExcludeRule> Create() =>
[
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\Windows", Scope = "global" },
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\System Volume Information", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = "$Recycle.Bin", Scope = "global" },
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\ProgramData\Microsoft", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = ".git", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = "Temp", Scope = "global" },
new() { Kind = ExcludeKind.Glob, Pattern = "Cache", Scope = "global" }
];
}

View File

@@ -0,0 +1,163 @@
namespace Explorer.Domain;
public sealed class Source
{
public long Id { get; set; }
public required string StableKey { get; set; }
public SourceKind Kind { get; set; }
public required string DisplayName { get; set; }
public string? VolumeGuid { get; set; }
public long? VolumeSerial { get; set; }
public string? Filesystem { get; set; }
public string? Label { get; set; }
public long? CapacityBytes { get; set; }
public string? DeviceInstanceId { get; set; }
public string? LastRootPath { get; set; }
public SourceStatus Status { get; set; } = SourceStatus.Offline;
public DateTimeOffset? LastSeenUtc { get; set; }
public DateTimeOffset? LastIndexedUtc { get; set; }
public long? UsnJournalId { get; set; }
public long? UsnNext { get; set; }
public long ScanGeneration { get; set; }
public string? LastError { get; set; }
public bool IsIndexed => LastIndexedUtc is not null;
}
public sealed class VolumeFingerprint
{
public SourceKind Kind { get; init; }
public string? VolumeGuid { get; init; }
public long? VolumeSerial { get; init; }
public string? Filesystem { get; init; }
public string? Label { get; init; }
public long? CapacityBytes { get; init; }
public string? DeviceInstanceId { get; init; }
public required string RootPath { get; init; }
public string? DisplayName { get; init; }
}
public sealed class IndexEntry
{
public long Id { get; set; }
public long SourceId { get; set; }
public long? ParentId { get; set; }
public required string Name { get; set; }
public required string NameNorm { get; set; }
public string? Extension { get; set; }
public bool IsDirectory { get; set; }
public long SizeBytes { get; set; }
public long AggregateSize { get; set; }
public int ChildFileCount { get; set; }
public int ChildDirCount { get; set; }
public DateTimeOffset? CreatedUtc { get; set; }
public DateTimeOffset? ModifiedUtc { get; set; }
public DateTimeOffset LastSeenUtc { get; set; }
public DateTimeOffset? LastIndexedUtc { get; set; }
public int Attributes { get; set; }
public long? FileId { get; set; }
public long? ParentFileId { get; set; }
public int ReparseTag { get; set; }
public EntryStatus Status { get; set; } = EntryStatus.Present;
public DateTimeOffset? DeletedUtc { get; set; }
public required string PathRel { get; set; }
public byte[]? ContentHash { get; set; }
public HashState HashState { get; set; }
public long ScanGeneration { get; set; }
public long? AllocatedSizeBytes { get; set; }
public CloudAvailability? CloudAvailability { get; set; }
}
public sealed class ExcludeRule
{
public long Id { get; set; }
public string Scope { get; set; } = "global";
public long? SourceId { get; set; }
public ExcludeKind Kind { get; set; }
public required string Pattern { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed class ScanJob
{
public long Id { get; set; }
public long SourceId { get; set; }
public ScanKind Kind { get; set; }
public ScanJobStatus Status { get; set; }
public DateTimeOffset? StartedUtc { get; set; }
public DateTimeOffset? FinishedUtc { get; set; }
public long FilesSeen { get; set; }
public long DirsSeen { get; set; }
public long BytesSeen { get; set; }
public string? ResumePath { get; set; }
public int ErrorCount { get; set; }
public string? LastError { get; set; }
public string? FolderPathRel { get; set; }
}
public sealed class ScanError
{
public long Id { get; set; }
public long JobId { get; set; }
public string? Path { get; set; }
public string? Kind { get; set; }
public string? Message { get; set; }
public DateTimeOffset Utc { get; set; }
}
public sealed class TransferJob
{
public long Id { get; set; }
public TransferOp Op { get; set; }
public required string SourcePath { get; set; }
public string? DestinationPath { get; set; }
public TransferStatus Status { get; set; }
public long? BytesTotal { get; set; }
public long BytesDone { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public string? Error { get; set; }
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
}
public sealed class FileSystemItem
{
public required string FullPath { get; init; }
public required string Name { get; init; }
public bool IsDirectory { get; init; }
public long SizeBytes { get; init; }
public DateTimeOffset? CreatedUtc { get; init; }
public DateTimeOffset? ModifiedUtc { get; init; }
public int Attributes { get; init; }
public long? FileId { get; init; }
public int ReparseTag { get; init; }
public long? AllocatedSizeBytes { get; init; }
public CloudPresence? Cloud { get; init; }
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
}
public sealed record CloudPresence(
string? ProviderId,
CloudAvailability Availability,
long LogicalSizeBytes,
long? AllocatedSizeBytes,
bool MayHydrateOnRead,
string? StatusText = null);
public sealed record ScanProgress
{
public long JobId { get; init; }
public long SourceId { get; init; }
public required string CurrentPath { get; init; }
public long FilesSeen { get; init; }
public long DirsSeen { get; init; }
public long BytesSeen { get; init; }
public int ErrorCount { get; init; }
public ScanJobStatus Status { get; init; }
}
public sealed class FolderListing
{
public required string Path { get; init; }
public bool IsOffline { get; init; }
public IReadOnlyList<FileSystemItem> Items { get; init; } = [];
public string? Error { get; init; }
}

View File

@@ -0,0 +1,138 @@
namespace Explorer.Domain;
public enum SourceKind
{
NtfsLocal,
Removable,
Smb,
Nfs,
Cloud
}
public enum SourceStatus
{
Online,
Offline,
Scanning,
Stale,
Error
}
public enum EntryStatus
{
Present = 0,
Offline = 1,
Deleted = 2,
Unknown = 3
}
public enum HashState
{
None = 0,
Partial = 1,
Full = 2,
Skipped = 3
}
public enum CloudAvailability
{
Unknown = 0,
OnlineOnly = 1,
LocallyAvailable = 2,
Pinned = 3,
Syncing = 4,
Error = 5
}
public enum ExcludeKind
{
PathPrefix,
Glob,
Extension,
Attribute
}
public enum ScanKind
{
Full,
Incremental,
Folder,
Rebuild
}
public enum ScanJobStatus
{
Queued,
Running,
Cancelled,
Failed,
Done,
Interrupted
}
public enum TransferOp
{
Copy,
Move,
Delete,
Rename,
NewFolder
}
public enum TransferStatus
{
Queued,
Running,
Cancelling,
Cancelled,
Failed,
Done
}
public enum FolderViewMode
{
Details,
List,
Preview
}
public enum SearchScopeKind
{
CurrentFolder,
CurrentTree,
Selected,
Sources,
AllKnown,
OfflineMedia
}
public enum IndexFreshness
{
Unknown,
Current,
Scanning,
Stale,
Offline,
NotIndexed
}
public static class AttributeFlags
{
public const int ReadOnly = 1;
public const int Hidden = 2;
public const int System = 4;
public const int Directory = 16;
public const int Archive = 32;
public const int ReparsePoint = 1024;
public const int Offline = 0x1000;
public const int RecallOnOpen = 0x40000;
public const int Pinned = 0x80000;
public const int Unpinned = 0x100000;
public const int RecallOnDataAccess = 0x400000;
public static bool MayHydrateOnRead(int attributes)
=> (attributes & (RecallOnDataAccess | Offline)) != 0;
public static bool IsPinned(int attributes)
=> (attributes & Pinned) != 0 && (attributes & Unpinned) == 0;
}

View File

@@ -0,0 +1,89 @@
using System.Text.RegularExpressions;
namespace Explorer.Domain;
public sealed class ExcludeEvaluator
{
private readonly IReadOnlyList<ExcludeRule> _rules;
private readonly bool _skipHidden;
private readonly bool _skipSystem;
public ExcludeEvaluator(IReadOnlyList<ExcludeRule> rules, bool skipHidden = true, bool skipSystem = true)
{
_rules = rules.Where(r => r.Enabled).ToList();
_skipHidden = skipHidden;
_skipSystem = skipSystem;
}
public bool ShouldExclude(string fullPath, string name, bool isDirectory, int attributes, long? sourceId)
{
if (_skipHidden && (attributes & AttributeFlags.Hidden) != 0)
{
return true;
}
if (_skipSystem && (attributes & AttributeFlags.System) != 0)
{
return true;
}
foreach (var rule in _rules)
{
if (rule.SourceId is not null && sourceId is not null && rule.SourceId != sourceId)
{
continue;
}
switch (rule.Kind)
{
case ExcludeKind.PathPrefix:
if (fullPath.StartsWith(rule.Pattern, StringComparison.OrdinalIgnoreCase))
{
return true;
}
break;
case ExcludeKind.Extension:
if (!isDirectory)
{
var ext = NameNormalizer.Extension(name);
if (string.Equals(ext, NameNormalizer.Normalize(rule.Pattern.TrimStart('.')), StringComparison.Ordinal))
{
return true;
}
}
break;
case ExcludeKind.Glob:
if (GlobMatch(name, rule.Pattern) || GlobMatch(fullPath, rule.Pattern))
{
return true;
}
break;
case ExcludeKind.Attribute:
if (int.TryParse(rule.Pattern, out var flag) && (attributes & flag) != 0)
{
return true;
}
break;
}
}
return false;
}
public static bool GlobMatch(string text, string pattern)
{
if (pattern is "*" or "*.*")
{
return true;
}
var rx = "^" + Regex.Escape(pattern)
.Replace("\\*", ".*", StringComparison.Ordinal)
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
return Regex.IsMatch(text, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Domain</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,34 @@
using System.Globalization;
using System.Text;
namespace Explorer.Domain;
public static class NameNormalizer
{
public static string Normalize(string name)
{
if (string.IsNullOrEmpty(name))
{
return string.Empty;
}
var form = name.Normalize(NormalizationForm.FormC);
return form.ToLower(CultureInfo.InvariantCulture);
}
public static string? Extension(string name)
{
if (string.IsNullOrEmpty(name))
{
return null;
}
var idx = name.LastIndexOf('.');
if (idx <= 0 || idx == name.Length - 1)
{
return null;
}
return Normalize(name[(idx + 1)..]);
}
}

View File

@@ -0,0 +1,202 @@
namespace Explorer.Domain;
public static class PathRules
{
public const string ExtendedPrefix = @"\\?\";
public const string ExtendedUncPrefix = @"\\?\UNC\";
public static string ToExtended(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return path;
}
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
{
return path;
}
if (path.StartsWith(@"\\", StringComparison.Ordinal))
{
return ExtendedUncPrefix + path[2..];
}
return ExtendedPrefix + path;
}
public static string FromExtended(string path)
{
if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase))
{
return @"\\" + path[ExtendedUncPrefix.Length..];
}
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
{
return path[ExtendedPrefix.Length..];
}
return path;
}
public static bool IsUnc(string path)
{
var p = FromExtended(path);
return p.StartsWith(@"\\", StringComparison.Ordinal);
}
public static string NormalizeDirectorySeparators(string path)
=> path.Replace('/', '\\');
public static string Combine(string root, string relative)
{
root = FromExtended(root).TrimEnd('\\');
relative = relative.Replace('/', '\\').TrimStart('\\');
if (string.IsNullOrEmpty(relative))
{
return EnsureDirectoryTrailingSlashIfRoot(root);
}
return root + "\\" + relative;
}
public static string MakeRelative(string root, string fullPath)
{
var r = FromExtended(root).TrimEnd('\\');
var f = FromExtended(fullPath).TrimEnd('\\');
if (f.Equals(r, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
if (f.StartsWith(r + "\\", StringComparison.OrdinalIgnoreCase))
{
return f[(r.Length + 1)..];
}
return f;
}
public static string CanonicalUncRoot(string path)
{
var p = FromExtended(path).TrimEnd('\\');
if (!p.StartsWith(@"\\", StringComparison.Ordinal))
{
return p;
}
var parts = p[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return @"\\" + p[2..].ToLowerInvariant();
}
return @"\\" + parts[0].ToLowerInvariant() + "\\" + parts[1];
}
public static string Parent(string path)
{
var p = FromExtended(path).TrimEnd('\\');
if (p.Length >= 2 && p[1] == ':' && p.Length <= 3)
{
return p.Length == 2 ? p + "\\" : p + (p.EndsWith('\\') ? "" : "\\");
}
if (IsUnc(p))
{
var root = CanonicalUncRoot(p);
if (p.Equals(root, StringComparison.OrdinalIgnoreCase))
{
return root;
}
}
var idx = p.LastIndexOf('\\');
if (idx <= 0)
{
return p;
}
if (idx == 2 && p[1] == ':')
{
return p[..3];
}
return p[..idx];
}
public static bool IsDriveRoot(string path)
{
var p = FromExtended(path).TrimEnd('\\');
return p.Length == 2 && p[1] == ':';
}
public static string EnsureDirectoryTrailingSlashIfRoot(string path)
{
var p = FromExtended(path);
if (p.Length == 2 && p[1] == ':')
{
return p + "\\";
}
return p;
}
public static string GetFileName(string path)
{
var p = FromExtended(path).TrimEnd('\\');
var idx = p.LastIndexOf('\\');
return idx < 0 ? p : p[(idx + 1)..];
}
public static string JoinDisplay(string? root, string pathRel)
{
if (string.IsNullOrWhiteSpace(root))
{
return pathRel;
}
return string.IsNullOrWhiteSpace(pathRel) ? EnsureDirectoryTrailingSlashIfRoot(root) : Combine(root, pathRel);
}
public static string ShortenDisplay(string path, int maxChars = 64)
{
var p = FromExtended(path);
if (p.Length <= maxChars)
{
return p;
}
var unc = IsUnc(p);
var parts = p.TrimEnd('\\').Split('\\', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 2)
{
return p;
}
string prefix;
var start = 0;
if (unc)
{
prefix = @"\\" + parts[0] + "\\" + parts[1];
start = 2;
}
else
{
prefix = parts.Length > 2 ? parts[0] + "\\" + parts[1] : parts[0];
start = parts.Length > 2 ? 2 : 1;
}
var leaf = parts[^1];
var parent = parts.Length - 1 > start ? parts[^2] + "\\" + leaf : leaf;
var candidate = prefix + @"\…\" + parent;
if (candidate.Length <= maxChars)
{
return candidate;
}
candidate = prefix + @"\…\" + leaf;
return candidate.Length <= maxChars ? candidate : prefix + @"\…";
}
}

View File

@@ -0,0 +1,25 @@
namespace Explorer.Domain;
public static class ReparsePolicy
{
public const int IoReparseTagMountPoint = unchecked((int)0xA0000003);
public const int IoReparseTagSymlink = unchecked((int)0xA000000C);
public static bool ShouldRecurseIntoDirectory(FileSystemItem item)
{
if (!item.IsDirectory)
{
return false;
}
if (!item.IsReparsePoint)
{
return true;
}
return false;
}
public static bool IsMountPoint(int reparseTag)
=> reparseTag == IoReparseTagMountPoint;
}

View File

@@ -0,0 +1,82 @@
namespace Explorer.Domain;
public static class VolumeIdentityMatcher
{
public sealed record MatchResult(Source? Source, MatchStrength Strength, bool Ambiguous);
public enum MatchStrength
{
None,
Weak,
SerialCapacity,
Guid
}
public static MatchResult Match(VolumeFingerprint fingerprint, IReadOnlyList<Source> known)
{
if (!string.IsNullOrWhiteSpace(fingerprint.VolumeGuid))
{
var guidHits = known
.Where(s => !string.IsNullOrEmpty(s.VolumeGuid)
&& string.Equals(s.VolumeGuid, fingerprint.VolumeGuid, StringComparison.OrdinalIgnoreCase))
.ToList();
if (guidHits.Count == 1)
{
return new MatchResult(guidHits[0], MatchStrength.Guid, false);
}
if (guidHits.Count > 1)
{
return new MatchResult(guidHits[0], MatchStrength.Guid, true);
}
}
if (fingerprint.VolumeSerial is > 0)
{
var serialHits = known.Where(s =>
s.VolumeSerial == fingerprint.VolumeSerial
&& string.Equals(s.Filesystem, fingerprint.Filesystem, StringComparison.OrdinalIgnoreCase)
&& s.CapacityBytes == fingerprint.CapacityBytes)
.ToList();
if (serialHits.Count == 1)
{
return new MatchResult(serialHits[0], MatchStrength.SerialCapacity, false);
}
if (serialHits.Count == 0 && !string.IsNullOrEmpty(fingerprint.Label))
{
serialHits = known.Where(s =>
s.VolumeSerial == fingerprint.VolumeSerial
&& string.Equals(s.Label, fingerprint.Label, StringComparison.OrdinalIgnoreCase)
&& s.CapacityBytes == fingerprint.CapacityBytes)
.ToList();
}
if (serialHits.Count == 1)
{
return new MatchResult(serialHits[0], MatchStrength.Weak, false);
}
if (serialHits.Count > 1)
{
return new MatchResult(serialHits[0], MatchStrength.Weak, true);
}
}
if (fingerprint.Kind is SourceKind.Smb or SourceKind.Nfs)
{
var unc = PathRules.CanonicalUncRoot(fingerprint.RootPath);
var uncHits = known.Where(s =>
s.Kind == fingerprint.Kind
&& s.LastRootPath is not null
&& string.Equals(PathRules.CanonicalUncRoot(s.LastRootPath), unc, StringComparison.OrdinalIgnoreCase))
.ToList();
if (uncHits.Count == 1)
{
return new MatchResult(uncHits[0], MatchStrength.SerialCapacity, false);
}
}
return new MatchResult(null, MatchStrength.None, false);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.FileOperations</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,70 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
namespace Explorer.FileOperations;
public sealed class FileOperationService
{
private readonly TransferQueue _queue;
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
public FileOperationService(TransferQueue queue, IShellFileOperations shell, IFileSystemEnumerator enumerator)
{
_queue = queue;
_shell = shell;
_enumerator = enumerator;
}
public void Open(IReadOnlyList<string> paths)
{
foreach (var path in paths)
{
_shell.Open(path);
}
}
public Task CopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _queue.EnqueueCopyAsync(sources, destinationDirectory, cancellationToken);
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
public Task DeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
=> _queue.EnqueueDeleteAsync(paths, cancellationToken);
public void Rename(string path, string newName)
{
var parent = PathRules.Parent(path);
var dest = Path.Combine(parent, newName);
var src = PathRules.ToExtended(path);
var dst = PathRules.ToExtended(dest);
if (Directory.Exists(src))
{
Directory.Move(src, dst);
}
else
{
File.Move(src, dst);
}
}
public string NewFolder(string parent)
{
var baseName = "New folder";
var name = baseName;
var i = 2;
var dest = Path.Combine(parent, name);
while (Directory.Exists(PathRules.ToExtended(dest)) || File.Exists(PathRules.ToExtended(dest)))
{
name = $"{baseName} ({i++})";
dest = Path.Combine(parent, name);
}
Directory.CreateDirectory(PathRules.ToExtended(dest));
return dest;
}
public FileSystemItem? GetItem(string path) => _enumerator.GetItem(path);
}

View File

@@ -0,0 +1,304 @@
using System.Threading.Channels;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IIndexStore _store;
private readonly ILogger<TransferQueue> _logger;
private readonly Channel<Work> _channel = Channel.CreateUnbounded<Work>();
private readonly List<TransferJob> _jobs = [];
private readonly object _gate = new();
public event EventHandler? Changed;
public event EventHandler<TransferJob>? JobFinished;
public TransferQueue(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IIndexStore store,
ILogger<TransferQueue> logger)
{
_shell = shell;
_enumerator = enumerator;
_store = store;
_logger = logger;
}
public IReadOnlyList<TransferJob> Snapshot()
{
lock (_gate)
{
return _jobs.ToList();
}
}
public async Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
{
foreach (var src in sources)
{
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
await EnqueueAsync(new TransferJob
{
Op = TransferOp.Copy,
SourcePath = src,
DestinationPath = dest,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
}
}
public async Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
{
foreach (var src in sources)
{
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
await EnqueueAsync(new TransferJob
{
Op = TransferOp.Move,
SourcePath = src,
DestinationPath = dest,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
}
}
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
{
await EnqueueAsync(new TransferJob
{
Op = TransferOp.Delete,
SourcePath = string.Join("|", paths),
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = paths.ToList()
}, cancellationToken).ConfigureAwait(false);
}
public void Cancel(long jobId)
{
lock (_gate)
{
var job = _jobs.FirstOrDefault(j => j.Id == jobId);
if (job is null)
{
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Running)
{
job.Status = TransferStatus.Cancelling;
}
else
{
_jobs.Remove(job);
}
}
Changed?.Invoke(this, EventArgs.Empty);
}
public void Dismiss(long jobId)
{
lock (_gate)
{
_jobs.RemoveAll(j => j.Id == jobId);
}
Changed?.Invoke(this, EventArgs.Empty);
}
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
lock (_gate)
{
_jobs.Insert(0, job);
}
_channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
Changed?.Invoke(this, EventArgs.Empty);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var work in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
{
var job = work.Job;
if (job.Status == TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelled;
await Persist(job).ConfigureAwait(false);
continue;
}
job.Status = TransferStatus.Running;
Changed?.Invoke(this, EventArgs.Empty);
try
{
switch (job.Op)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, stoppingToken).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, stoppingToken).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
}
if (job.Status == TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelled;
}
else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
{
job.Status = TransferStatus.Failed;
}
else if (job.Status != TransferStatus.Failed)
{
job.Status = TransferStatus.Done;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
job.Status = TransferStatus.Failed;
job.Error = ex.Message;
}
await Persist(job).ConfigureAwait(false);
if (job.Status is TransferStatus.Done or TransferStatus.Cancelled)
{
lock (_gate)
{
_jobs.Remove(job);
}
}
Changed?.Invoke(this, EventArgs.Empty);
JobFinished?.Invoke(this, job);
}
}
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
if (move && job.Status != TransferStatus.Failed && job.Status != TransferStatus.Cancelling)
{
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
}
return;
}
job.BytesTotal = item.SizeBytes;
var progress = new Progress<long>(b =>
{
job.BytesDone = b;
Changed?.Invoke(this, EventArgs.Empty);
});
var ok = move
? _shell.MoveFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out var error)
: _shell.CopyFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out error);
if (!ok)
{
job.Status = error == "Cancelled" ? TransferStatus.Cancelling : TransferStatus.Failed;
job.Error = error;
}
else
{
job.BytesDone = job.BytesTotal ?? job.BytesDone;
}
}
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling)
{
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
}
else
{
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var ok = move
? _shell.MoveFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out var err)
: _shell.CopyFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out err);
if (ok)
{
job.BytesDone += child.SizeBytes;
}
else if (err != "Cancelled")
{
job.Error = err;
}
Changed?.Invoke(this, EventArgs.Empty);
}
}
}
await Task.CompletedTask.ConfigureAwait(false);
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
if (!_shell.DeleteToRecycleBin(paths, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
private readonly record struct Work(TransferJob Job, CancellationTokenSource Cts);
}

View File

@@ -0,0 +1,108 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class DirectoryWatcherHub : IDisposable
{
private readonly IVolumeService _volumes;
private readonly IndexingCoordinator _indexing;
private readonly IIndexStore _store;
private readonly ILogger<DirectoryWatcherHub> _logger;
private readonly Dictionary<long, FileSystemWatcher> _watchers = new();
private readonly object _gate = new();
public DirectoryWatcherHub(
IVolumeService volumes,
IndexingCoordinator indexing,
IIndexStore store,
ILogger<DirectoryWatcherHub> logger)
{
_volumes = volumes;
_indexing = indexing;
_store = store;
_logger = logger;
}
public async Task RefreshAsync(CancellationToken cancellationToken)
{
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
lock (_gate)
{
foreach (var source in sources)
{
var online = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
if (online && source.IsIndexed)
{
EnsureWatcher(source);
}
else
{
RemoveWatcher(source.Id);
}
}
}
}
private void EnsureWatcher(Source source)
{
if (_watchers.ContainsKey(source.Id) || source.LastRootPath is null)
{
return;
}
try
{
var watcher = new FileSystemWatcher(source.LastRootPath)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.Size | NotifyFilters.LastWrite | NotifyFilters.Attributes,
InternalBufferSize = 64 * 1024
};
var sourceId = source.Id;
void OnChange(object s, FileSystemEventArgs e)
{
var rel = PathRules.MakeRelative(source.LastRootPath!, PathRules.Parent(e.FullPath));
_indexing.EnqueueReconcile(sourceId, rel);
}
watcher.Created += OnChange;
watcher.Changed += OnChange;
watcher.Deleted += OnChange;
watcher.Renamed += OnChange;
watcher.Error += (_, args) =>
{
_logger.LogWarning(args.GetException(), "Watcher overflow for source {Id}", sourceId);
_ = _store.Sources.UpdateStatusAsync(sourceId, SourceStatus.Stale, "Filesystem watcher overflow");
};
watcher.EnableRaisingEvents = true;
_watchers[source.Id] = watcher;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Watcher not available for {Path}", source.LastRootPath);
}
}
private void RemoveWatcher(long sourceId)
{
if (_watchers.Remove(sourceId, out var watcher))
{
watcher.Dispose();
}
}
public void Dispose()
{
lock (_gate)
{
foreach (var w in _watchers.Values)
{
w.Dispose();
}
_watchers.Clear();
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Indexing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,309 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class FilesystemScanner
{
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly StorageProviderRegistry _providers;
private readonly ILogger<FilesystemScanner> _logger;
public FilesystemScanner(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
ILogger<FilesystemScanner> logger)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
_logger = logger;
}
public async Task<ScanJob> ScanAsync(
Source source,
ScanKind kind,
string? folderPathRel,
IProgress<ScanProgress>? progress,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var generation = source.ScanGeneration + 1;
source.ScanGeneration = generation;
var job = new ScanJob
{
SourceId = source.Id,
Kind = kind,
Status = ScanJobStatus.Running,
StartedUtc = now,
FolderPathRel = folderPathRel
};
var pending = new List<IndexEntry>(AppConstants.ScanBatchSize);
try
{
await _store.Sources.UpsertAsync(source, CancellationToken.None).ConfigureAwait(false);
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null, CancellationToken.None)
.ConfigureAwait(false);
await _store.ScanJobs.InsertAsync(job, CancellationToken.None).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var excludes = await _store.Excludes.GetAllAsync(cancellationToken).ConfigureAwait(false);
var evaluator = new ExcludeEvaluator(excludes);
var root = source.LastRootPath ?? throw new InvalidOperationException("Source has no root path.");
var startRel = folderPathRel ?? "";
var startPath = PathRules.Combine(root, startRel);
var lastProgress = DateTime.UtcNow;
var visitedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var startItem = _enumerator.GetItem(startPath);
long? startParentId = null;
if (!string.IsNullOrEmpty(startRel))
{
var parentRel = PathRules.MakeRelative(root, PathRules.Parent(startPath));
var parent = await _store.Entries.GetByPathAsync(source.Id, parentRel, cancellationToken).ConfigureAwait(false);
startParentId = parent?.Id;
}
var startEntry = CreateEntry(source, startParentId, startItem ?? DummyDir(startPath, startRel, root), startRel, now, generation);
startEntry.IsDirectory = true;
await _store.RunWriteAsync(async s =>
{
startEntry.Id = await s.Entries.UpsertAsync(startEntry, cancellationToken).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
var stack = new Stack<Frame>();
stack.Push(new Frame
{
Path = startPath,
PathRel = startRel,
Id = startEntry.Id,
ParentId = startParentId
});
while (stack.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var frame = stack.Peek();
if (!frame.Expanded)
{
if (!visitedDirs.Add(frame.Path))
{
stack.Pop();
continue;
}
var children = await _providers.EnrichAsync(
_enumerator.EnumerateChildrenSafe(frame.Path, out var error),
cancellationToken).ConfigureAwait(false);
if (error is not null)
{
job.ErrorCount++;
job.LastError = error;
await _store.ScanJobs.AddErrorAsync(new ScanError
{
JobId = job.Id,
Path = frame.Path,
Kind = error.Contains("denied", StringComparison.OrdinalIgnoreCase) ? "AccessDenied" : "Io",
Message = error,
Utc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
frame.Expanded = true;
continue;
}
var childDirs = new List<Frame>();
foreach (var child in children)
{
cancellationToken.ThrowIfCancellationRequested();
if (evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, source.Id))
{
continue;
}
var rel = PathRules.MakeRelative(root, child.FullPath);
var entry = CreateEntry(source, frame.Id, child, rel, DateTimeOffset.UtcNow, generation);
pending.Add(entry);
if (child.IsDirectory)
{
job.DirsSeen++;
if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
{
childDirs.Add(new Frame
{
Path = child.FullPath,
PathRel = rel,
Parent = frame,
ParentId = frame.Id
});
}
else
{
frame.Dirs++;
}
}
else
{
job.FilesSeen++;
job.BytesSeen += child.SizeBytes;
frame.Size += child.SizeBytes;
frame.Files++;
}
}
await FlushAsync(pending, childDirs, cancellationToken).ConfigureAwait(false);
frame.Expanded = true;
for (var i = childDirs.Count - 1; i >= 0; i--)
{
stack.Push(childDirs[i]);
}
}
else
{
await _store.Entries.UpdateAggregatesAsync(frame.Id, frame.Size, frame.Files, frame.Dirs, cancellationToken)
.ConfigureAwait(false);
if (frame.Parent is not null)
{
frame.Parent.Size += frame.Size;
frame.Parent.Dirs++;
}
stack.Pop();
}
if ((DateTime.UtcNow - lastProgress).TotalMilliseconds >= AppConstants.ProgressHzMilliseconds)
{
lastProgress = DateTime.UtcNow;
progress?.Report(ToProgress(job, frame.Path));
job.ResumePath = frame.PathRel;
await _store.ScanJobs.UpdateAsync(job, cancellationToken).ConfigureAwait(false);
}
}
await FlushAsync(pending, [], cancellationToken).ConfigureAwait(false);
await _store.Entries.MarkMissingAsDeletedAsync(source.Id, generation, DateTimeOffset.UtcNow, string.IsNullOrEmpty(startRel) ? null : startRel, cancellationToken)
.ConfigureAwait(false);
await _store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, generation, cancellationToken)
.ConfigureAwait(false);
job.Status = ScanJobStatus.Done;
job.FinishedUtc = DateTimeOffset.UtcNow;
await _store.ScanJobs.UpdateAsync(job, cancellationToken).ConfigureAwait(false);
progress?.Report(ToProgress(job, startPath) with { Status = ScanJobStatus.Done });
return job;
}
catch (OperationCanceledException)
{
await FlushAsync(pending, [], CancellationToken.None).ConfigureAwait(false);
job.Status = ScanJobStatus.Cancelled;
job.FinishedUtc = DateTimeOffset.UtcNow;
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Scan cancelled", CancellationToken.None)
.ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Scan failed for source {Id}", source.Id);
job.Status = ScanJobStatus.Failed;
job.LastError = ex.Message;
job.FinishedUtc = DateTimeOffset.UtcNow;
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Error, ex.Message, CancellationToken.None)
.ConfigureAwait(false);
throw;
}
}
private async Task FlushAsync(List<IndexEntry> pending, List<Frame> dirs, CancellationToken cancellationToken)
{
if (pending.Count == 0)
{
return;
}
await _store.RunWriteAsync(async s =>
{
foreach (var entry in pending)
{
entry.Id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
}
}, cancellationToken).ConfigureAwait(false);
foreach (var d in dirs)
{
var match = pending.Find(e =>
e.IsDirectory && string.Equals(e.PathRel, d.PathRel, StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
d.Id = match.Id;
}
}
pending.Clear();
}
private static ScanProgress ToProgress(ScanJob job, string path) => new()
{
JobId = job.Id,
SourceId = job.SourceId,
CurrentPath = path,
FilesSeen = job.FilesSeen,
DirsSeen = job.DirsSeen,
BytesSeen = job.BytesSeen,
ErrorCount = job.ErrorCount,
Status = job.Status
};
private static FileSystemItem DummyDir(string startPath, string startRel, string root) => new()
{
FullPath = startPath,
Name = string.IsNullOrEmpty(startRel) ? root.TrimEnd('\\') : PathRules.GetFileName(startPath),
IsDirectory = true,
SizeBytes = 0,
Attributes = AttributeFlags.Directory,
ReparseTag = 0
};
private static IndexEntry CreateEntry(Source source, long? parentId, FileSystemItem item, string pathRel, DateTimeOffset now, long generation)
=> new()
{
SourceId = source.Id,
ParentId = parentId,
Name = item.Name,
NameNorm = NameNormalizer.Normalize(item.Name),
Extension = item.IsDirectory ? null : NameNormalizer.Extension(item.Name),
IsDirectory = item.IsDirectory,
SizeBytes = item.IsDirectory ? 0 : item.SizeBytes,
AggregateSize = item.IsDirectory ? 0 : item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
LastSeenUtc = now,
LastIndexedUtc = now,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
Status = EntryStatus.Present,
PathRel = pathRel,
ScanGeneration = generation,
AllocatedSizeBytes = item.AllocatedSizeBytes ?? item.Cloud?.AllocatedSizeBytes,
CloudAvailability = item.Cloud?.Availability
};
private sealed class Frame
{
public required string Path { get; init; }
public required string PathRel { get; init; }
public long Id { get; set; }
public long? ParentId { get; init; }
public Frame? Parent { get; init; }
public bool Expanded { get; set; }
public long Size { get; set; }
public int Files { get; set; }
public int Dirs { get; set; }
}
}

View File

@@ -0,0 +1,106 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class FolderReconciler
{
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly StorageProviderRegistry _providers;
public FolderReconciler(IIndexStore store, IFileSystemEnumerator enumerator, StorageProviderRegistry providers)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
}
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(source.LastRootPath))
{
return;
}
var full = PathRules.Combine(source.LastRootPath, pathRel);
var parent = await _store.Entries.GetByPathAsync(source.Id, pathRel, cancellationToken).ConfigureAwait(false);
if (parent is null)
{
return;
}
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
.ConfigureAwait(false);
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
await _store.RunWriteAsync(async s =>
{
foreach (var item in live)
{
var rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
var entry = new IndexEntry
{
SourceId = source.Id,
ParentId = parent.Id,
Name = item.Name,
NameNorm = NameNormalizer.Normalize(item.Name),
Extension = item.IsDirectory ? null : NameNormalizer.Extension(item.Name),
IsDirectory = item.IsDirectory,
SizeBytes = item.IsDirectory ? 0 : item.SizeBytes,
AggregateSize = item.IsDirectory ? 0 : item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
LastSeenUtc = now,
LastIndexedUtc = now,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
Status = EntryStatus.Present,
PathRel = rel,
ScanGeneration = source.ScanGeneration,
AllocatedSizeBytes = item.AllocatedSizeBytes ?? item.Cloud?.AllocatedSizeBytes,
CloudAvailability = item.Cloud?.Availability
};
var existing = indexed.FirstOrDefault(e => e.NameNorm == entry.NameNorm);
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
var id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
if (!item.IsDirectory)
{
var delta = item.SizeBytes - oldSize;
if (existing is null)
{
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, item.SizeBytes, 1, 0, cancellationToken)
.ConfigureAwait(false);
}
else if (delta != 0)
{
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
.ConfigureAwait(false);
}
}
else if (existing is null)
{
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
.ConfigureAwait(false);
}
_ = id;
}
foreach (var old in indexed)
{
if (!liveNames.Contains(old.NameNorm))
{
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
}
}
}, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,155 @@
using System.Threading.Channels;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class IndexingCoordinator : BackgroundService
{
private readonly IIndexStore _store;
private readonly FilesystemScanner _scanner;
private readonly FolderReconciler _reconciler;
private readonly UsnChangeApplier _usn;
private readonly IUsnJournal _journal;
private readonly IVolumeService _volumes;
private readonly ILogger<IndexingCoordinator> _logger;
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
private readonly Dictionary<long, CancellationTokenSource> _running = new();
private readonly object _gate = new();
public event EventHandler<ScanProgress>? ProgressChanged;
public IndexingCoordinator(
IIndexStore store,
FilesystemScanner scanner,
FolderReconciler reconciler,
UsnChangeApplier usn,
IUsnJournal journal,
IVolumeService volumes,
ILogger<IndexingCoordinator> logger)
{
_store = store;
_scanner = scanner;
_reconciler = reconciler;
_usn = usn;
_journal = journal;
_volumes = volumes;
_logger = logger;
}
public void EnqueueFullScan(long sourceId)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null));
public void EnqueueFolderScan(long sourceId, string pathRel)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel));
public void EnqueueReconcile(long sourceId, string pathRel)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel));
public void EnqueueUsn(long sourceId)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null));
public void Cancel(long sourceId)
{
lock (_gate)
{
if (_running.TryGetValue(sourceId, out var cts))
{
cts.Cancel();
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _store.ScanJobs.InterruptRunningAsync(stoppingToken).ConfigureAwait(false);
_ = Task.Run(() => PeriodicAsync(stoppingToken), stoppingToken);
await foreach (var item in _work.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await RunAsync(item, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Indexing work cancelled for source {Id}", item.SourceId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Indexing work failed for source {Id}", item.SourceId);
}
}
}
private async Task RunAsync(IndexWork item, CancellationToken stoppingToken)
{
var source = await _store.Sources.GetAsync(item.SourceId, stoppingToken).ConfigureAwait(false);
if (source is null)
{
return;
}
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
lock (_gate)
{
_running[item.SourceId] = linked;
}
try
{
switch (item.Kind)
{
case WorkKind.Full:
case WorkKind.Folder:
await _scanner.ScanAsync(
source,
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
item.PathRel,
new Progress<ScanProgress>(p => ProgressChanged?.Invoke(this, p)),
linked.Token).ConfigureAwait(false);
EnqueueUsn(source.Id);
break;
case WorkKind.Reconcile:
if (item.PathRel is not null)
{
await _reconciler.ReconcileAsync(source, item.PathRel, linked.Token).ConfigureAwait(false);
}
break;
case WorkKind.Usn:
await _usn.ApplyAsync(source, _journal, linked.Token).ConfigureAwait(false);
break;
}
}
finally
{
lock (_gate)
{
_running.Remove(item.SourceId);
}
}
}
private async Task PeriodicAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var sources = await _store.Sources.GetAllAsync(stoppingToken).ConfigureAwait(false);
foreach (var source in sources.Where(s => s.IsIndexed && s.Status is SourceStatus.Online or SourceStatus.Stale))
{
if (source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath))
{
EnqueueUsn(source.Id);
}
}
}
}
private enum WorkKind { Full, Folder, Reconcile, Usn }
private readonly record struct IndexWork(WorkKind Kind, long SourceId, string? PathRel);
}

View File

@@ -0,0 +1,180 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class UsnChangeApplier
{
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly ILogger<UsnChangeApplier> _logger;
public UsnChangeApplier(IIndexStore store, IFileSystemEnumerator enumerator, ILogger<UsnChangeApplier> logger)
{
_store = store;
_enumerator = enumerator;
_logger = logger;
}
public async Task<UsnReadStatus> ApplyAsync(Source source, IUsnJournal journal, CancellationToken cancellationToken)
{
if (source.LastRootPath is null || source.Kind is not (SourceKind.NtfsLocal or SourceKind.Removable))
{
return UsnReadStatus.Unavailable;
}
if (!journal.TryQuery(source.LastRootPath, out var current, out _))
{
return UsnReadStatus.Unavailable;
}
var from = new UsnJournalState
{
JournalId = source.UsnJournalId ?? 0,
NextUsn = source.UsnNext ?? 0
};
if (from.JournalId != 0 && from.JournalId != current.JournalId)
{
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Change journal reset", cancellationToken)
.ConfigureAwait(false);
return UsnReadStatus.JournalReset;
}
if (from.JournalId == 0)
{
await _store.Sources.UpdateUsnAsync(source.Id, current.JournalId, current.NextUsn, cancellationToken)
.ConfigureAwait(false);
return UsnReadStatus.Ok;
}
var records = journal.Read(source.LastRootPath, from, 4000, out var next, out var status);
if (status is UsnReadStatus.JournalReset)
{
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Change journal reset", cancellationToken)
.ConfigureAwait(false);
return status;
}
if (status != UsnReadStatus.Ok)
{
return status;
}
var coalesced = Coalesce(records);
var now = DateTimeOffset.UtcNow;
await _store.RunWriteAsync(async s =>
{
foreach (var rec in coalesced)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ApplyRecord(s, source, rec, now, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "USN apply failed for {Name}", rec.FileName);
}
}
await s.Sources.UpdateUsnAsync(source.Id, next.JournalId, next.NextUsn, cancellationToken).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
return UsnReadStatus.Ok;
}
private async Task ApplyRecord(IIndexStore store, Source source, UsnRecord rec, DateTimeOffset now, CancellationToken cancellationToken)
{
var existing = rec.FileReferenceNumber != 0
? await store.Entries.GetByFileIdAsync(source.Id, rec.FileReferenceNumber, cancellationToken).ConfigureAwait(false)
: null;
if (rec.IsDelete)
{
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
}
return;
}
var parent = rec.ParentFileReferenceNumber != 0
? await store.Entries.GetByFileIdAsync(source.Id, rec.ParentFileReferenceNumber, cancellationToken).ConfigureAwait(false)
: await store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
var parentRel = parent?.PathRel ?? "";
var rel = string.IsNullOrEmpty(parentRel) ? rec.FileName : parentRel + "\\" + rec.FileName;
var full = PathRules.Combine(source.LastRootPath!, rel);
var live = _enumerator.GetItem(full);
if (live is null)
{
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
}
return;
}
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
var entry = new IndexEntry
{
SourceId = source.Id,
ParentId = parent?.Id,
Name = live.Name,
NameNorm = NameNormalizer.Normalize(live.Name),
Extension = live.IsDirectory ? null : NameNormalizer.Extension(live.Name),
IsDirectory = live.IsDirectory,
SizeBytes = live.IsDirectory ? 0 : live.SizeBytes,
AggregateSize = existing?.AggregateSize ?? (live.IsDirectory ? 0 : live.SizeBytes),
CreatedUtc = live.CreatedUtc,
ModifiedUtc = live.ModifiedUtc,
LastSeenUtc = now,
LastIndexedUtc = now,
Attributes = live.Attributes,
FileId = rec.FileReferenceNumber,
ParentFileId = rec.ParentFileReferenceNumber,
ReparseTag = live.ReparseTag,
Status = EntryStatus.Present,
PathRel = rel,
ScanGeneration = source.ScanGeneration
};
if (existing is not null && !string.Equals(existing.PathRel, rel, StringComparison.OrdinalIgnoreCase))
{
await store.Entries.RenameSubtreePathAsync(source.Id, existing.PathRel, rel, cancellationToken)
.ConfigureAwait(false);
}
await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
if (!live.IsDirectory)
{
var delta = live.SizeBytes - oldSize;
if (existing is null)
{
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, live.SizeBytes, 1, 0, cancellationToken)
.ConfigureAwait(false);
}
else if (delta != 0)
{
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, delta, 0, 0, cancellationToken)
.ConfigureAwait(false);
}
}
}
private static List<UsnRecord> Coalesce(IReadOnlyList<UsnRecord> records)
{
var map = new Dictionary<long, UsnRecord>();
foreach (var rec in records)
{
map[rec.FileReferenceNumber] = rec;
}
return map.Values.ToList();
}
}

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Plugin.Abstractions</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,94 @@
namespace Explorer.Plugin.Abstractions;
public enum ProviderIsolation
{
InProcess = 0,
OutOfProcess = 1
}
[Flags]
public enum ProviderCapability
{
None = 0,
CloudState = 1,
Pin = 2,
Dehydrate = 4,
Quota = 8,
Sharing = 16,
VersionHistory = 32
}
public enum CloudAvailability
{
Unknown = 0,
OnlineOnly = 1,
LocallyAvailable = 2,
Pinned = 3,
Syncing = 4,
Error = 5
}
public enum ProviderAction
{
Pin = 0,
Unpin = 1,
Dehydrate = 2,
ShowQuota = 3,
Share = 4,
VersionHistory = 5
}
public enum ProviderActionStatus
{
Succeeded = 0,
Unsupported = 1,
Failed = 2,
RequiresUserConfirmation = 3
}
public sealed record ProviderManifest(
string Id,
string DisplayName,
string Version,
ProviderIsolation Isolation);
public sealed record ProviderItemState(
string ProviderId,
string Path,
CloudAvailability Availability,
long LogicalSizeBytes,
long? AllocatedSizeBytes,
bool MayHydrateOnOpen,
bool MayHydrateOnRead,
string? StatusText,
string? ErrorText);
public sealed record ProviderActionRequest(
ProviderAction Action,
IReadOnlyList<string> Paths);
public sealed record ProviderActionResult(
ProviderActionStatus Status,
string? Message = null);
public sealed record ProviderQuota(
string RootPath,
long? UsedBytes,
long? TotalBytes,
string? Label);
public sealed record ProviderPlace(
string ProviderId,
string DisplayName,
string Path);
public interface IStorageProvider
{
ProviderManifest Manifest { get; }
ProviderCapability GetCapabilities();
bool TryMatchRoot(string path);
IReadOnlyList<ProviderPlace> GetPlaces() => [];
Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default);
Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default);
Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Plugin.OneDrive</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,111 @@
using Microsoft.Win32;
using Explorer.Plugin.Abstractions;
namespace Explorer.Plugin.OneDrive;
public static class OneDrivePlaceDiscovery
{
public static IReadOnlyList<ProviderPlace> Discover()
{
var found = new List<ProviderPlace>();
void Add(string providerPath, string label)
{
if (string.IsNullOrWhiteSpace(providerPath))
{
return;
}
var trimmed = providerPath.Trim().TrimEnd('\\');
if (trimmed.Length == 0
|| found.Exists(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase)))
{
return;
}
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed));
}
try
{
using var accounts = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\OneDrive\Accounts");
if (accounts is not null)
{
foreach (var name in accounts.GetSubKeyNames())
{
using var key = accounts.OpenSubKey(name);
if (key is null)
{
continue;
}
var folder = key.GetValue("UserFolder") as string;
if (string.IsNullOrWhiteSpace(folder))
{
continue;
}
Add(folder, BuildLabel(
name,
folder,
key.GetValue("DisplayName") as string,
FirstNonEmpty(key.GetValue("UserName") as string, key.GetValue("UserDisplayName") as string)));
}
}
}
catch
{
// registry is optional
}
Add(Environment.GetEnvironmentVariable("OneDriveConsumer") ?? "", "OneDrive");
Add(Environment.GetEnvironmentVariable("OneDrive") ?? "", "OneDrive");
Add(Environment.GetEnvironmentVariable("OneDriveCommercial") ?? "", "OneDrive - Work");
return found;
}
public static string BuildLabel(string accountKey, string userFolder, string? displayName, string? userName)
{
var folderName = Path.GetFileName(userFolder.TrimEnd('\\'));
var user = FirstNonEmpty(userName, displayName);
var personal = accountKey.Equals("Personal", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(user))
{
if (personal)
{
return $"{user} - Personal";
}
if (!string.IsNullOrWhiteSpace(displayName)
&& !displayName.Equals(user, StringComparison.OrdinalIgnoreCase))
{
return $"{user} - {displayName}";
}
if (!string.IsNullOrWhiteSpace(folderName)
&& folderName.StartsWith("OneDrive", StringComparison.OrdinalIgnoreCase)
&& folderName.Length > "OneDrive".Length)
{
return folderName;
}
return string.IsNullOrWhiteSpace(accountKey) ? user : $"{user} - {accountKey}";
}
if (!string.IsNullOrWhiteSpace(folderName)
&& !folderName.Equals("OneDrive", StringComparison.OrdinalIgnoreCase))
{
return folderName;
}
if (!string.IsNullOrWhiteSpace(displayName))
{
return $"OneDrive - {displayName}";
}
return "OneDrive";
}
private static string? FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}

View File

@@ -0,0 +1,101 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.OneDrive;
public static class OneDrivePlaceholderState
{
public const int Offline = 0x1000;
public const int RecallOnOpen = 0x40000;
public const int Pinned = 0x80000;
public const int Unpinned = 0x100000;
public const int RecallOnDataAccess = 0x400000;
public static ProviderItemState FromLocalSignals(
string providerId,
string path,
int attributes,
int reparseTag,
long logicalSize,
long? allocatedSize,
uint? placeholderState)
{
var availability = MapAvailability(attributes, placeholderState);
var hydrateOnRead = availability == CloudAvailability.OnlineOnly
|| (attributes & (RecallOnDataAccess | Offline)) != 0
|| (placeholderState is uint cf && (cf & CloudFilesNative.PlaceholderPartial) != 0
&& (attributes & RecallOnDataAccess) != 0);
var hydrateOnOpen = hydrateOnRead || (attributes & RecallOnOpen) != 0;
return new ProviderItemState(
providerId,
path,
availability,
logicalSize,
allocatedSize,
hydrateOnOpen,
hydrateOnRead,
StatusText(availability),
availability == CloudAvailability.Error ? "Cloud placeholder reported an error" : null);
}
public static CloudAvailability MapAvailability(int attributes, uint? placeholderState)
{
if (placeholderState == CloudFilesNative.PlaceholderInvalid)
{
return CloudAvailability.Error;
}
if ((attributes & Pinned) != 0 && (attributes & Unpinned) == 0)
{
return CloudAvailability.Pinned;
}
if ((attributes & RecallOnDataAccess) != 0 || (attributes & Offline) != 0)
{
return CloudAvailability.OnlineOnly;
}
if (placeholderState is uint cf)
{
if ((cf & CloudFilesNative.PlaceholderPartial) != 0
&& (cf & CloudFilesNative.PlaceholderInSync) == 0)
{
return CloudAvailability.Syncing;
}
if ((cf & CloudFilesNative.Placeholder) != 0
&& (cf & CloudFilesNative.PlaceholderPartial) != 0)
{
return CloudAvailability.OnlineOnly;
}
}
if ((attributes & Unpinned) != 0 && (attributes & RecallOnDataAccess) == 0)
{
return CloudAvailability.LocallyAvailable;
}
if (placeholderState is uint known && (known & CloudFilesNative.Placeholder) != 0)
{
return CloudAvailability.LocallyAvailable;
}
if ((attributes & (Pinned | Unpinned | RecallOnDataAccess | Offline | RecallOnOpen)) != 0)
{
return CloudAvailability.LocallyAvailable;
}
return CloudAvailability.Unknown;
}
public static string StatusText(CloudAvailability availability) => availability switch
{
CloudAvailability.OnlineOnly => "Online-only",
CloudAvailability.LocallyAvailable => "Available on this device",
CloudAvailability.Pinned => "Always available on this device",
CloudAvailability.Syncing => "Syncing",
CloudAvailability.Error => "Sync error",
_ => ""
};
}

View File

@@ -0,0 +1,140 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.OneDrive;
public sealed class OneDriveStorageProvider : IStorageProvider
{
public const string ProviderId = "onedrive";
private readonly object _gate = new();
private IReadOnlyList<ProviderPlace>? _places;
public ProviderManifest Manifest { get; } = new(
ProviderId,
"OneDrive",
"1.0.0",
ProviderIsolation.InProcess);
public ProviderCapability GetCapabilities()
=> ProviderCapability.CloudState | ProviderCapability.Pin | ProviderCapability.Dehydrate;
public bool TryMatchRoot(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
foreach (var root in GetPlaces())
{
if (IsUnder(root.Path, path))
{
return true;
}
}
return CloudFilesNative.IsCloudSyncRoot(path);
}
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
{
var results = new List<ProviderItemState>(paths.Count);
foreach (var path in paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryMatchRoot(path))
{
continue;
}
try
{
var attrs = (int)File.GetAttributes(path);
var isDir = (attrs & (int)FileAttributes.Directory) != 0;
long logical = 0;
if (!isDir)
{
try { logical = new FileInfo(path).Length; } catch { /* locked */ }
}
var allocated = CloudFilesNative.TryGetAllocatedSize(path);
var placeholder = CloudFilesNative.TryGetPlaceholderState(attrs, 0);
results.Add(OneDrivePlaceholderState.FromLocalSignals(
ProviderId, path, attrs, 0, logical, allocated, placeholder));
}
catch
{
// fail-open for this item
}
}
return Task.FromResult<IReadOnlyList<ProviderItemState>>(results);
}
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
{
var capabilities = GetCapabilities();
var needed = request.Action switch
{
ProviderAction.Pin or ProviderAction.Unpin => ProviderCapability.Pin,
ProviderAction.Dehydrate => ProviderCapability.Dehydrate,
_ => ProviderCapability.None
};
if (needed == ProviderCapability.None || (capabilities & needed) == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "This action is not available."));
}
var pin = request.Action switch
{
ProviderAction.Pin => CloudFilesNative.PinPinned,
ProviderAction.Unpin or ProviderAction.Dehydrate => CloudFilesNative.PinUnpinned,
_ => CloudFilesNative.PinUnspecified
};
var failed = 0;
foreach (var path in request.Paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!CloudFilesNative.TrySetPinState(path, pin))
{
failed++;
}
}
if (failed == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Succeeded));
}
if (failed == request.Paths.Count)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, "OneDrive could not change the pin state."));
}
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, $"OneDrive could not change the pin state for {failed} item(s)."));
}
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderQuota?>(null);
public IReadOnlyList<ProviderPlace> GetPlaces()
{
lock (_gate)
{
return _places ??= OneDrivePlaceDiscovery.Discover();
}
}
private static bool IsUnder(string root, string path)
{
var a = root.TrimEnd('\\');
var b = path.TrimEnd('\\');
return b.Equals(a, StringComparison.OrdinalIgnoreCase)
|| b.StartsWith(a + "\\", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Presentation</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
namespace Explorer.Presentation;
public static class Formatters
{
public static string Size(long bytes)
{
if (bytes < 0)
{
return string.Empty;
}
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double v = bytes;
var u = 0;
while (v >= 1024 && u < units.Length - 1)
{
v /= 1024;
u++;
}
return u == 0 ? $"{bytes} B" : $"{v:0.##} {units[u]}";
}
public static string Date(DateTimeOffset? value)
=> value is null ? "" : value.Value.ToLocalTime().ToString("g");
}

View File

@@ -0,0 +1,50 @@
namespace Explorer.Presentation;
public sealed class NavigationHistory
{
private readonly List<string> _items = [];
private int _index = -1;
public bool CanGoBack => _index > 0;
public bool CanGoForward => _index >= 0 && _index < _items.Count - 1;
public string? Current => _index >= 0 && _index < _items.Count ? _items[_index] : null;
public void Navigate(string path)
{
if (_index >= 0 && _index < _items.Count
&& string.Equals(_items[_index], path, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (_index < _items.Count - 1 && _index >= 0)
{
_items.RemoveRange(_index + 1, _items.Count - _index - 1);
}
_items.Add(path);
_index = _items.Count - 1;
}
public string? Back()
{
if (!CanGoBack)
{
return null;
}
_index--;
return _items[_index];
}
public string? Forward()
{
if (!CanGoForward)
{
return null;
}
_index++;
return _items[_index];
}
}

View File

@@ -0,0 +1,660 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed record StorageScopeItem(Source? Source, string Label)
{
public override string ToString() => Label;
}
public sealed partial class StorageNodeViewModel : ObservableObject
{
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _childrenLoaded;
[ObservableProperty] private double _fraction;
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string PathRel { get; init; }
public required long SourceId { get; init; }
public long? EntryId { get; init; }
public long Size { get; init; }
public int FileCount { get; init; }
public int DirCount { get; init; }
public int Depth { get; init; }
public bool IsDirectory { get; init; } = true;
public bool IsSource { get; init; }
public string? State { get; init; }
public bool HasState => !string.IsNullOrEmpty(State);
public string SizeLabel => Formatters.Size(Size);
public string CountLabel => FileCount == 0 && DirCount == 0
? ""
: $"{FileCount:N0} files · {DirCount:N0} folders";
public bool CanExpand => IsDirectory && EntryId is not null;
public double Indent => Depth * 16;
public string Glyph => IsSource ? "\uEDA2" : "\uE8B7";
public ObservableCollection<StorageNodeViewModel> Children { get; } = [];
}
public sealed partial class AnalysisRowViewModel : ObservableObject
{
public required string Name { get; init; }
public required long Size { get; init; }
public required double Fraction { get; init; }
public string? Path { get; init; }
public string? PathRel { get; init; }
public string? ShortPath { get; init; }
public long? EntryId { get; init; }
public long? SourceId { get; init; }
public bool IsDirectory { get; init; }
public int FileCount { get; init; }
public int DirCount { get; init; }
public string? State { get; init; }
public bool HasState => !string.IsNullOrEmpty(State);
public string SizeLabel => Formatters.Size(Size);
public string CountLabel => FileCount == 0 && DirCount == 0
? ""
: FileCount > 0 && DirCount == 0
? $"{FileCount:N0} files"
: $"{FileCount:N0} files · {DirCount:N0} folders";
}
public sealed partial class AnalysisViewModel : ObservableObject
{
public const string PageTree = "Tree";
public const string PageFolders = "Biggest folders";
public const string PageFiles = "Biggest files";
public const string PageTypes = "By file type";
public const string PageSources = "By source";
private readonly AnalysisService _analysis;
private bool _suppressReload;
private CancellationTokenSource? _loadCts;
private int _loadVersion;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private string _page = PageTree;
[ObservableProperty] private string _status = "";
[ObservableProperty] private StorageScopeItem? _selectedScope;
[ObservableProperty] private StorageNodeViewModel? _selectedNode;
[ObservableProperty] private AnalysisRowViewModel? _selectedRow;
[ObservableProperty] private bool _isBusy;
public AnalysisViewModel(AnalysisService analysis)
{
_analysis = analysis;
Scopes = [];
TreeRoots = [];
VisibleNodes = [];
Rows = [];
}
public string[] Pages { get; } = [PageTree, PageFolders, PageFiles, PageTypes, PageSources];
public ObservableCollection<StorageScopeItem> Scopes { get; }
public ObservableCollection<StorageNodeViewModel> TreeRoots { get; }
public ObservableCollection<StorageNodeViewModel> VisibleNodes { get; }
public ObservableCollection<AnalysisRowViewModel> Rows { get; }
public bool IsTree => Page == PageTree;
public bool IsRanking => !IsTree;
public bool CanAct
{
get
{
var path = SelectedPath;
return !string.IsNullOrWhiteSpace(path) && path != "This PC";
}
}
public string? SelectedPath => IsTree
? SelectedNode?.FullPath
: SelectedRow?.Path;
public long? SelectedSourceId => IsTree
? SelectedNode?.SourceId
: SelectedRow?.SourceId;
public string? SelectedPathRel => IsTree ? SelectedNode?.PathRel : SelectedRow?.PathRel;
public bool SelectedIsDirectory => IsTree
? SelectedNode?.IsDirectory != false
: SelectedRow?.IsDirectory != false;
public bool SelectedIsSource => IsTree && SelectedNode?.IsSource == true
|| (!IsTree && SelectedRow is { IsDirectory: true, PathRel: "" or null } && Page == PageSources);
public string? SelectedNavigatePath
{
get
{
var path = SelectedPath;
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
return null;
}
return SelectedIsDirectory ? path : PathRules.Parent(path);
}
}
public string SelectedScanPathRel
{
get
{
var rel = SelectedPathRel;
if (string.IsNullOrEmpty(rel))
{
return "";
}
if (SelectedIsDirectory)
{
return rel;
}
return rel.Contains('\\') ? PathRules.Parent(rel) : "";
}
}
[RelayCommand]
public async Task OpenAsync()
{
IsOpen = true;
await RefreshScopesAsync().ConfigureAwait(true);
await ReloadAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Close()
{
_loadCts?.Cancel();
IsOpen = false;
IsBusy = false;
}
[RelayCommand]
public async Task ReloadAsync()
{
if (_suppressReload)
{
return;
}
_loadCts?.Cancel();
_loadCts?.Dispose();
var cts = new CancellationTokenSource();
_loadCts = cts;
var version = Interlocked.Increment(ref _loadVersion);
var ct = cts.Token;
IsBusy = true;
Status = "Reading index…";
try
{
if (Page == PageTree)
{
await LoadTreeAsync(ct).ConfigureAwait(true);
}
else
{
await LoadRankingAsync(ct).ConfigureAwait(true);
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception)
{
if (version == _loadVersion)
{
Status = "Could not read the index.";
}
}
finally
{
if (version == _loadVersion)
{
IsBusy = false;
NotifySelection();
}
}
}
[RelayCommand]
public async Task ToggleNodeAsync(StorageNodeViewModel? node)
{
if (node is null || !node.CanExpand)
{
return;
}
if (node.IsExpanded)
{
node.IsExpanded = false;
RemoveVisibleDescendants(node);
return;
}
if (!node.ChildrenLoaded)
{
var ct = _loadCts?.Token ?? CancellationToken.None;
await LoadChildrenAsync(node, ct).ConfigureAwait(true);
}
if (_loadCts?.IsCancellationRequested == true)
{
return;
}
node.IsExpanded = true;
InsertVisibleChildren(node);
}
public static void ApplySiblingFractions(IReadOnlyList<StorageNodeViewModel> siblings)
{
var max = siblings.Count == 0 ? 1L : Math.Max(1, siblings.Max(s => s.Size));
foreach (var sibling in siblings)
{
sibling.Fraction = sibling.Size / (double)max;
}
}
private async Task RefreshScopesAsync()
{
var previous = SelectedScope?.Source?.Id;
_suppressReload = true;
try
{
var sources = await _analysis.GetKnownSourcesAsync().ConfigureAwait(true);
Scopes.Clear();
Scopes.Add(new StorageScopeItem(null, "All indexed locations"));
foreach (var source in sources)
{
Scopes.Add(new StorageScopeItem(source, source.DisplayName));
}
SelectedScope = previous is long id
? Scopes.FirstOrDefault(s => s.Source?.Id == id) ?? Scopes[0]
: Scopes[0];
}
finally
{
_suppressReload = false;
}
}
private async Task LoadTreeAsync(CancellationToken cancellationToken)
{
Status = "Preparing storage indexes…";
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Status = "Reading index…";
var scope = SelectedScope;
var page = await AnalysisService.RunOffUiAsync(async ct =>
{
var known = await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false);
var sources = FilterSources(known, scope).ToList();
var rootsBySource = (await _analysis.GetDirectoryRootsAsync(ct).ConfigureAwait(false))
.ToDictionary(r => r.SourceId);
var roots = new List<StorageNodeViewModel>(sources.Count);
foreach (var source in sources)
{
rootsBySource.TryGetValue(source.Id, out var root);
var path = source.LastRootPath ?? source.DisplayName;
roots.Add(new StorageNodeViewModel
{
Name = source.DisplayName,
FullPath = path,
PathRel = "",
SourceId = source.Id,
EntryId = root?.Id,
Size = root?.AggregateSize ?? 0,
FileCount = root?.ChildFileCount ?? 0,
DirCount = root?.ChildDirCount ?? 0,
Depth = 0,
IsDirectory = true,
IsSource = true,
State = SourceState(source)
});
}
ApplySiblingFractions(roots);
return roots;
}, cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
TreeRoots.Clear();
VisibleNodes.Clear();
foreach (var root in page)
{
TreeRoots.Add(root);
}
RebuildVisible();
Status = page.Count == 0
? "Nothing indexed yet."
: "";
}
private async Task LoadChildrenAsync(StorageNodeViewModel node, CancellationToken cancellationToken)
{
if (node.EntryId is not long parentId)
{
node.ChildrenLoaded = true;
return;
}
var created = await AnalysisService.RunOffUiAsync(async ct =>
{
var children = await _analysis.LargestDirectoriesAsync(
node.SourceId, parentId, AppConstants.AnalysisTreeChildTake, ct).ConfigureAwait(false);
var source = (await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false))
.FirstOrDefault(s => s.Id == node.SourceId);
var state = source is null ? node.State : SourceState(source);
var rows = new List<StorageNodeViewModel>(children.Count);
foreach (var entry in children)
{
rows.Add(new StorageNodeViewModel
{
Name = entry.Name,
FullPath = PathRules.JoinDisplay(source?.LastRootPath ?? node.FullPath, entry.PathRel),
PathRel = entry.PathRel,
SourceId = entry.SourceId,
EntryId = entry.Id,
Size = entry.AggregateSize,
FileCount = entry.ChildFileCount,
DirCount = entry.ChildDirCount,
Depth = node.Depth + 1,
IsDirectory = true,
State = state is "Indexed" or null or "" ? null : state
});
}
ApplySiblingFractions(rows);
return rows;
}, cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
node.Children.Clear();
foreach (var child in created)
{
node.Children.Add(child);
}
node.ChildrenLoaded = true;
if (created.Count == AppConstants.AnalysisTreeChildTake)
{
Status = $"Showing the {AppConstants.AnalysisTreeChildTake:N0} largest folders in this directory.";
}
}
private async Task LoadRankingAsync(CancellationToken cancellationToken)
{
var page = Page;
var scope = SelectedScope;
var sourceId = scope?.Source?.Id;
if (scope?.Source is { IsIndexed: false })
{
Rows.Clear();
Status = "This location has not been indexed yet.";
return;
}
Status = "Preparing storage indexes…";
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Status = "Reading index…";
var built = await AnalysisService.RunOffUiAsync(
ct => BuildRankingRowsAsync(page, sourceId, ct),
cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
Rows.Clear();
foreach (var row in built)
{
Rows.Add(row);
}
Status = scope?.Source is { Status: SourceStatus.Stale }
? "Index may be out of date."
: "";
}
private async Task<List<AnalysisRowViewModel>> BuildRankingRowsAsync(
string page,
long? sourceId,
CancellationToken cancellationToken)
{
var sources = (await _analysis.GetKnownSourcesAsync(cancellationToken).ConfigureAwait(false))
.ToDictionary(s => s.Id);
IReadOnlyList<(string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)> items;
if (page == PageFolders)
{
var dirs = await _analysis.LargestDirectoriesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = dirs.Select(d => ToRank(d, sources, isDir: true)).ToList();
}
else if (page == PageFiles)
{
var files = await _analysis.LargestFilesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = files.Select(d => ToRank(d, sources, isDir: false)).ToList();
}
else if (page == PageTypes)
{
var types = await _analysis.UsageByExtensionAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
.ConfigureAwait(false);
items = types.Select(t => (
string.IsNullOrEmpty(t.Extension) ? "(none)" : "." + t.Extension,
t.TotalSize,
(string?)null,
(string?)null,
(string?)null,
(long?)null,
sourceId,
false,
(int)t.FileCount,
0,
(string?)null)).ToList();
}
else
{
var usage = await _analysis.UsageBySourceAsync(cancellationToken).ConfigureAwait(false);
items = usage.Select(u =>
{
sources.TryGetValue(u.SourceId, out var source);
var path = source?.LastRootPath ?? u.DisplayName;
var stateSource = source ?? new Source
{
StableKey = "",
DisplayName = u.DisplayName,
Status = u.Status
};
return (u.DisplayName, u.TotalSize, (string?)path, (string?)"", (string?)PathRules.ShortenDisplay(path),
(long?)null, (long?)u.SourceId, true, (int)u.FileCount, 0, SourceState(stateSource));
}).ToList();
}
var max = items.Count == 0 ? 1L : Math.Max(1, items.Max(i => i.Size));
return items.Select(item => new AnalysisRowViewModel
{
Name = item.Name,
Size = item.Size,
Fraction = item.Size / (double)max,
Path = item.Path,
PathRel = item.PathRel,
ShortPath = item.ShortPath,
EntryId = item.Id,
SourceId = item.SourceId,
IsDirectory = item.IsDir,
FileCount = item.Files,
DirCount = item.Dirs,
State = item.State
}).ToList();
}
private static (string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)
ToRank(IndexEntry entry, IReadOnlyDictionary<long, Source> sources, bool isDir)
{
sources.TryGetValue(entry.SourceId, out var source);
var full = PathRules.JoinDisplay(source?.LastRootPath, entry.PathRel);
var state = source is null ? null : SourceState(source);
if (state is "Indexed" or "")
{
state = null;
}
return (
entry.Name,
isDir ? entry.AggregateSize : entry.SizeBytes,
full,
entry.PathRel,
PathRules.ShortenDisplay(full),
entry.Id,
entry.SourceId,
isDir,
entry.ChildFileCount,
entry.ChildDirCount,
state);
}
private void RebuildVisible()
{
VisibleNodes.Clear();
foreach (var root in TreeRoots)
{
AppendVisible(root);
}
}
private void AppendVisible(StorageNodeViewModel node)
{
VisibleNodes.Add(node);
if (!node.IsExpanded)
{
return;
}
foreach (var child in node.Children)
{
AppendVisible(child);
}
}
private void InsertVisibleChildren(StorageNodeViewModel node)
{
var index = IndexOfVisible(node);
if (index < 0)
{
RebuildVisible();
return;
}
var insertAt = index + 1;
foreach (var child in node.Children)
{
VisibleNodes.Insert(insertAt++, child);
if (child.IsExpanded)
{
foreach (var nested in FlattenExpanded(child))
{
VisibleNodes.Insert(insertAt++, nested);
}
}
}
}
private void RemoveVisibleDescendants(StorageNodeViewModel node)
{
var index = IndexOfVisible(node);
if (index < 0)
{
RebuildVisible();
return;
}
var next = index + 1;
while (next < VisibleNodes.Count && VisibleNodes[next].Depth > node.Depth)
{
VisibleNodes.RemoveAt(next);
}
}
private int IndexOfVisible(StorageNodeViewModel node)
{
for (var i = 0; i < VisibleNodes.Count; i++)
{
if (ReferenceEquals(VisibleNodes[i], node))
{
return i;
}
}
return -1;
}
private static IEnumerable<StorageNodeViewModel> FlattenExpanded(StorageNodeViewModel node)
{
foreach (var child in node.Children)
{
yield return child;
if (!child.IsExpanded)
{
continue;
}
foreach (var nested in FlattenExpanded(child))
{
yield return nested;
}
}
}
private static IEnumerable<Source> FilterSources(IReadOnlyList<Source> sources, StorageScopeItem? scope)
{
if (scope?.Source is { } one)
{
return [one];
}
return sources.Where(s => s.IsIndexed);
}
private static string? SourceState(Source source)
=> source.Status switch
{
SourceStatus.Offline => "Offline",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "Stale",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? null : "Not indexed"
};
private void NotifySelection()
{
OnPropertyChanged(nameof(IsTree));
OnPropertyChanged(nameof(IsRanking));
OnPropertyChanged(nameof(CanAct));
OnPropertyChanged(nameof(SelectedPath));
OnPropertyChanged(nameof(SelectedNavigatePath));
OnPropertyChanged(nameof(SelectedSourceId));
OnPropertyChanged(nameof(SelectedPathRel));
OnPropertyChanged(nameof(SelectedIsSource));
OnPropertyChanged(nameof(SelectedIsDirectory));
}
partial void OnPageChanged(string value)
{
OnPropertyChanged(nameof(IsTree));
OnPropertyChanged(nameof(IsRanking));
_ = ReloadAsync();
}
partial void OnSelectedScopeChanged(StorageScopeItem? value) => _ = ReloadAsync();
partial void OnSelectedNodeChanged(StorageNodeViewModel? value) => NotifySelection();
partial void OnSelectedRowChanged(AnalysisRowViewModel? value) => NotifySelection();
}

View File

@@ -0,0 +1,79 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
public DuplicateViewModel(IIndexStore store, SourceManager sources)
{
_store = store;
_sources = sources;
Groups = [];
}
public ObservableCollection<string> Groups { get; }
[RelayCommand]
public async Task OpenAsync()
{
IsOpen = true;
IsBusy = true;
Status = "Finding size collisions…";
Groups.Clear();
try
{
var lines = await Task.Run(async () =>
{
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
var result = new List<string>();
foreach (var g in groups)
{
if (g.SameFileId)
{
continue;
}
var paths = string.Join(" | ", g.Entries.Select(e =>
{
sources.TryGetValue(e.SourceId, out var s);
return PathRules.Combine(s?.LastRootPath ?? s?.DisplayName ?? "", e.PathRel);
}));
result.Add($"{Formatters.Size(g.SizeBytes)} · {g.Entries.Count} files · {paths}");
}
return result;
}).ConfigureAwait(true);
foreach (var line in lines)
{
Groups.Add(line);
}
Status = Groups.Count == 0
? "No confirmed duplicates yet. Hashing continues in the background."
: $"{Groups.Count} duplicate groups";
}
catch (Exception)
{
Status = "Could not load duplicates.";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,265 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ExplorerPaneViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly NavigationHistory _history = new();
private CancellationTokenSource? _loadCts;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private bool _showIndexBanner;
[ObservableProperty] private string? _indexBannerText;
[ObservableProperty] private Source? _currentSource;
[ObservableProperty] private FolderViewMode _viewMode = FolderViewMode.Details;
[ObservableProperty] private string _sortProperty = "Name";
[ObservableProperty] private bool _sortDescending;
[ObservableProperty] private bool _isActive;
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
Items = [];
SelectedItems = [];
}
public ObservableCollection<FolderItemViewModel> Items { get; }
public ObservableCollection<FolderItemViewModel> SelectedItems { get; }
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
public bool CanGoBack => _history.CanGoBack;
public bool CanGoForward => _history.CanGoForward;
public bool CanGoUp => CurrentPath is not "This PC";
public async Task NavigateAsync(string path, bool addHistory = true)
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
IsBusy = true;
try
{
CurrentPath = path;
if (addHistory)
{
_history.Navigate(path);
}
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoForward));
OnPropertyChanged(nameof(CanGoUp));
OnPropertyChanged(nameof(Breadcrumb));
if (path == "This PC")
{
await LoadThisPcAsync(ct).ConfigureAwait(true);
return;
}
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
IsOffline = listing.IsOffline;
StatusMessage = listing.Error;
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
IEnumerable<FileSystemItem> ordered = listing.Items;
ordered = SortProperty switch
{
"Size" => SortDescending ? ordered.OrderByDescending(i => i.SizeBytes) : ordered.OrderBy(i => i.SizeBytes),
"Modified" => SortDescending ? ordered.OrderByDescending(i => i.ModifiedUtc) : ordered.OrderBy(i => i.ModifiedUtc),
"Type" => SortDescending ? ordered.OrderByDescending(i => i.IsDirectory) : ordered.OrderBy(i => i.IsDirectory),
_ => SortDescending
? ordered.OrderByDescending(i => i.IsDirectory).ThenByDescending(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
: ordered.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
};
foreach (var item in ordered)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
}
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src)
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
}
}
catch (OperationCanceledException)
{
// superseded
}
finally
{
IsBusy = false;
}
}
private async Task LoadThisPcAsync(CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
var listing = await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true);
foreach (var item in listing.Items)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
}
}
[RelayCommand]
public Task BackAsync()
{
var path = _history.Back();
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
}
[RelayCommand]
public Task ForwardAsync()
{
var path = _history.Forward();
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
}
[RelayCommand]
public Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
=> segment is null ? Task.CompletedTask : NavigateAsync(segment.Path);
[RelayCommand]
public Task UpAsync()
{
if (CurrentPath == "This PC")
{
return Task.CompletedTask;
}
if (PathRules.IsDriveRoot(CurrentPath)
|| (PathRules.IsUnc(CurrentPath)
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
{
return NavigateAsync("This PC");
}
return NavigateAsync(PathRules.Parent(CurrentPath));
}
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public Task OpenItemAsync(FolderItemViewModel item)
{
if (item.IsDirectory)
{
return NavigateAsync(item.FullPath);
}
_ops.Open([item.FullPath]);
return Task.CompletedTask;
}
public void BuildIndex()
{
if (CurrentSource is null)
{
return;
}
_indexing.EnqueueFullScan(CurrentSource.Id);
ShowIndexBanner = false;
IndexBannerText = null;
StatusMessage = "Building index…";
}
public void RescanFolder()
{
if (CurrentSource is null || CurrentPath == "This PC")
{
return;
}
var rel = PathRules.MakeRelative(CurrentSource.LastRootPath ?? CurrentPath, CurrentPath);
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
}
partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
{
if (path == "This PC")
{
return [new BreadcrumbSegment("This PC", "This PC", IsLast: true)];
}
var parts = new List<BreadcrumbSegment> { new("This PC", "This PC") };
var p = PathRules.FromExtended(path);
if (PathRules.IsUnc(p))
{
var root = PathRules.CanonicalUncRoot(p);
parts.Add(new BreadcrumbSegment(root, root));
if (p.Length > root.Length)
{
var acc = root;
foreach (var piece in p[(root.Length + 1)..].Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
acc = PathRules.Combine(acc, piece);
parts.Add(new BreadcrumbSegment(piece, acc));
}
}
}
else
{
var root = Path.GetPathRoot(p)?.TrimEnd('\\') ?? p;
parts.Add(new BreadcrumbSegment(root, root.EndsWith(':') ? root + "\\" : root));
var rest = p.Length > root.Length ? p[(root.Length)..].Trim('\\') : "";
if (!string.IsNullOrEmpty(rest))
{
var acc = root.EndsWith(':') ? root + "\\" : root;
foreach (var piece in rest.Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
acc = PathRules.Combine(acc, piece);
parts.Add(new BreadcrumbSegment(piece, acc));
}
}
}
if (parts.Count > 0)
{
parts[^1] = parts[^1] with { IsLast = true };
}
return parts;
}
}
public sealed record BreadcrumbSegment(string Label, string Path, bool IsLast = false);

View File

@@ -0,0 +1,73 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.FileOperations;
using Explorer.Indexing;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ExplorerTabViewModel : ObservableObject
{
private readonly Func<ExplorerPaneViewModel> _paneFactory;
public const double DefaultSplitRatio = 0.5;
public const double MinSplitRatio = 0.18;
public const double MaxSplitRatio = 0.82;
[ObservableProperty] private string _title = "This PC";
[ObservableProperty] private bool _isSplit;
[ObservableProperty] private ExplorerPaneViewModel _activePane;
[ObservableProperty] private double _splitRatio = DefaultSplitRatio;
public ExplorerTabViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
{
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
Left = _paneFactory();
Right = _paneFactory();
_activePane = Left;
Left.IsActive = true;
Left.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
Title = Left.CurrentPath == "This PC" ? "This PC" : Path.GetFileName(Left.CurrentPath.TrimEnd('\\'));
if (string.IsNullOrEmpty(Title))
{
Title = Left.CurrentPath;
}
}
};
}
public ExplorerPaneViewModel Left { get; }
public ExplorerPaneViewModel Right { get; }
public void Activate(ExplorerPaneViewModel pane)
{
ActivePane = pane;
Left.IsActive = pane == Left;
Right.IsActive = pane == Right;
}
public void SetSplitRatio(double ratio)
=> SplitRatio = Math.Clamp(ratio, MinSplitRatio, MaxSplitRatio);
public void ToggleSplit()
{
IsSplit = !IsSplit;
if (IsSplit)
{
_ = Right.NavigateAsync(Left.CurrentPath);
Activate(Right);
}
else
{
Activate(Left);
}
}
public Task OpenInitialAsync() => Left.NavigateAsync("This PC");
}

View File

@@ -0,0 +1,75 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Domain;
namespace Explorer.Presentation;
public sealed partial class FolderItemViewModel : ObservableObject
{
[ObservableProperty] private bool _isSelected;
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
{
Item = item;
SizeFromIndex = sizeFromIndex;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public string Name => Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
? ""
: Formatters.Size(Item.SizeBytes);
public string ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
public bool IsImage => !Item.IsDirectory && MediaKinds.IsImage(Item.Name);
public bool IsVideo => !Item.IsDirectory && MediaKinds.IsVideo(Item.Name);
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
public string SizeTooltip
{
get
{
var logical = SizeLabel;
var allocated = Item.AllocatedSizeBytes ?? Item.Cloud?.AllocatedSizeBytes;
if (allocated is long disk && disk != Item.SizeBytes && !Item.IsDirectory)
{
return string.IsNullOrEmpty(CloudStatus)
? $"Size {logical} · On disk {Formatters.Size(disk)}"
: $"{CloudStatus} · Size {logical} · On disk {Formatters.Size(disk)}";
}
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
}
}
}
internal static class MediaKinds
{
private static readonly HashSet<string> Images = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff", ".ico", ".jfif"
};
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg"
};
public static bool IsImage(string name) => Images.Contains(Path.GetExtension(name));
public static bool IsVideo(string name) => Videos.Contains(Path.GetExtension(name));
}
file static class ItemExt
{
public static string ExtensionDisplay(this FileSystemItem item)
{
var ext = NameNormalizer.Extension(item.Name);
return string.IsNullOrEmpty(ext) ? "File" : ext.ToUpperInvariant() + " file";
}
}

View File

@@ -0,0 +1,664 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Search;
using Explorer.Analysis;
using Explorer.Domain.Abstractions;
using Explorer.Plugin.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class MainViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
[ObservableProperty] private string _pathText = "";
[ObservableProperty] private string _theme = "Dark";
[ObservableProperty] private string _footer = "";
[ObservableProperty] private string? _promptUnc;
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
public MainViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources,
SearchService search,
AnalysisService analysis,
IIndexStore store,
TransferQueue transfers,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
_cloudPlaces = cloudPlaces;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
Search = new SearchViewModel(search, sources);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Transfers = new TransferQueueViewModel(transfers);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
{
void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx)
{
ctx.Post(_ => Go(), null);
}
else
{
Go();
}
};
_indexing.ProgressChanged += (_, p) =>
{
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
if (_ui is { } ctx)
{
ctx.Post(_ => Footer = text, null);
}
else
{
Footer = text;
}
};
}
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
public ObservableCollection<string> PathHistory { get; }
public NavigationTreeViewModel Tree { get; }
public SearchViewModel Search { get; }
public AnalysisViewModel Analysis { get; }
public DuplicateViewModel Duplicates { get; }
public TransferQueueViewModel Transfers { get; }
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane;
public async Task InitializeAsync()
{
await _sources.InitializeAsync().ConfigureAwait(true);
foreach (var path in _pathHistory.Load())
{
PathHistory.Add(path);
}
await NewTabAsync().ConfigureAwait(true);
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready";
}
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
await tab.OpenInitialAsync().ConfigureAwait(true);
PathText = tab.ActivePane.CurrentPath;
}
[RelayCommand]
public void CloseTab(ExplorerTabViewModel? tab)
{
tab ??= ActiveTab;
if (Tabs.Count <= 1)
{
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
ActiveTab = Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
[RelayCommand]
public void Split() => ActiveTab.ToggleSplit();
[RelayCommand]
public async Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
{
if (segment is null)
{
return;
}
await ActivePane.NavigateAsync(segment.Path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
{
"list" => FolderViewMode.List,
"preview" => FolderViewMode.Preview,
_ => FolderViewMode.Details
};
[RelayCommand]
public void CancelTransfer(TransferJob? job)
{
if (job is not null)
{
Transfers.Cancel(job);
}
}
[RelayCommand]
public Task BackAsync() => ActivePane.BackAsync();
[RelayCommand]
public Task ForwardAsync() => ActivePane.ForwardAsync();
[RelayCommand]
public Task UpAsync() => ActivePane.UpAsync();
[RelayCommand]
public Task RefreshAsync() => ActivePane.RefreshAsync();
[RelayCommand]
public async Task GoAsync()
{
var path = PathText.Trim();
if (string.IsNullOrEmpty(path))
{
return;
}
if (PathRules.IsUnc(path))
{
await _sources.AddUncAsync(path).ConfigureAwait(true);
await Tree.ReloadAsync(path).ConfigureAwait(true);
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
RememberEnteredPath(ActivePane.CurrentPath);
}
[RelayCommand]
public async Task TreeSelectAsync(NavNodeViewModel? node)
{
if (node is null || node.IsPlaceholder)
{
return;
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
}
PathText = node.Path;
}
[RelayCommand]
public Task OpenSelectedAsync()
{
var item = ActivePane.SelectedItems.FirstOrDefault() ?? ActivePane.Items.FirstOrDefault();
return item is null ? Task.CompletedTask : ActivePane.OpenItemAsync(item);
}
[RelayCommand]
public void Copy()
{
_clipboard = SelectedPaths();
_clipboardIsCut = false;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public void Cut()
{
_clipboard = SelectedPaths();
_clipboardIsCut = true;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public async Task PasteAsync()
{
if (Clipboard.TryGetFiles(out var osFiles, out var cut) && osFiles.Count > 0)
{
_clipboard = osFiles.ToList();
_clipboardIsCut = cut;
}
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
if (_clipboardIsCut)
{
await _ops.MoveAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
_clipboard = [];
}
else
{
await _ops.CopyAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
}
}
[RelayCommand]
public Task DeleteAsync()
{
var paths = SelectedPaths();
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
}
[RelayCommand]
public void CopyPath()
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
CopyPathsToClipboard(paths);
}
[RelayCommand]
public void NewFolder()
{
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
_ops.NewFolder(ActivePane.CurrentPath);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
public void RenameSelected(string newName)
{
var item = ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
_ops.Rename(item.FullPath, newName);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
[RelayCommand]
public async Task BuildIndexAsync()
{
var path = ActivePane.CurrentPath;
if (path == "This PC")
{
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
}
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
Footer = "Select a drive or folder to index.";
return;
}
var source = await _sources.EnsureForPathAsync(path).ConfigureAwait(true);
if (source is null)
{
Footer = "This location could not be indexed.";
return;
}
ActivePane.CurrentSource = source;
ActivePane.ShowIndexBanner = false;
ActivePane.IndexBannerText = null;
_indexing.EnqueueFullScan(source.Id);
Footer = $"Indexing {source.DisplayName}…";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
[RelayCommand]
public void RescanFolder() => ActivePane.RescanFolder();
public void RefreshCloudActions()
{
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
[RelayCommand]
public Task UnpinCloudAsync() => InvokeCloudAsync(ProviderAction.Unpin);
[RelayCommand]
public Task FreeUpCloudSpaceAsync() => InvokeCloudAsync(ProviderAction.Dehydrate);
private async Task InvokeCloudAsync(ProviderAction action)
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
var result = await _providers.InvokeAsync(action, paths).ConfigureAwait(true);
Footer = result.Message ?? (result.Status == ProviderActionStatus.Succeeded
? "Asked the cloud client to update these items."
: "Cloud action was not available.");
await ActivePane.RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
public void CancelIndex()
{
if (ActivePane.CurrentSource is not null)
{
_indexing.Cancel(ActivePane.CurrentSource.Id);
}
}
[RelayCommand]
public async Task OpenStorageHereAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Analysis.Close();
}
[RelayCommand]
public async Task OpenStorageOtherAsync()
{
if (!ActiveTab.IsSplit || !Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
var other = ActiveTab.ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
ActiveTab.Activate(other);
await other.NavigateAsync(path).ConfigureAwait(true);
PathText = other.CurrentPath;
}
[RelayCommand]
public async Task OpenStorageTabAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await NewTabAsync().ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public async Task SearchStorageAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Search.Scope = SearchScopeKind.CurrentTree;
Search.OpenWithoutSearch();
Analysis.Close();
}
[RelayCommand]
public void RescanStorage()
{
if (Analysis.SelectedSourceId is not long id)
{
return;
}
if (Analysis.SelectedIsSource)
{
_indexing.EnqueueFullScan(id);
}
else
{
_indexing.EnqueueFolderScan(id, Analysis.SelectedScanPathRel);
}
Footer = "Queued an index rescan for this location.";
}
[RelayCommand]
public void CopyStoragePath()
{
if (Analysis.SelectedPath is { } path)
{
CopyPathsToClipboard([path]);
}
}
[RelayCommand]
public async Task AddNetworkAsync()
{
if (string.IsNullOrWhiteSpace(PromptUnc))
{
return;
}
await _sources.AddUncAsync(PromptUnc.Trim()).ConfigureAwait(true);
PromptUnc = "";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
public async Task AddCloudFolderAsync(string path, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
Footer = "Added OneDrive folder to the navigation tree.";
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
}
private const string OneDriveProviderId = "onedrive";
[RelayCommand]
public Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(Search.Text) && !Search.IsOpen)
{
Search.OpenWithoutSearch();
return Task.CompletedTask;
}
return Search.RunAsync(ActivePane);
}
[RelayCommand]
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
{
if (files.Count == 0)
{
return;
}
if (move)
{
await _ops.MoveAsync(files, targetDirectory).ConfigureAwait(true);
}
else
{
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
}
}
private void WireTab(ExplorerTabViewModel tab)
{
tab.Left.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.Right.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ExplorerTabViewModel.ActivePane) && tab == ActiveTab)
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
};
}
private void OnPaneProperty(ExplorerTabViewModel tab, string? propertyName)
{
if (tab != ActiveTab)
{
return;
}
if (propertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
if (!string.IsNullOrEmpty(dir))
{
dirs.Add(dir);
}
}
}
Add(job.SourcePath);
Add(job.DestinationPath);
foreach (var extra in job.AdditionalSources)
{
Add(extra);
}
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
}
await ActivePane.RefreshAsync().ConfigureAwait(true);
if (ActiveTab.IsSplit)
{
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
await other.RefreshAsync().ConfigureAwait(true);
}
}
private void EnqueueReconcile(string path)
{
_ = ReconcileAsync(path);
}
private async Task ReconcileAsync(string path)
{
try
{
var source = await _sources.FindByPathAsync(path).ConfigureAwait(true);
if (source is not { IsIndexed: true } || source.LastRootPath is null)
{
return;
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
_indexing.EnqueueReconcile(source.Id, rel);
}
catch
{
// local errors stay out of the UI
}
}
public void RememberEnteredPath(string path)
{
var next = PathHistoryStore.Remember(PathHistory, path);
if (next.Count == PathHistory.Count
&& PathHistory.Count > 0
&& string.Equals(PathHistory[0], path, StringComparison.OrdinalIgnoreCase))
{
return;
}
PathHistory.Clear();
foreach (var item in next)
{
PathHistory.Add(item);
}
_pathHistory.Save(PathHistory);
}
private List<string> SelectedPaths()
=> ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
private void CopyPathsToClipboard(IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return;
}
Clipboard.SetFiles(paths, _clipboardIsCut);
}
partial void OnActiveTabChanged(ExplorerTabViewModel value)
{
PathText = value.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
}

View File

@@ -0,0 +1,362 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed partial class NavNodeViewModel : ObservableObject
{
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private string _label = "";
[ObservableProperty] private string _path = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _isOffline;
[ObservableProperty] private bool _childrenLoaded;
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
}
public sealed class NavigationTreeViewModel
{
private readonly SourceManager _sources;
private readonly BrowseService _browse;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
public NavigationTreeViewModel(
SourceManager sources,
BrowseService browse,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
{
_sources = sources;
_browse = browse;
_providers = providers;
_cloudPlaces = cloudPlaces;
Roots = [];
}
public ObservableCollection<NavNodeViewModel> Roots { get; }
public bool IsRevealing { get; private set; }
public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default)
{
var expanded = new List<string>();
CollectExpanded(Roots, expanded);
var selected = FindSelected(Roots)?.Path;
var restore = revealPath ?? selected;
IsRevealing = true;
try
{
Roots.Clear();
var thisPc = new NavNodeViewModel { Label = "This PC", Path = "This PC", Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true };
Roots.Add(thisPc);
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
foreach (var source in sources)
{
var node = new NavNodeViewModel
{
Label = source.DisplayName,
Path = source.LastRootPath ?? source.DisplayName,
Status = source.Status switch
{
SourceStatus.Offline => source.LastSeenUtc is null
? "Offline"
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "May be out of date",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? "Indexed" : ""
},
IsOffline = source.Status == SourceStatus.Offline,
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind == SourceKind.Smb ? "\uE968" : "\uEDA2"
};
AddPlaceholder(node);
thisPc.Children.Add(node);
}
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
var exists = Directory.Exists(place.Path);
var node = new NavNodeViewModel
{
Label = place.DisplayName,
Path = place.Path,
Glyph = "\uE753",
Status = exists ? "" : "Offline",
IsOffline = !exists
};
AddPlaceholder(node);
Roots.Add(node);
}
foreach (var path in expanded)
{
var node = FindByPath(Roots, path);
if (node is null)
{
continue;
}
await EnsureChildrenAsync(node).ConfigureAwait(true);
node.IsExpanded = true;
}
if (!string.IsNullOrWhiteSpace(restore))
{
await RevealPathAsync(restore).ConfigureAwait(true);
}
}
finally
{
IsRevealing = false;
}
}
public async Task EnsureChildrenAsync(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.ChildrenLoaded || node.Path == "This PC")
{
return;
}
var listing = await _browse.ListAsync(node.Path).ConfigureAwait(true);
node.Children.Clear();
foreach (var dir in listing.Items.Where(i => i.IsDirectory).OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).Take(200))
{
var child = new NavNodeViewModel
{
Label = dir.Name,
Path = dir.FullPath
};
AddPlaceholder(child);
node.Children.Add(child);
}
node.ChildrenLoaded = true;
}
public async Task RevealPathAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
{
return;
}
var nested = IsRevealing;
IsRevealing = true;
try
{
if (PathsEqual(path, "This PC"))
{
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
if (thisPc is not null)
{
thisPc.IsExpanded = true;
SelectOnly(thisPc);
}
return;
}
var current = FindBestRoot(Roots, path);
if (current is null)
{
return;
}
current.IsExpanded = true;
await EnsureChildrenAsync(current).ConfigureAwait(true);
var remaining = PathRules.MakeRelative(current.Path, path);
if (!string.IsNullOrEmpty(remaining))
{
foreach (var segment in remaining.Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
var next = current.Children.FirstOrDefault(c =>
!c.IsPlaceholder && c.Label.Equals(segment, StringComparison.OrdinalIgnoreCase));
if (next is null)
{
break;
}
current = next;
current.IsExpanded = true;
await EnsureChildrenAsync(current).ConfigureAwait(true);
}
}
SelectOnly(current);
}
finally
{
if (!nested)
{
IsRevealing = false;
}
}
}
public static bool PathsEqual(string a, string b)
{
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var na = PathRules.FromExtended(a).TrimEnd('\\');
var nb = PathRules.FromExtended(b).TrimEnd('\\');
if (na.Equals(nb, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return string.Equals(
PathRules.EnsureDirectoryTrailingSlashIfRoot(na),
PathRules.EnsureDirectoryTrailingSlashIfRoot(nb),
StringComparison.OrdinalIgnoreCase);
}
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
{
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
NavNodeViewModel? best = null;
var bestLength = -1;
void Consider(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.Path == "This PC")
{
return;
}
var root = PathRules.FromExtended(node.Path).TrimEnd('\\');
if (normalized.Equals(root, StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
{
if (root.Length > bestLength)
{
best = node;
bestLength = root.Length;
}
}
}
foreach (var root in roots)
{
if (root.Path == "This PC")
{
foreach (var drive in root.Children.Where(c => !c.IsPlaceholder))
{
Consider(drive);
}
}
else
{
Consider(root);
}
}
return best;
}
private static void AddPlaceholder(NavNodeViewModel node)
{
if (node.Children.Count == 0)
{
node.Children.Add(new NavNodeViewModel { IsPlaceholder = true, ChildrenLoaded = true });
}
}
private static void CollectExpanded(IEnumerable<NavNodeViewModel> nodes, List<string> into)
{
foreach (var node in nodes)
{
if (node.IsPlaceholder)
{
continue;
}
if (node.IsExpanded)
{
into.Add(node.Path);
}
CollectExpanded(node.Children, into);
}
}
private static NavNodeViewModel? FindSelected(IEnumerable<NavNodeViewModel> nodes)
{
foreach (var node in nodes)
{
if (node.IsPlaceholder)
{
continue;
}
if (node.IsSelected)
{
return node;
}
var child = FindSelected(node.Children);
if (child is not null)
{
return child;
}
}
return null;
}
private static NavNodeViewModel? FindByPath(IEnumerable<NavNodeViewModel> nodes, string path)
{
foreach (var node in nodes)
{
if (node.IsPlaceholder)
{
continue;
}
if (PathsEqual(node.Path, path))
{
return node;
}
var child = FindByPath(node.Children, path);
if (child is not null)
{
return child;
}
}
return null;
}
private void SelectOnly(NavNodeViewModel target)
{
ClearSelection(Roots);
target.IsSelected = true;
}
private static void ClearSelection(IEnumerable<NavNodeViewModel> nodes)
{
foreach (var node in nodes)
{
if (node.IsSelected)
{
node.IsSelected = false;
}
ClearSelection(node.Children);
}
}
}

View File

@@ -0,0 +1,172 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Search;
namespace Explorer.Presentation.ViewModels;
public sealed record SearchScopeChoice(SearchScopeKind Kind, string Label);
public sealed partial class SearchViewModel : ObservableObject
{
private readonly SearchService _search;
private readonly SourceManager _sources;
private CancellationTokenSource? _runCts;
[ObservableProperty] private string _text = "";
[ObservableProperty] private SearchScopeKind _scope = SearchScopeKind.AllKnown;
[ObservableProperty] private string? _extension;
[ObservableProperty] private bool _foldersOnly;
[ObservableProperty] private bool _filesOnly;
[ObservableProperty] private string? _minSizeText;
[ObservableProperty] private string? _maxSizeText;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
public SearchViewModel(SearchService search, SourceManager sources)
{
_search = search;
_sources = sources;
Results = [];
}
public ObservableCollection<FolderItemViewModel> Results { get; }
public SearchScopeChoice[] ScopeChoices { get; } =
[
new(SearchScopeKind.CurrentFolder, "Current folder"),
new(SearchScopeKind.CurrentTree, "Current folder tree"),
new(SearchScopeKind.AllKnown, "All indexed locations"),
new(SearchScopeKind.OfflineMedia, "Offline media")
];
public void OpenWithoutSearch()
{
IsOpen = true;
IsBusy = false;
if (string.IsNullOrWhiteSpace(Status) || Status.StartsWith("Searching", StringComparison.Ordinal))
{
Status = "";
}
}
internal static bool? DirectoryFilter(bool foldersOnly, bool filesOnly)
=> foldersOnly == filesOnly ? null : foldersOnly;
[RelayCommand]
public async Task RunAsync(ExplorerPaneViewModel? pane, CancellationToken cancellationToken = default)
{
_runCts?.Cancel();
_runCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var ct = _runCts.Token;
IsOpen = true;
IsBusy = true;
Status = "Searching…";
Results.Clear();
try
{
var source = pane?.CurrentSource;
var sources = await _sources.RefreshOnlineStateAsync(ct).ConfigureAwait(true);
var indexed = sources.Where(s => s.IsIndexed).ToList();
var scope = Scope;
if (scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected
&& source is not { IsIndexed: true })
{
scope = SearchScopeKind.AllKnown;
}
IReadOnlyList<long>? sourceIds = scope switch
{
SearchScopeKind.AllKnown => indexed.Count == 0 ? sources.Select(s => s.Id).ToList() : null,
SearchScopeKind.OfflineMedia => sources.Where(s => s.Status == SourceStatus.Offline).Select(s => s.Id).ToList(),
SearchScopeKind.Sources when source is not null => [source.Id],
_ when source is not null => [source.Id],
_ => indexed.Select(s => s.Id).ToList()
};
string? prefix = null;
if (pane is not null && pane.CurrentPath != "This PC" && source is { IsIndexed: true, LastRootPath: not null }
&& scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected)
{
prefix = PathRules.MakeRelative(source.LastRootPath, pane.CurrentPath);
}
var query = new SearchQuery
{
Scope = scope,
Text = Text,
Extension = Extension,
IsDirectory = DirectoryFilter(FoldersOnly, FilesOnly),
MinSize = ParseSize(MinSizeText),
MaxSize = ParseSize(MaxSizeText),
SourceIds = sourceIds,
PathRelPrefix = prefix,
IncludeOffline = scope is SearchScopeKind.AllKnown or SearchScopeKind.OfflineMedia,
Take = AppConstants.SearchPageSize
};
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
var byId = sources.ToDictionary(s => s.Id);
foreach (var entry in entries)
{
byId.TryGetValue(entry.SourceId, out var src);
var full = src?.LastRootPath is null
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
: PathRules.Combine(src.LastRootPath, entry.PathRel);
Results.Add(new FolderItemViewModel(new FileSystemItem
{
FullPath = full,
Name = entry.Name,
IsDirectory = entry.IsDirectory,
SizeBytes = entry.IsDirectory ? entry.AggregateSize : entry.SizeBytes,
CreatedUtc = entry.CreatedUtc,
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
ReparseTag = entry.ReparseTag
}, entry.IsDirectory));
}
if (Results.Count > 0)
{
Status = $"{Results.Count} results";
}
else if (indexed.Count == 0)
{
Status = "Nothing indexed yet. Index a drive or folder, then search again.";
}
else
{
Status = "No results";
}
}
catch (OperationCanceledException)
{
Status = "Cancelled";
}
finally
{
IsBusy = false;
}
}
private static long? ParseSize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
text = text.Trim();
double mul = 1;
if (text.EndsWith("kb", StringComparison.OrdinalIgnoreCase)) { mul = 1024; text = text[..^2]; }
else if (text.EndsWith("mb", StringComparison.OrdinalIgnoreCase)) { mul = 1024 * 1024; text = text[..^2]; }
else if (text.EndsWith("gb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024; text = text[..^2]; }
else if (text.EndsWith("tb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024 * 1024; text = text[..^2]; }
return double.TryParse(text.Trim(), out var n) ? (long)(n * mul) : null;
}
}

View File

@@ -0,0 +1,60 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class TransferQueueViewModel : ObservableObject
{
private readonly TransferQueue _queue;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
public TransferQueueViewModel(TransferQueue queue)
{
_queue = queue;
Jobs = [];
_queue.Changed += (_, _) =>
{
if (_ui is { } ctx)
{
ctx.Post(_ => Reload(), null);
}
else
{
Reload();
}
};
Reload();
}
public ObservableCollection<TransferJob> Jobs { get; }
public bool HasJobs => Jobs.Count > 0;
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
else
{
_queue.Dismiss(job.Id);
}
}
private void Reload()
{
Jobs.Clear();
foreach (var job in _queue.Snapshot().Where(IsVisible))
{
Jobs.Add(job);
}
OnPropertyChanged(nameof(HasJobs));
}
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running
or TransferStatus.Cancelling or TransferStatus.Failed;
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Search</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Search;
public sealed class SearchQuery
{
public SearchScopeKind Scope { get; init; } = SearchScopeKind.CurrentTree;
public string? Text { get; init; }
public string? Extension { get; init; }
public bool? IsDirectory { get; init; }
public long? MinSize { get; init; }
public long? MaxSize { get; init; }
public DateTimeOffset? CreatedAfter { get; init; }
public DateTimeOffset? CreatedBefore { get; init; }
public DateTimeOffset? ModifiedAfter { get; init; }
public DateTimeOffset? ModifiedBefore { get; init; }
public IReadOnlyList<long>? SourceIds { get; init; }
public string? PathRelPrefix { get; init; }
public bool IncludeOffline { get; init; } = true;
public int Skip { get; init; }
public int Take { get; init; } = AppConstants.SearchPageSize;
}
public sealed class SearchService
{
private readonly IIndexStore _store;
public SearchService(IIndexStore store) => _store = store;
public Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchQuery query, CancellationToken cancellationToken = default)
{
var text = query.Text?.Trim();
string? extension = query.Extension;
string? name = text;
if (!string.IsNullOrEmpty(text) && text.StartsWith("*.", StringComparison.Ordinal) && !text.Contains(' ', StringComparison.Ordinal))
{
extension = text[2..];
name = null;
}
var request = new SearchRequest
{
Name = name,
Extension = extension,
IsDirectory = query.IsDirectory,
MinSize = query.MinSize,
MaxSize = query.MaxSize,
CreatedAfter = query.CreatedAfter,
CreatedBefore = query.CreatedBefore,
ModifiedAfter = query.ModifiedAfter,
ModifiedBefore = query.ModifiedBefore,
SourceIds = query.SourceIds,
PathRelPrefix = query.Scope is SearchScopeKind.CurrentTree or SearchScopeKind.CurrentFolder or SearchScopeKind.Selected
? query.PathRelPrefix
: null,
DirectChildrenOnly = query.Scope == SearchScopeKind.CurrentFolder,
IncludeOffline = query.IncludeOffline || query.Scope == SearchScopeKind.OfflineMedia,
Skip = query.Skip,
Take = query.Take
};
return _store.Search.SearchAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,334 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class AnalysisStore : IAnalysisStore
{
private const string EntryColumns = """
id, source_id, parent_id, name, path_rel, is_dir, size_bytes, aggregate_size,
child_file_count, child_dir_count
""";
private readonly SqliteIndexStore _store;
public AnalysisStore(SqliteIndexStore store) => _store = store;
public Task EnsureReadyAsync(CancellationToken cancellationToken = default)
=> _store.EnsureAnalysisIndexesAsync(cancellationToken);
public async Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleAsync<(long n, long gen, string? last)>(new CommandDefinition(
"SELECT COUNT(*) AS n, ifnull(SUM(scan_generation), 0) AS gen, ifnull(MAX(last_indexed_utc), '') AS last FROM sources",
cancellationToken: cancellationToken)).ConfigureAwait(false);
return HashCode.Combine(row.n, row.gen, row.last ?? "");
}
public async Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
$"""
SELECT {EntryColumns}
FROM entries
WHERE parent_id IS NULL AND is_dir=1
""",
cancellationToken: cancellationToken)).ConfigureAwait(false);
return Map(rows);
}
public async Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(long? sourceId, long? parentId, int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = $"""
SELECT {EntryColumns}
FROM entries
WHERE is_dir=1 AND status=0
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
sql += parentId is not null ? " AND parent_id=@parentId" : " AND parent_id IS NOT NULL";
sql += " ORDER BY aggregate_size DESC LIMIT @take";
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
sql, new { sourceId, parentId, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
return Map(rows);
}
public async Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = $"""
SELECT {EntryColumns}
FROM entries
WHERE is_dir=0 AND status=0
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
if (!string.IsNullOrEmpty(pathRelPrefix))
sql += " AND (path_rel=@pathRelPrefix OR path_rel LIKE @like ESCAPE '\\')";
sql += " ORDER BY size_bytes DESC LIMIT @take";
var like = string.IsNullOrEmpty(pathRelPrefix) ? null : pathRelPrefix.Replace("\\", "\\\\") + "\\\\%";
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
sql, new { sourceId, pathRelPrefix, like, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
return Map(rows);
}
public async Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = """
SELECT ifnull(extension, '') AS Extension, SUM(size_bytes) AS TotalSize, COUNT(*) AS FileCount
FROM entries
WHERE is_dir=0 AND status=0
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
if (!string.IsNullOrEmpty(pathRelPrefix))
sql += " AND (path_rel=@pathRelPrefix OR path_rel LIKE @like ESCAPE '\\')";
sql += " GROUP BY extension ORDER BY TotalSize DESC LIMIT @take";
var like = string.IsNullOrEmpty(pathRelPrefix) ? null : pathRelPrefix.Replace("\\", "\\\\") + "\\\\%";
var rows = await conn.QueryAsync<ExtensionUsage>(new CommandDefinition(
sql, new { sourceId, pathRelPrefix, like, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
return rows.AsList();
}
public async Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<SourceUsage>(new CommandDefinition(
"""
SELECT s.id AS SourceId, s.display_name AS DisplayName, s.status AS Status,
ifnull(r.aggregate_size, 0) AS TotalSize,
ifnull((
SELECT COUNT(*) FROM entries e
WHERE e.source_id=s.id AND e.is_dir=0 AND e.status=0
), 0) AS FileCount
FROM sources s
LEFT JOIN entries r ON r.source_id=s.id AND r.parent_id IS NULL
ORDER BY TotalSize DESC
""",
cancellationToken: cancellationToken)).ConfigureAwait(false);
return rows.AsList();
}
public async Task<IReadOnlyList<IndexEntry>> ChildrenBySizeAsync(long parentId, int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
$"""
SELECT {EntryColumns}
FROM entries
WHERE parent_id=@parentId AND status=0
ORDER BY CASE WHEN is_dir=1 THEN aggregate_size ELSE size_bytes END DESC
LIMIT @take
""",
new { parentId, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
return Map(rows);
}
private static IReadOnlyList<IndexEntry> Map(IEnumerable<AnalysisEntryRow> rows)
=> rows.Select(r => r.ToModel()).ToList();
}
internal sealed class AnalysisEntryRow
{
public long id { get; set; }
public long source_id { get; set; }
public long? parent_id { get; set; }
public string name { get; set; } = "";
public string path_rel { get; set; } = "";
public int is_dir { get; set; }
public long size_bytes { get; set; }
public long aggregate_size { get; set; }
public int child_file_count { get; set; }
public int child_dir_count { get; set; }
public IndexEntry ToModel() => new()
{
Id = id,
SourceId = source_id,
ParentId = parent_id,
Name = name,
NameNorm = name,
PathRel = path_rel,
IsDirectory = is_dir != 0,
SizeBytes = size_bytes,
AggregateSize = aggregate_size,
ChildFileCount = child_file_count,
ChildDirCount = child_dir_count,
LastSeenUtc = DateTimeOffset.UnixEpoch
};
}
internal sealed class HistoryStore : IHistoryStore
{
private readonly SqliteIndexStore _store;
public HistoryStore(SqliteIndexStore store) => _store = store;
public Task CaptureSourceSnapshotAsync(long sourceId, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
INSERT INTO source_stats_history (source_id, captured_utc, total_size, file_count, dir_count)
SELECT @sourceId, @utc,
ifnull((SELECT aggregate_size FROM entries WHERE source_id=@sourceId AND parent_id IS NULL), 0),
(SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND is_dir=0 AND status=0),
(SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND is_dir=1 AND status=0)
""", new { sourceId, utc = utc.ToString("O") }), cancellationToken);
public Task CaptureDirectorySnapshotsAsync(long sourceId, DateTimeOffset utc, long minSize, int topN, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
INSERT INTO directory_stats_history (source_id, path_rel, captured_utc, aggregate_size, file_count)
SELECT source_id, path_rel, @utc, aggregate_size, child_file_count
FROM entries
WHERE source_id=@sourceId AND is_dir=1 AND status=0 AND aggregate_size >= @minSize
ORDER BY aggregate_size DESC
LIMIT @topN
""", new { sourceId, utc = utc.ToString("O"), minSize, topN }), cancellationToken);
public async Task<IReadOnlyList<SourceSnapshot>> GetSourceHistoryAsync(long sourceId, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<(string captured_utc, long total_size, long file_count, long dir_count)>(
"SELECT captured_utc, total_size, file_count, dir_count FROM source_stats_history WHERE source_id=@sourceId ORDER BY captured_utc",
new { sourceId }).ConfigureAwait(false);
return rows.Select(r => new SourceSnapshot
{
CapturedUtc = DateTimeOffset.Parse(r.captured_utc),
TotalSize = r.total_size,
FileCount = r.file_count,
DirCount = r.dir_count
}).ToList();
}
}
internal sealed class HashStore : IHashStore
{
private readonly SqliteIndexStore _store;
public HashStore(SqliteIndexStore store) => _store = store;
public Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn =>
{
var sql = """
INSERT OR IGNORE INTO hash_queue (entry_id, size_bytes, priority, state)
SELECT e.id, e.size_bytes, CASE WHEN e.size_bytes > 104857600 THEN 1 ELSE 0 END, 'Pending'
FROM entries e
WHERE e.is_dir=0 AND e.status=0 AND e.size_bytes > 0
AND e.size_bytes IN (
SELECT size_bytes FROM entries
WHERE is_dir=0 AND status=0 AND size_bytes > 0
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
sql += """
GROUP BY size_bytes HAVING COUNT(*) > 1
)
""";
if (sourceId is not null) sql += " AND e.source_id=@sourceId";
return conn.ExecuteAsync(sql, new { sourceId });
}, cancellationToken);
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<HashWorkItem>("""
SELECT q.entry_id AS EntryId, q.size_bytes AS SizeBytes, q.state AS State,
e.source_id AS SourceId, e.path_rel AS PathRel, s.last_root_path AS RootPath, e.file_id AS FileId,
e.attributes AS Attributes, e.cloud_availability AS CloudAvailability
FROM hash_queue q
JOIN entries e ON e.id = q.entry_id
JOIN sources s ON s.id = e.source_id
WHERE q.state IN ('Pending','PartialDone')
ORDER BY q.priority, q.size_bytes
LIMIT @take
""", new { take }).ConfigureAwait(false);
return rows.ToList();
}
public Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
await conn.ExecuteAsync(
"UPDATE entries SET content_hash=@hash, hash_state=1 WHERE id=@entryId",
new { entryId, hash }).ConfigureAwait(false);
await conn.ExecuteAsync(
"UPDATE hash_queue SET state='PartialDone' WHERE entry_id=@entryId",
new { entryId }).ConfigureAwait(false);
}, cancellationToken);
public Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
await conn.ExecuteAsync(
"UPDATE entries SET content_hash=@hash, hash_state=2 WHERE id=@entryId",
new { entryId, hash }).ConfigureAwait(false);
await conn.ExecuteAsync(
"UPDATE hash_queue SET state='Done' WHERE entry_id=@entryId",
new { entryId }).ConfigureAwait(false);
}, cancellationToken);
public Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync(
"UPDATE hash_queue SET state='Unique' WHERE entry_id=@entryId",
new { entryId }), cancellationToken);
public async Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var count = await conn.ExecuteScalarAsync<long>("""
SELECT COUNT(*) FROM entries e
JOIN entries me ON me.id = @entryId
WHERE e.id != @entryId AND e.is_dir = 0 AND e.status = 0 AND e.size_bytes = @sizeBytes
AND e.hash_state >= 1 AND e.content_hash IS NOT NULL AND e.content_hash = me.content_hash
""", new { entryId, sizeBytes }).ConfigureAwait(false);
return count > 0;
}
public Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync(
"UPDATE hash_queue SET state='Error' WHERE entry_id=@entryId",
new { entryId }), cancellationToken);
public Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
await conn.ExecuteAsync(
"UPDATE entries SET hash_state=3 WHERE id=@entryId",
new { entryId }).ConfigureAwait(false);
await conn.ExecuteAsync(
"UPDATE hash_queue SET state='Skipped' WHERE entry_id=@entryId",
new { entryId }).ConfigureAwait(false);
}, cancellationToken);
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = """
SELECT * FROM entries
WHERE is_dir=0 AND status=0 AND hash_state=2 AND content_hash IS NOT NULL
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
if (!string.IsNullOrEmpty(pathPrefix))
sql += " AND (path_rel=@pathPrefix OR path_rel LIKE @like ESCAPE '\\')";
sql += " ORDER BY content_hash, file_id";
var like = string.IsNullOrEmpty(pathPrefix) ? null : pathPrefix.Replace("\\", "\\\\") + "\\\\%";
var rows = (await conn.QueryAsync<EntryRow>(sql, new { sourceId, pathPrefix, like }).ConfigureAwait(false))
.Select(r => r.ToModel())
.ToList();
return rows
.GroupBy(e => Convert.ToHexString(e.ContentHash!))
.Where(g => g.Count() > 1)
.Take(take)
.Select(g =>
{
var list = g.ToList();
var fileIds = list.Select(e => e.FileId).Where(id => id is > 0).Distinct().ToList();
return new DuplicateGroup
{
SizeBytes = list[0].SizeBytes,
Hash = list[0].ContentHash,
Entries = list,
SameFileId = fileIds.Count == 1 && list.All(e => e.FileId == fileIds[0])
};
})
.ToList();
}
}

View File

@@ -0,0 +1,40 @@
using Dapper;
using System.Data;
namespace Explorer.Storage.Sqlite;
internal sealed class EnumStringHandler<T> : SqlMapper.TypeHandler<T> where T : struct, Enum
{
public override void SetValue(IDbDataParameter parameter, T value)
=> parameter.Value = value.ToString();
public override T Parse(object value)
=> Enum.Parse<T>(Convert.ToString(value) ?? "", ignoreCase: true);
}
internal static class DapperSetup
{
private static int _done;
public static void Ensure()
{
if (Interlocked.Exchange(ref _done, 1) == 1)
{
return;
}
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.ScanKind>());
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.ScanJobStatus>());
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.SourceStatus>());
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
}
}
internal sealed class DateTimeOffsetHandler : SqlMapper.TypeHandler<DateTimeOffset?>
{
public override void SetValue(IDbDataParameter parameter, DateTimeOffset? value)
=> parameter.Value = value?.ToString("O") ?? (object)DBNull.Value;
public override DateTimeOffset? Parse(object value)
=> value is null or DBNull ? null : DateTimeOffset.Parse(Convert.ToString(value)!);
}

View File

@@ -0,0 +1,353 @@
using Dapper;
using Microsoft.Data.Sqlite;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class EntryStore : IEntryStore
{
private readonly SqliteIndexStore _store;
public EntryStore(SqliteIndexStore store) => _store = store;
public async Task<IndexEntry?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>("SELECT * FROM entries WHERE id=@id", new { id }).ConfigureAwait(false);
return row?.ToModel();
}
public async Task<IndexEntry?> GetRootAsync(long sourceId, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND parent_id IS NULL LIMIT 1",
new { sourceId }).ConfigureAwait(false);
return row?.ToModel();
}
public async Task<IndexEntry?> GetByPathAsync(long sourceId, string pathRel, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND path_rel=@pathRel COLLATE NOCASE LIMIT 1",
new { sourceId, pathRel }).ConfigureAwait(false);
return row?.ToModel();
}
public async Task<IndexEntry?> GetByParentNameAsync(long sourceId, long? parentId, string nameNorm, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) AND name_norm=@nameNorm LIMIT 1",
new { sourceId, parentId, nameNorm }).ConfigureAwait(false);
return row?.ToModel();
}
public async Task<IndexEntry?> GetByFileIdAsync(long sourceId, long fileId, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND file_id=@fileId AND status=0 LIMIT 1",
new { sourceId, fileId }).ConfigureAwait(false);
return row?.ToModel();
}
public async Task<IReadOnlyList<IndexEntry>> GetChildrenAsync(long sourceId, long? parentId, EntryStatus? status = EntryStatus.Present, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
IEnumerable<EntryRow> rows;
if (status is null)
{
rows = await conn.QueryAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) ORDER BY is_dir DESC, name_norm",
new { sourceId, parentId }).ConfigureAwait(false);
}
else
{
rows = await conn.QueryAsync<EntryRow>(
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) AND status=@st ORDER BY is_dir DESC, name_norm",
new { sourceId, parentId, st = (int)status.Value }).ConfigureAwait(false);
}
return rows.Select(r => r.ToModel()).ToList();
}
public Task UpsertBatchAsync(IReadOnlyList<IndexEntry> entries, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
foreach (var entry in entries)
{
await UpsertCore(conn, entry).ConfigureAwait(false);
}
}, cancellationToken);
public Task<long> UpsertAsync(IndexEntry entry, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => UpsertCore(conn, entry), cancellationToken);
internal static async Task<long> UpsertCore(SqliteConnection conn, IndexEntry entry)
{
var existing = await SqliteExec.ScalarAsync<long?>(
conn,
"SELECT id FROM entries WHERE source_id=@SourceId AND ifnull(parent_id,-1)=ifnull(@ParentId,-1) AND name_norm=@NameNorm",
new { entry.SourceId, entry.ParentId, entry.NameNorm }).ConfigureAwait(false);
if (existing is > 0)
{
entry.Id = existing.Value;
await SqliteExec.ExecuteAsync(conn, """
UPDATE entries SET
name=@Name, extension=@Extension, is_dir=@IsDir, size_bytes=@SizeBytes,
created_utc=@CreatedUtc, modified_utc=@ModifiedUtc, last_seen_utc=@LastSeenUtc,
last_indexed_utc=@LastIndexedUtc, attributes=@Attributes, file_id=@FileId,
parent_file_id=@ParentFileId, reparse_tag=@ReparseTag, status=@Status,
deleted_utc=@DeletedUtc, path_rel=@PathRel, scan_generation=@ScanGeneration,
allocated_size=@AllocatedSizeBytes, cloud_availability=@CloudAvailability
WHERE id=@Id
""", ToArgs(entry)).ConfigureAwait(false);
return entry.Id;
}
var id = await SqliteExec.InsertAsync(conn, """
INSERT INTO entries (source_id, parent_id, name, name_norm, extension, is_dir, size_bytes, aggregate_size,
child_file_count, child_dir_count, created_utc, modified_utc, last_seen_utc, last_indexed_utc,
attributes, file_id, parent_file_id, reparse_tag, status, deleted_utc, path_rel, content_hash, hash_state, scan_generation,
allocated_size, cloud_availability)
VALUES (@SourceId, @ParentId, @Name, @NameNorm, @Extension, @IsDir, @SizeBytes, @AggregateSize,
@ChildFileCount, @ChildDirCount, @CreatedUtc, @ModifiedUtc, @LastSeenUtc, @LastIndexedUtc,
@Attributes, @FileId, @ParentFileId, @ReparseTag, @Status, @DeletedUtc, @PathRel, @ContentHash, @HashState, @ScanGeneration,
@AllocatedSizeBytes, @CloudAvailability)
""", ToArgs(entry)).ConfigureAwait(false);
entry.Id = id;
return id;
}
public Task UpdateAggregatesAsync(long id, long aggregateSize, int files, int dirs, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"UPDATE entries SET aggregate_size=@aggregateSize, child_file_count=@files, child_dir_count=@dirs WHERE id=@id",
new { id, aggregateSize, files, dirs }), cancellationToken);
public Task ApplySizeDeltaToAncestorsAsync(long? parentId, long sizeDelta, int fileDelta, int dirDelta, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => ApplyDelta(conn, parentId, sizeDelta, fileDelta, dirDelta), cancellationToken);
internal static async Task ApplyDelta(SqliteConnection conn, long? parentId, long sizeDelta, int fileDelta, int dirDelta)
{
var current = parentId;
while (current is > 0)
{
await SqliteExec.ExecuteAsync(conn,
"UPDATE entries SET aggregate_size = aggregate_size + @sizeDelta, child_file_count = child_file_count + @fileDelta, child_dir_count = child_dir_count + @dirDelta WHERE id=@id",
new { id = current.Value, sizeDelta, fileDelta, dirDelta }).ConfigureAwait(false);
current = await SqliteExec.ScalarAsync<long?>(
conn, "SELECT parent_id FROM entries WHERE id=@id", new { id = current.Value }).ConfigureAwait(false);
}
}
public Task MarkMissingAsDeletedAsync(long sourceId, long generation, DateTimeOffset utc, string? pathRelPrefix, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var sql = """
UPDATE entries SET status=2, deleted_utc=@utc
WHERE source_id=@sourceId AND status=0 AND scan_generation < @generation
""";
if (!string.IsNullOrEmpty(pathRelPrefix))
{
sql += " AND (path_rel = @prefix OR path_rel LIKE @like ESCAPE '\\')";
}
await SqliteExec.ExecuteAsync(conn, sql, new
{
sourceId,
generation,
utc = utc.ToString("O"),
prefix = pathRelPrefix,
like = EscapeLike(pathRelPrefix) + "\\\\%"
}).ConfigureAwait(false);
}, cancellationToken);
public Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(
conn, "UPDATE entries SET status=1 WHERE source_id=@sourceId AND status=0",
new { sourceId }), cancellationToken);
public Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(
conn, "UPDATE entries SET status=0 WHERE source_id=@sourceId AND status=1",
new { sourceId }), cancellationToken);
public Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var isDir = await SqliteExec.ScalarAsync<long?>(conn, "SELECT is_dir FROM entries WHERE id=@id", new { id })
.ConfigureAwait(false);
if (isDir is null)
{
return;
}
var parentId = await SqliteExec.ScalarAsync<long?>(conn, "SELECT parent_id FROM entries WHERE id=@id", new { id })
.ConfigureAwait(false);
var size = await SqliteExec.ScalarAsync<long>(conn, "SELECT size_bytes FROM entries WHERE id=@id", new { id })
.ConfigureAwait(false);
var aggregate = await SqliteExec.ScalarAsync<long>(conn, "SELECT aggregate_size FROM entries WHERE id=@id", new { id })
.ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn,
"UPDATE entries SET status=2, deleted_utc=@utc WHERE id=@id",
new { id, utc = utc.ToString("O") }).ConfigureAwait(false);
if (isDir == 0)
{
await ApplyDelta(conn, parentId, -size, -1, 0).ConfigureAwait(false);
}
else
{
await ApplyDelta(conn, parentId, -aggregate, 0, -1).ConfigureAwait(false);
}
}, cancellationToken);
public Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"DELETE FROM entries WHERE status=2 AND deleted_utc IS NOT NULL AND deleted_utc < @cutoff",
new { cutoff = cutoffUtc.ToString("O") }), cancellationToken);
public Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (string.IsNullOrEmpty(oldPathRel))
{
return;
}
await SqliteExec.ExecuteAsync(conn,
"UPDATE entries SET path_rel=@newPathRel WHERE source_id=@sourceId AND path_rel=@oldPathRel",
new { sourceId, oldPathRel, newPathRel }).ConfigureAwait(false);
var like = EscapeLike(oldPathRel) + "\\\\%";
await SqliteExec.ExecuteAsync(conn, """
UPDATE entries
SET path_rel = @newPathRel || substr(path_rel, @oldLen)
WHERE source_id=@sourceId AND path_rel LIKE @like ESCAPE '\'
""", new
{
sourceId,
newPathRel,
oldLen = oldPathRel.Length + 1,
like
}).ConfigureAwait(false);
}, cancellationToken);
public async Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
return await conn.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND status=0",
new { sourceId }).ConfigureAwait(false);
}
private static string EscapeLike(string? value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
}
private static object ToArgs(IndexEntry e) => new
{
e.Id,
e.SourceId,
e.ParentId,
e.Name,
e.NameNorm,
e.Extension,
IsDir = e.IsDirectory ? 1 : 0,
e.SizeBytes,
e.AggregateSize,
e.ChildFileCount,
e.ChildDirCount,
CreatedUtc = e.CreatedUtc?.ToString("O"),
ModifiedUtc = e.ModifiedUtc?.ToString("O"),
LastSeenUtc = e.LastSeenUtc.ToString("O"),
LastIndexedUtc = e.LastIndexedUtc?.ToString("O"),
e.Attributes,
e.FileId,
e.ParentFileId,
e.ReparseTag,
Status = (int)e.Status,
DeletedUtc = e.DeletedUtc?.ToString("O"),
e.PathRel,
e.ContentHash,
HashState = (int)e.HashState,
e.ScanGeneration,
e.AllocatedSizeBytes,
CloudAvailability = e.CloudAvailability is { } a ? (int?)a : null
};
}
internal sealed class EntryRow
{
public long id { get; set; }
public long source_id { get; set; }
public long? parent_id { get; set; }
public string name { get; set; } = "";
public string name_norm { get; set; } = "";
public string? extension { get; set; }
public int is_dir { get; set; }
public long size_bytes { get; set; }
public long aggregate_size { get; set; }
public int child_file_count { get; set; }
public int child_dir_count { get; set; }
public string? created_utc { get; set; }
public string? modified_utc { get; set; }
public string last_seen_utc { get; set; } = "";
public string? last_indexed_utc { get; set; }
public int attributes { get; set; }
public long? file_id { get; set; }
public long? parent_file_id { get; set; }
public int reparse_tag { get; set; }
public int status { get; set; }
public string? deleted_utc { get; set; }
public string path_rel { get; set; } = "";
public byte[]? content_hash { get; set; }
public int hash_state { get; set; }
public long scan_generation { get; set; }
public long? allocated_size { get; set; }
public int? cloud_availability { get; set; }
public IndexEntry ToModel() => new()
{
Id = id,
SourceId = source_id,
ParentId = parent_id,
Name = name,
NameNorm = name_norm,
Extension = extension,
IsDirectory = is_dir != 0,
SizeBytes = size_bytes,
AggregateSize = aggregate_size,
ChildFileCount = child_file_count,
ChildDirCount = child_dir_count,
CreatedUtc = Parse(created_utc),
ModifiedUtc = Parse(modified_utc),
LastSeenUtc = DateTimeOffset.Parse(last_seen_utc),
LastIndexedUtc = Parse(last_indexed_utc),
Attributes = attributes,
FileId = file_id,
ParentFileId = parent_file_id,
ReparseTag = reparse_tag,
Status = (EntryStatus)status,
DeletedUtc = Parse(deleted_utc),
PathRel = path_rel,
ContentHash = content_hash,
HashState = (HashState)hash_state,
ScanGeneration = scan_generation,
AllocatedSizeBytes = allocated_size,
CloudAvailability = cloud_availability is { } a ? (CloudAvailability)a : null
};
private static DateTimeOffset? Parse(string? v)
=> string.IsNullOrEmpty(v) ? null : DateTimeOffset.Parse(v);
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Storage.Sqlite</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schema\V1.sql" LogicalName="Explorer.Storage.Sqlite.Schema.V1.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,182 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class ExcludeStore : IExcludeStore
{
private readonly SqliteIndexStore _store;
public ExcludeStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<ExcludeRule>> GetAllAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<(long id, string? scope, long? source_id, string kind, string pattern, int enabled)>(
"SELECT id, scope, source_id, kind, pattern, enabled FROM excludes").ConfigureAwait(false);
return rows.Select(r => new ExcludeRule
{
Id = r.id,
Scope = r.scope ?? "global",
SourceId = r.source_id,
Kind = Enum.Parse<ExcludeKind>(r.kind),
Pattern = r.pattern,
Enabled = r.enabled != 0
}).ToList();
}
public Task<long> AddAsync(ExcludeRule rule, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
await conn.ExecuteAsync("""
INSERT OR IGNORE INTO excludes (scope, source_id, kind, pattern, enabled)
VALUES (@Scope, @SourceId, @Kind, @Pattern, @Enabled)
""", new
{
rule.Scope,
rule.SourceId,
Kind = rule.Kind.ToString(),
rule.Pattern,
Enabled = rule.Enabled ? 1 : 0
}).ConfigureAwait(false);
return await conn.ExecuteScalarAsync<long>("""
SELECT id FROM excludes
WHERE ifnull(source_id,-1)=ifnull(@SourceId,-1) AND kind=@Kind AND pattern=@Pattern
""", new
{
rule.SourceId,
Kind = rule.Kind.ToString(),
rule.Pattern
}).ConfigureAwait(false);
}, cancellationToken);
public Task RemoveAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM excludes WHERE id=@id", new { id }), cancellationToken);
public Task EnsureDefaultsAsync(IReadOnlyList<ExcludeRule> defaults, CancellationToken cancellationToken = default)
=> _store.RunWriteAsync(async _ =>
{
foreach (var rule in defaults)
{
await AddAsync(rule, cancellationToken).ConfigureAwait(false);
}
}, cancellationToken);
}
internal sealed class ScanJobStore : IScanJobStore
{
private readonly SqliteIndexStore _store;
public ScanJobStore(SqliteIndexStore store) => _store = store;
public Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO scan_jobs (source_id, kind, status, started_utc, finished_utc, files_seen, dirs_seen, bytes_seen, resume_path, error_count, last_error, folder_path_rel)
VALUES (@SourceId, @Kind, @Status, @StartedUtc, @FinishedUtc, @FilesSeen, @DirsSeen, @BytesSeen, @ResumePath, @ErrorCount, @LastError, @FolderPathRel);
""", Args(job)).ConfigureAwait(false);
job.Id = id;
return id;
}, cancellationToken);
public Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
UPDATE scan_jobs SET status=@Status, started_utc=@StartedUtc, finished_utc=@FinishedUtc,
files_seen=@FilesSeen, dirs_seen=@DirsSeen, bytes_seen=@BytesSeen, resume_path=@ResumePath,
error_count=@ErrorCount, last_error=@LastError, folder_path_rel=@FolderPathRel
WHERE id=@Id
""", Args(job)), cancellationToken);
public Task InterruptRunningAsync(CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
WHERE status IN ('Running','Queued')
""", new { utc = DateTimeOffset.UtcNow.ToString("O") }), cancellationToken);
public Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
INSERT INTO scan_errors (job_id, path, kind, message, utc)
VALUES (@JobId, @Path, @Kind, @Message, @Utc)
""", new
{
error.JobId,
error.Path,
error.Kind,
error.Message,
Utc = error.Utc.ToString("O")
}), cancellationToken);
public async Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
return await conn.QuerySingleOrDefaultAsync<ScanJob>(
"SELECT id AS Id, source_id AS SourceId, kind AS Kind, status AS Status, started_utc AS StartedUtc, finished_utc AS FinishedUtc, files_seen AS FilesSeen, dirs_seen AS DirsSeen, bytes_seen AS BytesSeen, resume_path AS ResumePath, error_count AS ErrorCount, last_error AS LastError, folder_path_rel AS FolderPathRel FROM scan_jobs WHERE id=@id",
new { id }).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<ScanJob>(
"SELECT id AS Id, source_id AS SourceId, kind AS Kind, status AS Status, started_utc AS StartedUtc, finished_utc AS FinishedUtc, files_seen AS FilesSeen, dirs_seen AS DirsSeen, bytes_seen AS BytesSeen, resume_path AS ResumePath, error_count AS ErrorCount, last_error AS LastError, folder_path_rel AS FolderPathRel FROM scan_jobs ORDER BY id DESC LIMIT @take",
new { take }).ConfigureAwait(false);
return rows.ToList();
}
private static object Args(ScanJob j) => new
{
j.Id,
j.SourceId,
Kind = j.Kind.ToString(),
Status = j.Status.ToString(),
StartedUtc = j.StartedUtc?.ToString("O"),
FinishedUtc = j.FinishedUtc?.ToString("O"),
j.FilesSeen,
j.DirsSeen,
j.BytesSeen,
j.ResumePath,
j.ErrorCount,
j.LastError,
j.FolderPathRel
};
}
internal sealed class TransferStore : ITransferStore
{
private readonly SqliteIndexStore _store;
public TransferStore(SqliteIndexStore store) => _store = store;
public Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO transfer_jobs (op, src, dst, status, bytes_total, bytes_done, created_utc, error)
VALUES (@Op, @Src, @Dst, @Status, @BytesTotal, @BytesDone, @CreatedUtc, @Error);
""", new
{
Op = job.Op.ToString(),
Src = job.SourcePath,
Dst = job.DestinationPath,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
CreatedUtc = job.CreatedUtc.ToString("O"),
job.Error
}).ConfigureAwait(false);
job.Id = id;
return id;
}, cancellationToken);
public Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
UPDATE transfer_jobs SET status=@Status, bytes_total=@BytesTotal, bytes_done=@BytesDone, error=@Error
WHERE id=@Id
""", new
{
job.Id,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
job.Error
}), cancellationToken);
}

View File

@@ -0,0 +1,170 @@
CREATE TABLE sources (
id INTEGER PRIMARY KEY,
stable_key TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
display_name TEXT NOT NULL,
volume_guid TEXT,
volume_serial INTEGER,
filesystem TEXT,
label TEXT,
capacity_bytes INTEGER,
device_instance_id TEXT,
last_root_path TEXT,
status TEXT NOT NULL,
last_seen_utc TEXT,
last_indexed_utc TEXT,
usn_journal_id INTEGER,
usn_next INTEGER,
scan_generation INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE TABLE entries (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES entries(id),
name TEXT NOT NULL,
name_norm TEXT NOT NULL,
extension TEXT,
is_dir INTEGER NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
aggregate_size INTEGER NOT NULL DEFAULT 0,
child_file_count INTEGER NOT NULL DEFAULT 0,
child_dir_count INTEGER NOT NULL DEFAULT 0,
created_utc TEXT,
modified_utc TEXT,
last_seen_utc TEXT NOT NULL,
last_indexed_utc TEXT,
attributes INTEGER NOT NULL DEFAULT 0,
file_id INTEGER,
parent_file_id INTEGER,
reparse_tag INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
deleted_utc TEXT,
path_rel TEXT NOT NULL,
content_hash BLOB,
hash_state INTEGER NOT NULL DEFAULT 0,
scan_generation INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ix_entries_identity ON entries(source_id, ifnull(parent_id, -1), name_norm);
CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status);
CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0;
CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0;
CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc);
CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL;
CREATE INDEX ix_entries_path ON entries(source_id, path_rel);
CREATE INDEX ix_entries_status ON entries(source_id, status);
CREATE INDEX IF NOT EXISTS ix_entries_dir_agg ON entries(source_id, aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_entries_dir_agg_all ON entries(aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_entries_file_size_all ON entries(size_bytes DESC) WHERE is_dir = 0 AND status = 0;
CREATE INDEX IF NOT EXISTS ix_entries_ext_usage ON entries(source_id, extension, size_bytes) WHERE is_dir = 0 AND status = 0;
CREATE INDEX IF NOT EXISTS ix_entries_ext_usage_all ON entries(extension, size_bytes) WHERE is_dir = 0 AND status = 0;
CREATE VIRTUAL TABLE entries_fts USING fts5(
name,
name_norm,
extension,
content = 'entries',
content_rowid = 'id',
tokenize = 'unicode61'
);
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
INSERT INTO entries_fts(rowid, name, name_norm, extension)
VALUES (new.id, new.name, new.name_norm, new.extension);
END;
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
END;
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
INSERT INTO entries_fts(rowid, name, name_norm, extension)
VALUES (new.id, new.name, new.name_norm, new.extension);
END;
CREATE TABLE excludes (
id INTEGER PRIMARY KEY,
scope TEXT,
source_id INTEGER REFERENCES sources(id),
kind TEXT NOT NULL,
pattern TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX ix_excludes_pattern ON excludes(ifnull(source_id, -1), kind, pattern);
CREATE TABLE scan_jobs (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
started_utc TEXT,
finished_utc TEXT,
files_seen INTEGER NOT NULL DEFAULT 0,
dirs_seen INTEGER NOT NULL DEFAULT 0,
bytes_seen INTEGER NOT NULL DEFAULT 0,
resume_path TEXT,
error_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
folder_path_rel TEXT
);
CREATE TABLE scan_errors (
id INTEGER PRIMARY KEY,
job_id INTEGER NOT NULL,
path TEXT,
kind TEXT,
message TEXT,
utc TEXT NOT NULL
);
CREATE TABLE transfer_jobs (
id INTEGER PRIMARY KEY,
op TEXT NOT NULL,
src TEXT NOT NULL,
dst TEXT,
status TEXT NOT NULL,
bytes_total INTEGER,
bytes_done INTEGER,
created_utc TEXT NOT NULL,
error TEXT
);
CREATE TABLE hash_queue (
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
size_bytes INTEGER NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL
);
CREATE TABLE source_stats_history (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
captured_utc TEXT NOT NULL,
total_size INTEGER NOT NULL,
file_count INTEGER NOT NULL,
dir_count INTEGER NOT NULL
);
CREATE TABLE directory_stats_history (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
path_rel TEXT NOT NULL,
captured_utc TEXT NOT NULL,
aggregate_size INTEGER NOT NULL,
file_count INTEGER NOT NULL
);
CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30');

View File

@@ -0,0 +1,196 @@
namespace Explorer.Storage.Sqlite;
internal static class SchemaScript
{
public static readonly string[] AnalysisIndexes =
[
"CREATE INDEX IF NOT EXISTS ix_entries_dir_agg ON entries(source_id, aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS ix_entries_dir_agg_all ON entries(aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS ix_entries_file_size_all ON entries(size_bytes DESC) WHERE is_dir = 0 AND status = 0",
"CREATE INDEX IF NOT EXISTS ix_entries_ext_usage ON entries(source_id, extension, size_bytes) WHERE is_dir = 0 AND status = 0",
"CREATE INDEX IF NOT EXISTS ix_entries_ext_usage_all ON entries(extension, size_bytes) WHERE is_dir = 0 AND status = 0"
];
public static readonly string[] Statements =
[
"""
CREATE TABLE sources (
id INTEGER PRIMARY KEY,
stable_key TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
display_name TEXT NOT NULL,
volume_guid TEXT,
volume_serial INTEGER,
filesystem TEXT,
label TEXT,
capacity_bytes INTEGER,
device_instance_id TEXT,
last_root_path TEXT,
status TEXT NOT NULL,
last_seen_utc TEXT,
last_indexed_utc TEXT,
usn_journal_id INTEGER,
usn_next INTEGER,
scan_generation INTEGER NOT NULL DEFAULT 0,
last_error TEXT
)
""",
"""
CREATE TABLE entries (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES entries(id),
name TEXT NOT NULL,
name_norm TEXT NOT NULL,
extension TEXT,
is_dir INTEGER NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
aggregate_size INTEGER NOT NULL DEFAULT 0,
child_file_count INTEGER NOT NULL DEFAULT 0,
child_dir_count INTEGER NOT NULL DEFAULT 0,
created_utc TEXT,
modified_utc TEXT,
last_seen_utc TEXT NOT NULL,
last_indexed_utc TEXT,
attributes INTEGER NOT NULL DEFAULT 0,
file_id INTEGER,
parent_file_id INTEGER,
reparse_tag INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
deleted_utc TEXT,
path_rel TEXT NOT NULL,
content_hash BLOB,
hash_state INTEGER NOT NULL DEFAULT 0,
scan_generation INTEGER NOT NULL DEFAULT 0,
allocated_size INTEGER,
cloud_availability INTEGER
)
""",
"CREATE UNIQUE INDEX ix_entries_identity ON entries(source_id, ifnull(parent_id, -1), name_norm)",
"CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status)",
"CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0",
"CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0",
"CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc)",
"CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL",
"CREATE INDEX ix_entries_path ON entries(source_id, path_rel)",
"CREATE INDEX ix_entries_status ON entries(source_id, status)",
..AnalysisIndexes,
"""
CREATE VIRTUAL TABLE entries_fts USING fts5(
name,
name_norm,
extension,
content = 'entries',
content_rowid = 'id',
tokenize = 'unicode61'
)
""",
"""
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
INSERT INTO entries_fts(rowid, name, name_norm, extension)
VALUES (new.id, new.name, new.name_norm, new.extension);
END
""",
"""
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
END
""",
"""
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
INSERT INTO entries_fts(rowid, name, name_norm, extension)
VALUES (new.id, new.name, new.name_norm, new.extension);
END
""",
"""
CREATE TABLE excludes (
id INTEGER PRIMARY KEY,
scope TEXT,
source_id INTEGER REFERENCES sources(id),
kind TEXT NOT NULL,
pattern TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
)
""",
"CREATE UNIQUE INDEX ix_excludes_pattern ON excludes(ifnull(source_id, -1), kind, pattern)",
"""
CREATE TABLE scan_jobs (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
started_utc TEXT,
finished_utc TEXT,
files_seen INTEGER NOT NULL DEFAULT 0,
dirs_seen INTEGER NOT NULL DEFAULT 0,
bytes_seen INTEGER NOT NULL DEFAULT 0,
resume_path TEXT,
error_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
folder_path_rel TEXT
)
""",
"""
CREATE TABLE scan_errors (
id INTEGER PRIMARY KEY,
job_id INTEGER NOT NULL,
path TEXT,
kind TEXT,
message TEXT,
utc TEXT NOT NULL
)
""",
"""
CREATE TABLE transfer_jobs (
id INTEGER PRIMARY KEY,
op TEXT NOT NULL,
src TEXT NOT NULL,
dst TEXT,
status TEXT NOT NULL,
bytes_total INTEGER,
bytes_done INTEGER,
created_utc TEXT NOT NULL,
error TEXT
)
""",
"""
CREATE TABLE hash_queue (
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
size_bytes INTEGER NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL
)
""",
"""
CREATE TABLE source_stats_history (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
captured_utc TEXT NOT NULL,
total_size INTEGER NOT NULL,
file_count INTEGER NOT NULL,
dir_count INTEGER NOT NULL
)
""",
"""
CREATE TABLE directory_stats_history (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
path_rel TEXT NOT NULL,
captured_utc TEXT NOT NULL,
aggregate_size INTEGER NOT NULL,
file_count INTEGER NOT NULL
)
""",
"CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc)",
"""
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""",
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')"
];
}

View File

@@ -0,0 +1,153 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class SearchStore : ISearchStore
{
private readonly SqliteIndexStore _store;
public SearchStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = new System.Text.StringBuilder("""
SELECT e.* FROM entries e
WHERE 1=1
""");
var args = new DynamicParameters();
if (!request.IncludeDeleted)
{
sql.Append(request.IncludeOffline
? " AND e.status IN (0, 1)"
: " AND e.status = 0");
}
if (request.SourceIds is { Count: > 0 })
{
sql.Append(" AND e.source_id IN @SourceIds");
args.Add("SourceIds", request.SourceIds);
}
if (!string.IsNullOrEmpty(request.PathRelPrefix) || request.DirectChildrenOnly)
{
if (request.DirectChildrenOnly)
{
sql.Append("""
AND e.parent_id = (
SELECT p.id FROM entries p
WHERE p.source_id = e.source_id AND p.path_rel = @Prefix
LIMIT 1)
""");
args.Add("Prefix", request.PathRelPrefix ?? "");
}
else if (!string.IsNullOrEmpty(request.PathRelPrefix))
{
sql.Append(" AND (e.path_rel = @Prefix OR e.path_rel LIKE @PrefixLike ESCAPE '\\')");
args.Add("Prefix", request.PathRelPrefix);
args.Add("PrefixLike", EscapeLike(request.PathRelPrefix) + "\\\\%");
}
}
if (request.IsDirectory is not null)
{
sql.Append(" AND e.is_dir = @IsDir");
args.Add("IsDir", request.IsDirectory.Value ? 1 : 0);
}
if (!string.IsNullOrEmpty(request.Extension))
{
sql.Append(" AND e.extension = @Ext");
args.Add("Ext", NameNormalizer.Normalize(request.Extension.TrimStart('.')));
}
if (request.MinSize is not null)
{
sql.Append(" AND e.size_bytes >= @MinSize");
args.Add("MinSize", request.MinSize);
}
if (request.MaxSize is not null)
{
sql.Append(" AND e.size_bytes <= @MaxSize");
args.Add("MaxSize", request.MaxSize);
}
if (request.CreatedAfter is not null)
{
sql.Append(" AND e.created_utc >= @CreatedAfter");
args.Add("CreatedAfter", request.CreatedAfter.Value.ToString("O"));
}
if (request.CreatedBefore is not null)
{
sql.Append(" AND e.created_utc <= @CreatedBefore");
args.Add("CreatedBefore", request.CreatedBefore.Value.ToString("O"));
}
if (request.ModifiedAfter is not null)
{
sql.Append(" AND e.modified_utc >= @ModifiedAfter");
args.Add("ModifiedAfter", request.ModifiedAfter.Value.ToString("O"));
}
if (request.ModifiedBefore is not null)
{
sql.Append(" AND e.modified_utc <= @ModifiedBefore");
args.Add("ModifiedBefore", request.ModifiedBefore.Value.ToString("O"));
}
if (!string.IsNullOrWhiteSpace(request.Name))
{
var name = request.Name.Trim();
if (name.Contains('*', StringComparison.Ordinal) || name.Contains('?', StringComparison.Ordinal))
{
sql.Append(" AND (e.name_norm LIKE @Glob ESCAPE '\\' OR e.name LIKE @Glob ESCAPE '\\')");
args.Add("Glob", GlobToLike(name));
}
else
{
sql.Append(" AND e.id IN (SELECT rowid FROM entries_fts WHERE entries_fts MATCH @Fts)");
args.Add("Fts", ToFtsQuery(name));
}
}
sql.Append(" ORDER BY e.is_dir DESC, e.name_norm LIMIT @Take OFFSET @Skip");
args.Add("Take", Math.Clamp(request.Take, 1, AppConstants.SearchHardLimit));
args.Add("Skip", Math.Max(0, request.Skip));
var rows = await conn.QueryAsync<EntryRow>(sql.ToString(), args).ConfigureAwait(false);
return rows.Select(r => r.ToModel()).ToList();
}
internal static string ToFtsQuery(string name)
{
var token = name.Replace("\"", "\"\"", StringComparison.Ordinal).Trim();
if (token.Length == 0)
{
return "\"\"";
}
if (token.Any(ch => !char.IsLetterOrDigit(ch)))
{
return $"\"{token}\"*";
}
return $"{token}*";
}
internal static string GlobToLike(string glob)
{
var escaped = glob.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
return escaped.Replace("*", "%", StringComparison.Ordinal).Replace("?", "_", StringComparison.Ordinal);
}
private static string EscapeLike(string value)
=> value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,182 @@
using Dapper;
using Microsoft.Data.Sqlite;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class SourceStore : ISourceStore
{
private readonly SqliteIndexStore _store;
public SourceStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<SourceRow>("SELECT * FROM sources ORDER BY display_name").ConfigureAwait(false);
return rows.Select(r => r.ToModel()).ToList();
}
public async Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM sources WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return null;
}
return new SourceRow
{
id = reader.GetInt64(reader.GetOrdinal("id")),
stable_key = reader.GetString(reader.GetOrdinal("stable_key")),
kind = reader.GetString(reader.GetOrdinal("kind")),
display_name = reader.GetString(reader.GetOrdinal("display_name")),
volume_guid = reader.IsDBNull(reader.GetOrdinal("volume_guid")) ? null : reader.GetString(reader.GetOrdinal("volume_guid")),
volume_serial = reader.IsDBNull(reader.GetOrdinal("volume_serial")) ? null : reader.GetInt64(reader.GetOrdinal("volume_serial")),
filesystem = reader.IsDBNull(reader.GetOrdinal("filesystem")) ? null : reader.GetString(reader.GetOrdinal("filesystem")),
label = reader.IsDBNull(reader.GetOrdinal("label")) ? null : reader.GetString(reader.GetOrdinal("label")),
capacity_bytes = reader.IsDBNull(reader.GetOrdinal("capacity_bytes")) ? null : reader.GetInt64(reader.GetOrdinal("capacity_bytes")),
device_instance_id = reader.IsDBNull(reader.GetOrdinal("device_instance_id")) ? null : reader.GetString(reader.GetOrdinal("device_instance_id")),
last_root_path = reader.IsDBNull(reader.GetOrdinal("last_root_path")) ? null : reader.GetString(reader.GetOrdinal("last_root_path")),
status = reader.GetString(reader.GetOrdinal("status")),
last_seen_utc = reader.IsDBNull(reader.GetOrdinal("last_seen_utc")) ? null : reader.GetString(reader.GetOrdinal("last_seen_utc")),
last_indexed_utc = reader.IsDBNull(reader.GetOrdinal("last_indexed_utc")) ? null : reader.GetString(reader.GetOrdinal("last_indexed_utc")),
usn_journal_id = reader.IsDBNull(reader.GetOrdinal("usn_journal_id")) ? null : reader.GetInt64(reader.GetOrdinal("usn_journal_id")),
usn_next = reader.IsDBNull(reader.GetOrdinal("usn_next")) ? null : reader.GetInt64(reader.GetOrdinal("usn_next")),
scan_generation = reader.GetInt64(reader.GetOrdinal("scan_generation")),
last_error = reader.IsDBNull(reader.GetOrdinal("last_error")) ? null : reader.GetString(reader.GetOrdinal("last_error"))
}.ToModel();
}
public async Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QuerySingleOrDefaultAsync<SourceRow>("SELECT * FROM sources WHERE stable_key = @key", new { key }).ConfigureAwait(false);
return row?.ToModel();
}
public Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (source.Id == 0)
{
const string insert = """
INSERT INTO sources (stable_key, kind, display_name, volume_guid, volume_serial, filesystem, label,
capacity_bytes, device_instance_id, last_root_path, status, last_seen_utc, last_indexed_utc,
usn_journal_id, usn_next, scan_generation, last_error)
VALUES (@StableKey, @Kind, @DisplayName, @VolumeGuid, @VolumeSerial, @Filesystem, @Label,
@CapacityBytes, @DeviceInstanceId, @LastRootPath, @Status, @LastSeenUtc, @LastIndexedUtc,
@UsnJournalId, @UsnNext, @ScanGeneration, @LastError);
""";
var id = await SqliteInsert.ExecuteAsync(conn, insert, ToArgs(source)).ConfigureAwait(false);
source.Id = id;
return id;
}
const string update = """
UPDATE sources SET
kind=@Kind, display_name=@DisplayName, volume_guid=@VolumeGuid, volume_serial=@VolumeSerial,
filesystem=@Filesystem, label=@Label, capacity_bytes=@CapacityBytes,
device_instance_id=@DeviceInstanceId, last_root_path=@LastRootPath, status=@Status,
last_seen_utc=@LastSeenUtc, last_indexed_utc=@LastIndexedUtc, usn_journal_id=@UsnJournalId,
usn_next=@UsnNext, scan_generation=@ScanGeneration, last_error=@LastError
WHERE id=@Id;
""";
await SqliteExec.ExecuteAsync(conn, update, ToArgs(source)).ConfigureAwait(false);
return source.Id;
}, cancellationToken);
public Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"UPDATE sources SET status=@status, last_error=@error WHERE id=@id",
new { id, status = status.ToString(), error }), cancellationToken);
public Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"UPDATE sources SET usn_journal_id=@journalId, usn_next=@nextUsn WHERE id=@id",
new { id, journalId, nextUsn }), cancellationToken);
public Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"UPDATE sources SET last_indexed_utc=@utc, scan_generation=@generation, status='Online', last_error=NULL WHERE id=@id",
new { id, utc = utc.ToString("O"), generation }), cancellationToken);
public Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
"UPDATE sources SET last_root_path=@rootPath, last_seen_utc=@utc WHERE id=@id",
new { id, rootPath, utc = utc.ToString("O") }), cancellationToken);
private static object ToArgs(Source s) => new
{
s.Id,
s.StableKey,
Kind = s.Kind.ToString(),
s.DisplayName,
s.VolumeGuid,
s.VolumeSerial,
s.Filesystem,
s.Label,
s.CapacityBytes,
s.DeviceInstanceId,
s.LastRootPath,
Status = s.Status.ToString(),
LastSeenUtc = s.LastSeenUtc?.ToString("O"),
LastIndexedUtc = s.LastIndexedUtc?.ToString("O"),
s.UsnJournalId,
s.UsnNext,
s.ScanGeneration,
s.LastError
};
private sealed class SourceRow
{
public long id { get; set; }
public string stable_key { get; set; } = "";
public string kind { get; set; } = "";
public string display_name { get; set; } = "";
public string? volume_guid { get; set; }
public long? volume_serial { get; set; }
public string? filesystem { get; set; }
public string? label { get; set; }
public long? capacity_bytes { get; set; }
public string? device_instance_id { get; set; }
public string? last_root_path { get; set; }
public string status { get; set; } = "";
public string? last_seen_utc { get; set; }
public string? last_indexed_utc { get; set; }
public long? usn_journal_id { get; set; }
public long? usn_next { get; set; }
public long scan_generation { get; set; }
public string? last_error { get; set; }
public Source ToModel() => new()
{
Id = id,
StableKey = stable_key,
Kind = Enum.Parse<SourceKind>(kind),
DisplayName = display_name,
VolumeGuid = volume_guid,
VolumeSerial = volume_serial,
Filesystem = filesystem,
Label = label,
CapacityBytes = capacity_bytes,
DeviceInstanceId = device_instance_id,
LastRootPath = last_root_path,
Status = Enum.Parse<SourceStatus>(status),
LastSeenUtc = Parse(last_seen_utc),
LastIndexedUtc = Parse(last_indexed_utc),
UsnJournalId = usn_journal_id,
UsnNext = usn_next,
ScanGeneration = scan_generation,
LastError = last_error
};
private static DateTimeOffset? Parse(string? v)
=> string.IsNullOrEmpty(v) ? null : DateTimeOffset.Parse(v);
}
}

View File

@@ -0,0 +1,76 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace Explorer.Storage.Sqlite;
/// <summary>
/// Synchronous commands on the writer connection.
/// Microsoft.Data.Sqlite async APIs plus Dapper readers can hang (busy connection / sync-context).
/// The store already serializes writers, so blocking here is acceptable.
/// </summary>
internal static class SqliteExec
{
public static Task ExecuteAsync(SqliteConnection conn, string sql, object? args = null)
{
Execute(conn, sql, args);
return Task.CompletedTask;
}
public static Task<T?> ScalarAsync<T>(SqliteConnection conn, string sql, object? args = null)
=> Task.FromResult(Scalar<T>(conn, sql, args));
public static Task<long> InsertAsync(SqliteConnection conn, string sql, object args)
=> Task.FromResult(Insert(conn, sql, args));
public static void Execute(SqliteConnection conn, string sql, object? args = null)
{
using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 20;
cmd.CommandText = Strip(sql);
Bind(cmd, args);
cmd.ExecuteNonQuery();
}
public static T? Scalar<T>(SqliteConnection conn, string sql, object? args = null)
{
using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 20;
cmd.CommandText = Strip(sql);
Bind(cmd, args);
var value = cmd.ExecuteScalar();
if (value is null or DBNull)
{
return default;
}
if (value is T typed)
{
return typed;
}
var target = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
return (T)Convert.ChangeType(value, target);
}
public static long Insert(SqliteConnection conn, string sql, object args)
{
Execute(conn, sql, args);
return Scalar<long>(conn, "SELECT last_insert_rowid()")!;
}
private static string Strip(string sql) => sql.Trim().TrimEnd(';');
private static void Bind(SqliteCommand cmd, object? args)
{
if (args is null)
{
return;
}
foreach (var prop in args.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
var value = prop.GetValue(args) ?? DBNull.Value;
cmd.Parameters.AddWithValue("@" + prop.Name, value);
}
}
}

View File

@@ -0,0 +1,435 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
{
private readonly string _path;
private readonly ILogger<SqliteIndexStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _writeDepth = new();
private SqliteConnection? _write;
private bool _opened;
private int _analysisIndexesReady;
public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> logger)
{
_path = databasePath;
_logger = logger;
Sources = new SourceStore(this);
Entries = new EntryStore(this);
Excludes = new ExcludeStore(this);
ScanJobs = new ScanJobStore(this);
Transfers = new TransferStore(this);
Search = new SearchStore(this);
Analysis = new AnalysisStore(this);
History = new HistoryStore(this);
Hashes = new HashStore(this);
}
public ISourceStore Sources { get; }
public IEntryStore Entries { get; }
public IExcludeStore Excludes { get; }
public IScanJobStore ScanJobs { get; }
public ITransferStore Transfers { get; }
public ISearchStore Search { get; }
public IAnalysisStore Analysis { get; }
public IHistoryStore History { get; }
public IHashStore Hashes { get; }
internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open.");
public async Task OpenAsync(CancellationToken cancellationToken = default)
{
if (_opened)
{
return;
}
DapperSetup.Ensure();
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
_write = new SqliteConnection(BuildConnectionString(_path));
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyPragmas(_write);
Migrate(_write);
if (IndexExists(_write, "ix_entries_dir_agg_all"))
{
Volatile.Write(ref _analysisIndexesReady, 1);
}
_opened = true;
_logger.LogInformation("Opened index database at {Path}", _path);
}
public async Task CloseAsync()
{
if (_write is not null)
{
await _write.CloseAsync().ConfigureAwait(false);
await _write.DisposeAsync().ConfigureAwait(false);
_write = null;
}
_opened = false;
}
public async Task<string> QuickCheckAsync(CancellationToken cancellationToken = default)
{
await using var conn = await OpenReadAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check;";
var result = (string)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? "unknown");
return result;
}
public async Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default)
{
await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
var outermost = _writeDepth.Value == 1;
SqliteTransaction? tx = null;
try
{
if (outermost)
{
tx = (SqliteTransaction)await Write.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
}
await work(this).ConfigureAwait(false);
if (tx is not null)
{
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
catch
{
if (tx is not null)
{
await tx.RollbackAsync(cancellationToken).ConfigureAwait(false);
}
throw;
}
finally
{
if (tx is not null)
{
await tx.DisposeAsync().ConfigureAwait(false);
}
ExitWrite();
}
}
public async Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default)
{
T result = default!;
await RunWriteAsync(async store => { result = await work(store).ConfigureAwait(false); }, cancellationToken)
.ConfigureAwait(false);
return result;
}
internal async Task<T> WriteAsync<T>(Func<SqliteConnection, Task<T>> work, CancellationToken cancellationToken)
{
if (!_opened)
{
throw new InvalidOperationException("Store is not open.");
}
await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
try
{
return await work(Write).ConfigureAwait(false);
}
finally
{
ExitWrite();
}
}
internal async Task WriteAsync(Func<SqliteConnection, Task> work, CancellationToken cancellationToken)
{
await WriteAsync(async c =>
{
await work(c).ConfigureAwait(false);
return 0;
}, cancellationToken).ConfigureAwait(false);
}
private Task EnterWriteAsync(CancellationToken cancellationToken)
{
if (_writeDepth.Value == 0)
{
_writeLock.Wait(cancellationToken);
_writeDepth.Value = 1;
}
else
{
_writeDepth.Value++;
}
return Task.CompletedTask;
}
private void ExitWrite()
{
_writeDepth.Value--;
if (_writeDepth.Value == 0)
{
_writeLock.Release();
}
}
internal async Task<SqliteConnection> OpenReadAsync(CancellationToken cancellationToken)
{
var conn = new SqliteConnection(BuildConnectionString(_path));
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(conn);
return conn;
}
internal static string BuildConnectionString(string path)
=> new SqliteConnectionStringBuilder
{
DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
}.ToString();
private static void ApplyPragmas(SqliteConnection conn)
{
foreach (var pragma in new[]
{
"PRAGMA journal_mode = WAL;",
"PRAGMA synchronous = NORMAL;",
"PRAGMA foreign_keys = ON;",
"PRAGMA temp_store = MEMORY;",
"PRAGMA busy_timeout = 5000;"
})
{
using var cmd = conn.CreateCommand();
cmd.CommandText = pragma;
cmd.ExecuteNonQuery();
}
}
internal Task EnsureAnalysisIndexesAsync(CancellationToken cancellationToken)
{
if (Volatile.Read(ref _analysisIndexesReady) == 1)
{
return Task.CompletedTask;
}
return WriteAsync(conn =>
{
EnsureAnalysisIndexes(conn);
return Task.CompletedTask;
}, cancellationToken);
}
private void EnsureAnalysisIndexes(SqliteConnection conn)
{
if (IndexExists(conn, "ix_entries_dir_agg_all"))
{
if (ReadUserVersion(conn) < 3)
{
SetUserVersion(conn, 3);
}
Volatile.Write(ref _analysisIndexesReady, 1);
return;
}
foreach (var sql in SchemaScript.AnalysisIndexes)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
SetUserVersion(conn, 3);
Volatile.Write(ref _analysisIndexesReady, 1);
_logger.LogInformation("Migrated SQLite schema to v3 (analysis indexes)");
}
private static bool IndexExists(SqliteConnection conn, string name)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='index' AND name=@name";
cmd.Parameters.AddWithValue("@name", name);
return cmd.ExecuteScalar() is not null;
}
private static int ReadUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
return Convert.ToInt32(cmd.ExecuteScalar());
}
private static void SetUserVersion(SqliteConnection conn, int version)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA user_version = {version};";
cmd.ExecuteNonQuery();
}
private static void ApplyReadPragmas(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA busy_timeout = 5000;";
cmd.ExecuteNonQuery();
using var cmd2 = conn.CreateCommand();
cmd2.CommandText = "PRAGMA query_only = ON;";
cmd2.ExecuteNonQuery();
using var cmd3 = conn.CreateCommand();
cmd3.CommandText = "PRAGMA foreign_keys = ON;";
cmd3.ExecuteNonQuery();
}
private void Migrate(SqliteConnection conn)
{
using var versionCmd = conn.CreateCommand();
versionCmd.CommandText = "PRAGMA user_version;";
var version = Convert.ToInt32(versionCmd.ExecuteScalar());
if (version >= AppConstants.SchemaVersion && TableExists(conn, "scan_jobs"))
{
return;
}
if (TableExists(conn, "entries") && version < 2)
{
EnsureColumn(conn, "entries", "allocated_size", "INTEGER");
EnsureColumn(conn, "entries", "cloud_availability", "INTEGER");
using var bump = conn.CreateCommand();
bump.CommandText = "PRAGMA user_version = 2;";
bump.ExecuteNonQuery();
_logger.LogInformation("Migrated SQLite schema to v2 (cloud size/state)");
version = 2;
if (version >= AppConstants.SchemaVersion)
{
return;
}
}
if (!TableExists(conn, "sources"))
{
foreach (var statement in SchemaScript.Statements)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = statement;
cmd.ExecuteNonQuery();
}
using var set = conn.CreateCommand();
set.CommandText = $"PRAGMA user_version = {AppConstants.SchemaVersion};";
set.ExecuteNonQuery();
_logger.LogInformation("Initialized SQLite schema v{Version}", AppConstants.SchemaVersion);
return;
}
if (!TableExists(conn, "scan_jobs"))
{
throw new InvalidOperationException(
"Index database is incomplete. Delete index.db under LocalAppData\\ExplorerWorkbench and restart.");
}
}
private static void EnsureColumn(SqliteConnection conn, string table, string column, string type)
{
if (ColumnExists(conn, table, column))
{
return;
}
using var cmd = conn.CreateCommand();
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
cmd.ExecuteNonQuery();
}
private static bool ColumnExists(SqliteConnection conn, string table, string column)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name=@name";
cmd.Parameters.AddWithValue("@name", column);
return cmd.ExecuteScalar() is not null;
}
private static bool TableExists(SqliteConnection conn, string name)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=@name";
cmd.Parameters.AddWithValue("@name", name);
return cmd.ExecuteScalar() is not null;
}
private static void ExecuteScript(SqliteConnection conn, string sql)
{
foreach (var statement in SplitStatements(sql))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = statement;
cmd.ExecuteNonQuery();
}
}
private static IEnumerable<string> SplitStatements(string sql)
{
var sb = new System.Text.StringBuilder();
var beginDepth = 0;
using var reader = new StringReader(sql);
while (reader.ReadLine() is { } line)
{
var trimmed = line.Trim();
if (trimmed.Equals("BEGIN", StringComparison.OrdinalIgnoreCase)
|| trimmed.EndsWith(" BEGIN", StringComparison.OrdinalIgnoreCase))
{
beginDepth++;
}
sb.AppendLine(line);
if (trimmed.Equals("END;", StringComparison.OrdinalIgnoreCase)
|| (beginDepth == 0 && trimmed.EndsWith(';') && !trimmed.StartsWith("CREATE TRIGGER", StringComparison.OrdinalIgnoreCase)))
{
if (trimmed.Equals("END;", StringComparison.OrdinalIgnoreCase) && beginDepth > 0)
{
beginDepth--;
}
if (beginDepth == 0)
{
var statement = sb.ToString().Trim();
sb.Clear();
if (statement.Length > 0)
{
yield return statement;
}
}
}
}
var tail = sb.ToString().Trim();
if (tail.Length > 0)
{
yield return tail;
}
}
private static string ReadEmbedded(string name)
{
var assembly = typeof(SqliteIndexStore).Assembly;
using var stream = assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException($"Missing embedded resource {name}. Have: {string.Join(',', assembly.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
public async ValueTask DisposeAsync()
{
await CloseAsync().ConfigureAwait(false);
_writeLock.Dispose();
}
}

View File

@@ -0,0 +1,10 @@
using Dapper;
using Microsoft.Data.Sqlite;
namespace Explorer.Storage.Sqlite;
internal static class SqliteInsert
{
public static Task<long> ExecuteAsync(SqliteConnection conn, string sql, object args)
=> SqliteExec.InsertAsync(conn, sql, args);
}

View File

@@ -0,0 +1,160 @@
using System.Runtime.InteropServices;
using Explorer.Domain;
namespace Explorer.Windows;
public static partial class CloudFilesNative
{
public const uint PlaceholderNone = 0;
public const uint Placeholder = 0x1;
public const uint PlaceholderSyncRoot = 0x2;
public const uint PlaceholderInSync = 0x8;
public const uint PlaceholderPartial = 0x10;
public const uint PlaceholderPartiallyOnDisk = 0x20;
public const uint PlaceholderInvalid = 0xFFFFFFFF;
public const int PinUnspecified = 0;
public const int PinPinned = 1;
public const int PinUnpinned = 2;
public const int PinExcluded = 3;
public const int PinInherit = 4;
public static nint OpenNoRecall(string path)
=> NativeMethods.CreateFile(
PathRules.ToExtended(path),
NativeMethods.FileReadAttributes,
NativeMethods.FileShareRead | NativeMethods.FileShareWrite | NativeMethods.FileShareDelete,
0,
NativeMethods.OpenExisting,
NativeMethods.SafeOpenFlags,
0);
public static bool IsInvalid(nint handle)
=> handle == nint.Zero || handle == new nint(-1);
public static long? TryGetAllocatedSize(string path)
{
var handle = OpenNoRecall(path);
if (IsInvalid(handle))
{
return null;
}
try
{
return TryGetAllocatedSize(handle);
}
finally
{
NativeMethods.CloseHandle(handle);
}
}
public static long? TryGetAllocatedSize(nint handle)
{
var size = Marshal.SizeOf<FileStandardInfo>();
var buffer = Marshal.AllocHGlobal(size);
try
{
if (!NativeMethods.GetFileInformationByHandleEx(handle, NativeMethods.FileStandardInfo, buffer, (uint)size))
{
return null;
}
var info = Marshal.PtrToStructure<FileStandardInfo>(buffer);
return info.AllocationSize;
}
catch
{
return null;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
public static uint? TryGetPlaceholderState(int attributes, int reparseTag)
{
try
{
return CfGetPlaceholderStateFromAttributeTag((uint)attributes, (uint)reparseTag);
}
catch (DllNotFoundException)
{
return null;
}
catch (EntryPointNotFoundException)
{
return null;
}
}
public static bool TrySetPinState(string path, int pinState)
{
var handle = OpenNoRecall(path);
if (IsInvalid(handle))
{
return false;
}
try
{
return CfSetPinState(handle, pinState, 0, nint.Zero) == 0;
}
catch (DllNotFoundException)
{
return false;
}
catch (EntryPointNotFoundException)
{
return false;
}
finally
{
NativeMethods.CloseHandle(handle);
}
}
public static bool IsCloudSyncRoot(string path)
{
try
{
uint returned = 0;
var hr = CfGetSyncRootInfoByPath(path, 0, nint.Zero, 0, ref returned);
const int insufficientBuffer = unchecked((int)0x8007007A);
return hr == 0 || (hr == insufficientBuffer && returned > 0);
}
catch (DllNotFoundException)
{
return false;
}
catch (EntryPointNotFoundException)
{
return false;
}
catch
{
return false;
}
}
[LibraryImport("cldapi.dll")]
private static partial uint CfGetPlaceholderStateFromAttributeTag(uint fileAttributes, uint reparseTag);
[DllImport("cldapi.dll", ExactSpelling = true)]
private static extern int CfSetPinState(nint fileHandle, int pinState, uint pinFlags, nint overlapped);
[DllImport("cldapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int CfGetSyncRootInfoByPath(string filePath, int infoClass, nint infoBuffer, uint infoBufferLength, ref uint returnedLength);
[StructLayout(LayoutKind.Sequential)]
private struct FileStandardInfo
{
public long AllocationSize;
public long EndOfFile;
public uint NumberOfLinks;
public byte DeletePending;
public byte Directory;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Windows</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,174 @@
using System.Runtime.InteropServices;
using System.Text;
namespace Explorer.Windows;
internal static partial class NativeMethods
{
public const uint GenericRead = 0x80000000;
public const uint FileShareRead = 0x00000001;
public const uint FileShareWrite = 0x00000002;
public const uint FileShareDelete = 0x00000004;
public const uint OpenExisting = 3;
public const uint FileFlagBackupSemantics = 0x02000000;
public const uint FileFlagOpenNoRecall = 0x00100000;
public const uint FileFlagOpenReparsePoint = 0x00200000;
public const uint FileReadAttributes = 0x0080;
public const uint FileStandardInfo = 1;
public const uint SafeOpenFlags = FileFlagBackupSemantics | FileFlagOpenNoRecall | FileFlagOpenReparsePoint;
public const uint IoctlQueryUsnJournal = 0x000900f4;
public const uint IoctlReadUsnJournal = 0x000900bb;
public const uint FileAttributeDirectory = 0x10;
public const uint FileAttributeReparsePoint = 0x400;
public const int MaxPath = 32767;
public const int FoDelete = 0x0003;
public const int FofSilent = 0x0004;
public const int FofNoConfirmation = 0x0010;
public const int FofAllowUndo = 0x0040;
public const int FofNoErrorUi = 0x0400;
public const int FofNoConfirmMkdir = 0x0200;
public const uint CopyFileFailIfExists = 0x00000001;
public const uint CopyFileRestartable = 0x00000002;
public const uint MoveFileCopyAllowed = 0x0002;
public const uint MoveFileReplaceExisting = 0x0001;
public const uint MoveFileWriteThrough = 0x0008;
public const int ProgressContinue = 0;
public const int ProgressCancel = 1;
public const uint ProcessModeBackgroundBegin = 0x00100000;
public const uint ProcessModeBackgroundEnd = 0x00200000;
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
public static partial nint CreateFile(string lpFileName, uint dwDesiredAccess, uint dwShareMode, nint lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, nint hTemplateFile);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool CloseHandle(nint hObject);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool DeviceIoControl(nint hDevice, uint dwIoControlCode, nint lpInBuffer, uint nInBufferSize, nint lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, nint lpOverlapped);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetVolumeNameForVolumeMountPoint(string lpszVolumeMountPoint, [Out] char[] lpszVolumeName, uint cchBufferLength);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetVolumeInformation(string lpRootPathName, [Out] char[]? lpVolumeNameBuffer, uint nVolumeNameSize, out uint lpVolumeSerialNumber, out uint lpMaximumComponentLength, out uint lpFileSystemFlags, [Out] char[]? lpFileSystemNameBuffer, uint nFileSystemNameSize);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetFileInformationByHandle(nint hFile, out ByHandleFileInformation lpFileInformation);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetFileInformationByHandleEx(nint hFile, uint fileInformationClass, nint lpFileInformation, uint dwBufferSize);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool SetPriorityClass(nint hProcess, uint dwPriorityClass);
[LibraryImport("kernel32.dll")]
public static partial nint GetCurrentProcess();
public delegate int CopyProgressRoutine(long TotalFileSize, long TotalBytesTransferred, long StreamSize, long StreamBytesTransferred, uint dwStreamNumber, uint dwCallbackReason, nint hSourceFile, nint hDestinationFile, nint lpData);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CopyFileEx(string lpExistingFileName, string lpNewFileName, CopyProgressRoutine? lpProgressRoutine, nint lpData, ref int pbCancel, uint dwCopyFlags);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool MoveFileWithProgress(string lpExistingFileName, string lpNewFileName, CopyProgressRoutine? lpProgressRoutine, nint lpData, uint dwFlags);
public const uint ShgfiIcon = 0x000000100;
public const uint ShgfiSmallIcon = 0x000000001;
public const uint ShgfiUseFileAttributes = 0x000000010;
public const uint FileAttributeNormal = 0x00000080;
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern int SHFileOperation(ref ShFileOpStruct lpFileOp);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
public static extern nint SHGetFileInfo(string pszPath, uint dwFileAttributes, ref ShFileInfo psfi, uint cbFileInfo, uint uFlags);
[LibraryImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool DestroyIcon(nint hIcon);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct ShFileInfo
{
public nint hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
public string szTypeName;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct ShFileOpStruct
{
public nint hwnd;
public uint wFunc;
public string pFrom;
public string pTo;
public ushort fFlags;
public int fAnyOperationsAborted;
public nint hNameMappings;
public string? lpszProgressTitle;
}
[StructLayout(LayoutKind.Sequential)]
public struct ByHandleFileInformation
{
public uint FileAttributes;
public long CreationTime;
public long LastAccessTime;
public long LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
public struct UsnJournalDataV2
{
public ulong UsnJournalId;
public long FirstUsn;
public long NextUsn;
public long LowestValidUsn;
public long MaxUsn;
public ulong MaximumSize;
public ulong AllocationDelta;
public ushort MinSupportedMajorVersion;
public ushort MaxSupportedMajorVersion;
public uint Flags;
public ulong RangeTrackChunkSize;
public long RangeTrackFileSizeThreshold;
}
[StructLayout(LayoutKind.Sequential)]
public struct ReadUsnJournalDataV1
{
public long StartUsn;
public uint ReasonMask;
public uint ReturnOnlyOnClose;
public ulong Timeout;
public ulong BytesToWaitFor;
public ulong UsnJournalId;
public ushort MinMajorVersion;
public ushort MaxMajorVersion;
}
public static string FromCharBuffer(char[] buffer)
{
var n = Array.IndexOf(buffer, '\0');
return n < 0 ? new string(buffer) : new string(buffer, 0, n);
}
}

View File

@@ -0,0 +1,22 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Windows;
public sealed class WindowsAppEnvironment : IAppEnvironment
{
public WindowsAppEnvironment()
{
DataDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AppConstants.ProductFolderName);
Directory.CreateDirectory(DataDirectory);
LogDirectory = Path.Combine(DataDirectory, AppConstants.LogFolderName);
Directory.CreateDirectory(LogDirectory);
DatabasePath = Path.Combine(DataDirectory, AppConstants.DatabaseFileName);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}

View File

@@ -0,0 +1,173 @@
using System.IO.Enumeration;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Windows;
public sealed class WindowsFileSystemEnumerator : IFileSystemEnumerator
{
private readonly ILogger<WindowsFileSystemEnumerator> _logger;
public WindowsFileSystemEnumerator(ILogger<WindowsFileSystemEnumerator> logger) => _logger = logger;
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
{
var error = (string?)null;
return EnumerateChildrenSafe(directoryPath, out error);
}
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
{
error = null;
var results = new List<FileSystemItem>();
var path = PathRules.ToExtended(directoryPath);
try
{
if (!Directory.Exists(path))
{
error = "Path not found";
return results;
}
var options = new EnumerationOptions
{
IgnoreInaccessible = true,
RecurseSubdirectories = false,
ReturnSpecialDirectories = false,
AttributesToSkip = 0
};
foreach (var entry in new FileSystemEnumerable<FileSystemItem>(path, Transform, options))
{
results.Add(entry);
}
}
catch (UnauthorizedAccessException ex)
{
error = "Access denied";
_logger.LogDebug(ex, "Access denied enumerating {Path}", directoryPath);
}
catch (IOException ex)
{
error = ex.Message;
_logger.LogDebug(ex, "IO error enumerating {Path}", directoryPath);
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogWarning(ex, "Failed enumerating {Path}", directoryPath);
}
return results;
}
public FileSystemItem? GetItem(string path)
{
try
{
var ext = PathRules.ToExtended(path);
if (Directory.Exists(ext))
{
var info = new DirectoryInfo(ext);
return FromInfo(info, true);
}
if (File.Exists(ext))
{
var info = new FileInfo(ext);
return FromInfo(info, false);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "GetItem failed for {Path}", path);
}
return null;
}
private static FileSystemItem Transform(ref FileSystemEntry entry)
{
var name = entry.FileName.ToString();
var full = PathRules.FromExtended(entry.ToSpecifiedFullPath());
var attrs = (int)entry.Attributes;
return new FileSystemItem
{
FullPath = full,
Name = name,
IsDirectory = entry.IsDirectory,
SizeBytes = entry.IsDirectory ? 0 : entry.Length,
CreatedUtc = ToOffset(entry.CreationTimeUtc),
ModifiedUtc = ToOffset(entry.LastWriteTimeUtc),
Attributes = attrs,
FileId = null,
ReparseTag = (attrs & AttributeFlags.ReparsePoint) != 0 ? GuessReparse(entry.Attributes) : 0
};
}
private static FileSystemItem FromInfo(FileSystemInfo info, bool isDir)
{
var attrs = (int)info.Attributes;
long size = 0;
if (!isDir && info is FileInfo file)
{
try { size = file.Length; } catch { /* locked */ }
}
return new FileSystemItem
{
FullPath = PathRules.FromExtended(info.FullName),
Name = info.Name,
IsDirectory = isDir,
SizeBytes = size,
CreatedUtc = new DateTimeOffset(DateTime.SpecifyKind(info.CreationTimeUtc, DateTimeKind.Utc)),
ModifiedUtc = new DateTimeOffset(DateTime.SpecifyKind(info.LastWriteTimeUtc, DateTimeKind.Utc)),
Attributes = attrs,
FileId = TryGetFileId(info.FullName),
ReparseTag = (attrs & AttributeFlags.ReparsePoint) != 0 ? GuessReparse(info.Attributes) : 0
};
}
internal static long? TryGetFileId(string path)
{
var handle = NativeMethods.CreateFile(
PathRules.ToExtended(path),
NativeMethods.FileReadAttributes,
NativeMethods.FileShareRead | NativeMethods.FileShareWrite | NativeMethods.FileShareDelete,
0,
NativeMethods.OpenExisting,
NativeMethods.SafeOpenFlags,
0);
if (handle == nint.Zero || handle == new nint(-1))
{
return null;
}
try
{
if (!NativeMethods.GetFileInformationByHandle(handle, out var info))
{
return null;
}
return ((long)info.FileIndexHigh << 32) | info.FileIndexLow;
}
finally
{
NativeMethods.CloseHandle(handle);
}
}
private static int GuessReparse(FileAttributes attributes)
{
if ((attributes & FileAttributes.ReparsePoint) == 0)
{
return 0;
}
return ReparsePolicy.IoReparseTagSymlink;
}
private static DateTimeOffset ToOffset(DateTimeOffset utc) => utc;
}

View File

@@ -0,0 +1,144 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Windows;
public sealed class WindowsShellFileOperations : IShellFileOperations
{
private readonly ILogger<WindowsShellFileOperations> _logger;
public WindowsShellFileOperations(ILogger<WindowsShellFileOperations> logger) => _logger = logger;
public void Open(string path)
{
var psi = new ProcessStartInfo
{
FileName = PathRules.FromExtended(path),
UseShellExecute = true
};
Process.Start(psi);
}
public bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error)
{
error = null;
if (paths.Count == 0)
{
return true;
}
var joined = string.Join("\0", paths.Select(PathRules.FromExtended)) + "\0\0";
var op = new NativeMethods.ShFileOpStruct
{
hwnd = 0,
wFunc = NativeMethods.FoDelete,
pFrom = joined,
pTo = null!,
fFlags = (ushort)(NativeMethods.FofAllowUndo | NativeMethods.FofNoConfirmation | NativeMethods.FofNoErrorUi | NativeMethods.FofSilent),
fAnyOperationsAborted = 0,
hNameMappings = 0,
lpszProgressTitle = null
};
var rc = NativeMethods.SHFileOperation(ref op);
if (rc != 0)
{
error = $"Recycle failed ({rc})";
_logger.LogWarning("SHFileOperation delete returned {Code}", rc);
return false;
}
return true;
}
public bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
{
error = null;
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
var cancel = 0;
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
{
progress?.Report(transferred);
return cancellationToken.IsCancellationRequested ? NativeMethods.ProgressCancel : NativeMethods.ProgressContinue;
};
var flags = overwrite ? 0u : NativeMethods.CopyFileFailIfExists;
var ok = NativeMethods.CopyFileEx(
PathRules.ToExtended(source),
PathRules.ToExtended(destination),
cb,
0,
ref cancel,
flags);
if (!ok)
{
var code = Marshal.GetLastWin32Error();
if (cancellationToken.IsCancellationRequested || code == 1235)
{
error = "Cancelled";
return false;
}
error = new System.ComponentModel.Win32Exception(code).Message;
return false;
}
return true;
}
public bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error)
{
error = null;
Directory.CreateDirectory(Path.GetDirectoryName(PathRules.FromExtended(destination))!);
NativeMethods.CopyProgressRoutine cb = (total, transferred, _, _, _, _, _, _, _) =>
{
progress?.Report(transferred);
return cancellationToken.IsCancellationRequested ? NativeMethods.ProgressCancel : NativeMethods.ProgressContinue;
};
var flags = NativeMethods.MoveFileCopyAllowed | NativeMethods.MoveFileWriteThrough;
if (overwrite)
{
flags |= NativeMethods.MoveFileReplaceExisting;
}
var ok = NativeMethods.MoveFileWithProgress(
PathRules.ToExtended(source),
PathRules.ToExtended(destination),
cb,
0,
flags);
if (!ok)
{
var code = Marshal.GetLastWin32Error();
if (cancellationToken.IsCancellationRequested)
{
error = "Cancelled";
return false;
}
error = new System.ComponentModel.Win32Exception(code).Message;
return false;
}
return true;
}
}
public static class BackgroundIo
{
public static IDisposable Begin()
{
NativeMethods.SetPriorityClass(NativeMethods.GetCurrentProcess(), NativeMethods.ProcessModeBackgroundBegin);
return new Reset();
}
private sealed class Reset : IDisposable
{
public void Dispose()
=> NativeMethods.SetPriorityClass(NativeMethods.GetCurrentProcess(), NativeMethods.ProcessModeBackgroundEnd);
}
}

View File

@@ -0,0 +1,209 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Windows;
public sealed class WindowsUsnJournal : IUsnJournal
{
private readonly ILogger<WindowsUsnJournal> _logger;
public WindowsUsnJournal(ILogger<WindowsUsnJournal> logger) => _logger = logger;
public bool TryQuery(string rootPath, out UsnJournalState state, out string? error)
{
state = new UsnJournalState();
error = null;
var volume = OpenVolume(rootPath, out error);
if (volume == nint.Zero)
{
return false;
}
try
{
var size = Marshal.SizeOf<NativeMethods.UsnJournalDataV2>();
var buffer = Marshal.AllocHGlobal(size);
try
{
if (!NativeMethods.DeviceIoControl(volume, NativeMethods.IoctlQueryUsnJournal, 0, 0, buffer, (uint)size, out _, 0))
{
error = new Win32Exception(Marshal.GetLastWin32Error()).Message;
return false;
}
var data = Marshal.PtrToStructure<NativeMethods.UsnJournalDataV2>(buffer);
state = new UsnJournalState
{
JournalId = unchecked((long)data.UsnJournalId),
NextUsn = data.NextUsn
};
return true;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogDebug(ex, "USN query failed for {Path}", rootPath);
return false;
}
finally
{
NativeMethods.CloseHandle(volume);
}
}
public IReadOnlyList<UsnRecord> Read(string rootPath, UsnJournalState from, int maxRecords, out UsnJournalState next, out UsnReadStatus status)
{
next = from;
status = UsnReadStatus.Unavailable;
var records = new List<UsnRecord>();
if (!TryQuery(rootPath, out var current, out var error))
{
status = error?.Contains("denied", StringComparison.OrdinalIgnoreCase) == true
? UsnReadStatus.AccessDenied
: UsnReadStatus.Unavailable;
return records;
}
if (from.JournalId != 0 && from.JournalId != current.JournalId)
{
status = UsnReadStatus.JournalReset;
next = current;
return records;
}
var volume = OpenVolume(rootPath, out _);
if (volume == nint.Zero)
{
status = UsnReadStatus.AccessDenied;
return records;
}
try
{
var read = new NativeMethods.ReadUsnJournalDataV1
{
StartUsn = from.NextUsn == 0 ? current.NextUsn : from.NextUsn,
ReasonMask = 0xFFFFFFFF,
ReturnOnlyOnClose = 0,
Timeout = 0,
BytesToWaitFor = 0,
UsnJournalId = unchecked((ulong)current.JournalId),
MinMajorVersion = 2,
MaxMajorVersion = 3
};
var inSize = Marshal.SizeOf(read);
var inPtr = Marshal.AllocHGlobal(inSize);
var outSize = 64 * 1024;
var outPtr = Marshal.AllocHGlobal(outSize);
try
{
Marshal.StructureToPtr(read, inPtr, false);
if (!NativeMethods.DeviceIoControl(volume, NativeMethods.IoctlReadUsnJournal, inPtr, (uint)inSize, outPtr, (uint)outSize, out var returned, 0))
{
var code = Marshal.GetLastWin32Error();
status = code is 5 or 1314 ? UsnReadStatus.AccessDenied : UsnReadStatus.Error;
if (code is 1179 or 1180)
{
status = UsnReadStatus.JournalReset;
}
return records;
}
if (returned < 8)
{
status = UsnReadStatus.Ok;
next = current;
return records;
}
var nextUsn = Marshal.ReadInt64(outPtr);
var offset = 8;
while (offset + 60 < returned && records.Count < maxRecords)
{
var recordLength = Marshal.ReadInt32(outPtr, offset);
if (recordLength <= 0)
{
break;
}
var major = Marshal.ReadInt16(outPtr, offset + 4);
var frn = Marshal.ReadInt64(outPtr, offset + 8);
var parentFrn = Marshal.ReadInt64(outPtr, offset + 16);
var usn = Marshal.ReadInt64(outPtr, offset + 24);
var reason = Marshal.ReadInt32(outPtr, offset + 40);
var attrs = Marshal.ReadInt32(outPtr, offset + 52);
var nameLength = Marshal.ReadInt16(outPtr, offset + 56);
var nameOffset = Marshal.ReadInt16(outPtr, offset + 58);
var name = Marshal.PtrToStringUni(outPtr + offset + nameOffset, nameLength / 2) ?? "";
records.Add(new UsnRecord
{
FileReferenceNumber = frn,
ParentFileReferenceNumber = parentFrn,
Usn = usn,
FileName = name,
Reason = reason,
FileAttributes = attrs
});
offset += recordLength;
_ = major;
}
next = new UsnJournalState { JournalId = current.JournalId, NextUsn = nextUsn };
status = UsnReadStatus.Ok;
return records;
}
finally
{
Marshal.FreeHGlobal(inPtr);
Marshal.FreeHGlobal(outPtr);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "USN read failed for {Path}", rootPath);
status = UsnReadStatus.Error;
return records;
}
finally
{
NativeMethods.CloseHandle(volume);
}
}
private static nint OpenVolume(string rootPath, out string? error)
{
error = null;
var letter = Path.GetPathRoot(rootPath)?.TrimEnd('\\');
if (string.IsNullOrEmpty(letter))
{
error = "Invalid volume";
return nint.Zero;
}
var volumePath = @"\\.\" + letter;
var handle = NativeMethods.CreateFile(
volumePath,
NativeMethods.GenericRead,
NativeMethods.FileShareRead | NativeMethods.FileShareWrite,
0,
NativeMethods.OpenExisting,
0,
0);
if (handle == nint.Zero || handle == new nint(-1))
{
error = new Win32Exception(Marshal.GetLastWin32Error()).Message;
return nint.Zero;
}
return handle;
}
}

View File

@@ -0,0 +1,205 @@
using System.Runtime.InteropServices;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Windows;
public sealed class WindowsVolumeService : IVolumeService
{
private readonly ILogger<WindowsVolumeService> _logger;
public WindowsVolumeService(ILogger<WindowsVolumeService> logger) => _logger = logger;
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes()
{
var list = new List<VolumeFingerprint>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
// Mapped network letters are often !IsReady until first access; still expose them.
if (!drive.IsReady && drive.DriveType != DriveType.Network)
{
continue;
}
var fp = Probe(drive.Name);
if (fp is not null)
{
list.Add(fp);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Skipping drive {Name}", drive.Name);
}
}
return list;
}
public VolumeFingerprint? Probe(string path)
{
try
{
if (PathRules.IsUnc(path))
{
var root = PathRules.CanonicalUncRoot(path);
return new VolumeFingerprint
{
Kind = SourceKind.Smb,
RootPath = root,
DisplayName = root,
Filesystem = "SMB"
};
}
var rootPath = Path.GetPathRoot(path);
if (string.IsNullOrEmpty(rootPath))
{
return null;
}
rootPath = PathRules.EnsureDirectoryTrailingSlashIfRoot(rootPath.TrimEnd('\\'));
if (!rootPath.EndsWith('\\'))
{
rootPath += "\\";
}
DriveInfo? drive = null;
try { drive = new DriveInfo(rootPath); } catch { /* network letter may throw */ }
var driveType = drive?.DriveType ?? DriveType.Unknown;
var kind = driveType switch
{
DriveType.Removable => SourceKind.Removable,
DriveType.Network => SourceKind.Smb,
DriveType.Fixed => SourceKind.NtfsLocal,
_ => SourceKind.NtfsLocal
};
string? guid = null;
uint serial = 0;
string? fs = null;
string? label = null;
long? capacity = null;
try
{
if (drive is { IsReady: true })
{
fs = drive.DriveFormat;
label = drive.VolumeLabel;
capacity = drive.TotalSize;
}
}
catch (IOException)
{
// mapped drives can fail DriveFormat/VolumeLabel
}
if (driveType != DriveType.Network)
{
try
{
var nameBuf = new char[50];
if (NativeMethods.GetVolumeNameForVolumeMountPoint(rootPath, nameBuf, 50))
{
guid = NativeMethods.FromCharBuffer(nameBuf);
}
}
catch
{
// optional identity
}
try
{
var volName = new char[261];
var fsName = new char[261];
if (NativeMethods.GetVolumeInformation(rootPath, volName, 261, out serial, out _, out _, fsName, 261))
{
var parsedLabel = NativeMethods.FromCharBuffer(volName);
if (!string.IsNullOrWhiteSpace(parsedLabel))
{
label = parsedLabel;
}
var parsedFs = NativeMethods.FromCharBuffer(fsName);
if (!string.IsNullOrWhiteSpace(parsedFs))
{
fs = parsedFs;
}
}
}
catch
{
// optional identity
}
}
else
{
fs ??= "SMB";
}
var display = string.IsNullOrWhiteSpace(label)
? driveType == DriveType.Network
? $"{rootPath.TrimEnd('\\')} (Network)"
: capacity is > 0
? $"{rootPath.TrimEnd('\\')} ({FormatSize(capacity.Value)})"
: rootPath.TrimEnd('\\')
: $"{label} ({rootPath.TrimEnd('\\')})";
if (kind == SourceKind.Removable && !string.IsNullOrWhiteSpace(label))
{
display = capacity is > 0
? $"{label} {FormatSize(capacity.Value)}"
: label;
}
return new VolumeFingerprint
{
Kind = kind,
VolumeGuid = guid,
VolumeSerial = serial == 0 ? null : serial,
Filesystem = fs,
Label = label,
CapacityBytes = capacity,
RootPath = rootPath,
DisplayName = display
};
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Probe failed for {Path}", path);
return null;
}
}
public bool IsPathReachable(string path)
{
try
{
var target = PathRules.ToExtended(path);
return Directory.Exists(target) || File.Exists(target);
}
catch
{
return false;
}
}
private static string FormatSize(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double v = bytes;
var u = 0;
while (v >= 1024 && u < units.Length - 1)
{
v /= 1024;
u++;
}
return $"{v:0.#} {units[u]}";
}
}