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:
45
src/Explorer.Analysis/AnalysisResultCache.cs
Normal file
45
src/Explorer.Analysis/AnalysisResultCache.cs
Normal 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);
|
||||
}
|
||||
117
src/Explorer.Analysis/AnalysisService.cs
Normal file
117
src/Explorer.Analysis/AnalysisService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
158
src/Explorer.Analysis/DuplicateAndHistory.cs
Normal file
158
src/Explorer.Analysis/DuplicateAndHistory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/Explorer.Analysis/Explorer.Analysis.csproj
Normal file
12
src/Explorer.Analysis/Explorer.Analysis.csproj
Normal 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
850
src/Explorer.App/App.xaml
Normal 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="" 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>
|
||||
56
src/Explorer.App/App.xaml.cs
Normal file
56
src/Explorer.App/App.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
86
src/Explorer.App/AppServices.cs
Normal file
86
src/Explorer.App/AppServices.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/Explorer.App/AssemblyInfo.cs
Normal file
10
src/Explorer.App/AssemblyInfo.cs
Normal 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)
|
||||
)]
|
||||
107
src/Explorer.App/Converters.cs
Normal file
107
src/Explorer.App/Converters.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Explorer.App/Explorer.App.csproj
Normal file
38
src/Explorer.App/Explorer.App.csproj
Normal 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>
|
||||
65
src/Explorer.App/ListViewLayout.cs
Normal file
65
src/Explorer.App/ListViewLayout.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
630
src/Explorer.App/MainWindow.xaml
Normal file
630
src/Explorer.App/MainWindow.xaml
Normal 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>
|
||||
525
src/Explorer.App/MainWindow.xaml.cs
Normal file
525
src/Explorer.App/MainWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/Explorer.App/PromptWindow.xaml
Normal file
21
src/Explorer.App/PromptWindow.xaml
Normal 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>
|
||||
30
src/Explorer.App/PromptWindow.xaml.cs
Normal file
30
src/Explorer.App/PromptWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
50
src/Explorer.App/Services/WpfClipboard.cs
Normal file
50
src/Explorer.App/Services/WpfClipboard.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
159
src/Explorer.App/ShellIconConverter.cs
Normal file
159
src/Explorer.App/ShellIconConverter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/Explorer.App/Themes/Dark.xaml
Normal file
22
src/Explorer.App/Themes/Dark.xaml
Normal 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>
|
||||
22
src/Explorer.App/Themes/Light.xaml
Normal file
22
src/Explorer.App/Themes/Light.xaml
Normal 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>
|
||||
16
src/Explorer.App/app.manifest
Normal file
16
src/Explorer.App/app.manifest
Normal 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>
|
||||
10
src/Explorer.Application/AppOptions.cs
Normal file
10
src/Explorer.Application/AppOptions.cs
Normal 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;
|
||||
}
|
||||
151
src/Explorer.Application/BrowseService.cs
Normal file
151
src/Explorer.Application/BrowseService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
115
src/Explorer.Application/CloudPlaceStore.cs
Normal file
115
src/Explorer.Application/CloudPlaceStore.cs
Normal 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;
|
||||
}
|
||||
15
src/Explorer.Application/Explorer.Application.csproj
Normal file
15
src/Explorer.Application/Explorer.Application.csproj
Normal 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>
|
||||
49
src/Explorer.Application/HydrationGuard.cs
Normal file
49
src/Explorer.Application/HydrationGuard.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/Explorer.Application/PathHistoryStore.cs
Normal file
77
src/Explorer.Application/PathHistoryStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
295
src/Explorer.Application/SourceManager.cs
Normal file
295
src/Explorer.Application/SourceManager.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/Explorer.Application/StorageProviderRegistry.cs
Normal file
230
src/Explorer.Application/StorageProviderRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/Explorer.Contracts/Explorer.Contracts.csproj
Normal file
8
src/Explorer.Contracts/Explorer.Contracts.csproj
Normal 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>
|
||||
29
src/Explorer.Contracts/SourceDto.cs
Normal file
29
src/Explorer.Contracts/SourceDto.cs
Normal 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);
|
||||
}
|
||||
180
src/Explorer.Domain/Abstractions/IIndexStore.cs
Normal file
180
src/Explorer.Domain/Abstractions/IIndexStore.cs
Normal 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; }
|
||||
}
|
||||
7
src/Explorer.Domain/Abstractions/IOsClipboard.cs
Normal file
7
src/Explorer.Domain/Abstractions/IOsClipboard.cs
Normal 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);
|
||||
}
|
||||
95
src/Explorer.Domain/Abstractions/Platform.cs
Normal file
95
src/Explorer.Domain/Abstractions/Platform.cs
Normal 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);
|
||||
}
|
||||
21
src/Explorer.Domain/AppConstants.cs
Normal file
21
src/Explorer.Domain/AppConstants.cs
Normal 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;
|
||||
}
|
||||
16
src/Explorer.Domain/DefaultExcludes.cs
Normal file
16
src/Explorer.Domain/DefaultExcludes.cs
Normal 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" }
|
||||
];
|
||||
}
|
||||
163
src/Explorer.Domain/Entities.cs
Normal file
163
src/Explorer.Domain/Entities.cs
Normal 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; }
|
||||
}
|
||||
138
src/Explorer.Domain/Enums.cs
Normal file
138
src/Explorer.Domain/Enums.cs
Normal 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;
|
||||
}
|
||||
89
src/Explorer.Domain/ExcludeEvaluator.cs
Normal file
89
src/Explorer.Domain/ExcludeEvaluator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
5
src/Explorer.Domain/Explorer.Domain.csproj
Normal file
5
src/Explorer.Domain/Explorer.Domain.csproj
Normal file
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Explorer.Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
34
src/Explorer.Domain/NameNormalizer.cs
Normal file
34
src/Explorer.Domain/NameNormalizer.cs
Normal 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)..]);
|
||||
}
|
||||
}
|
||||
202
src/Explorer.Domain/PathRules.cs
Normal file
202
src/Explorer.Domain/PathRules.cs
Normal 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 + @"\…";
|
||||
}
|
||||
}
|
||||
25
src/Explorer.Domain/ReparsePolicy.cs
Normal file
25
src/Explorer.Domain/ReparsePolicy.cs
Normal 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;
|
||||
}
|
||||
82
src/Explorer.Domain/VolumeIdentityMatcher.cs
Normal file
82
src/Explorer.Domain/VolumeIdentityMatcher.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/Explorer.FileOperations/Explorer.FileOperations.csproj
Normal file
13
src/Explorer.FileOperations/Explorer.FileOperations.csproj
Normal 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>
|
||||
70
src/Explorer.FileOperations/FileOperationService.cs
Normal file
70
src/Explorer.FileOperations/FileOperationService.cs
Normal 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);
|
||||
}
|
||||
304
src/Explorer.FileOperations/TransferQueue.cs
Normal file
304
src/Explorer.FileOperations/TransferQueue.cs
Normal 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);
|
||||
}
|
||||
108
src/Explorer.Indexing/DirectoryWatcherHub.cs
Normal file
108
src/Explorer.Indexing/DirectoryWatcherHub.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Explorer.Indexing/Explorer.Indexing.csproj
Normal file
13
src/Explorer.Indexing/Explorer.Indexing.csproj
Normal 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>
|
||||
309
src/Explorer.Indexing/FilesystemScanner.cs
Normal file
309
src/Explorer.Indexing/FilesystemScanner.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
106
src/Explorer.Indexing/FolderReconciler.cs
Normal file
106
src/Explorer.Indexing/FolderReconciler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
155
src/Explorer.Indexing/IndexingCoordinator.cs
Normal file
155
src/Explorer.Indexing/IndexingCoordinator.cs
Normal 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);
|
||||
}
|
||||
180
src/Explorer.Indexing/UsnChangeApplier.cs
Normal file
180
src/Explorer.Indexing/UsnChangeApplier.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Explorer.Plugin.Abstractions</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
94
src/Explorer.Plugin.Abstractions/ProviderContracts.cs
Normal file
94
src/Explorer.Plugin.Abstractions/ProviderContracts.cs
Normal 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);
|
||||
}
|
||||
10
src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj
Normal file
10
src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj
Normal 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>
|
||||
111
src/Explorer.Plugin.OneDrive/OneDrivePlaceDiscovery.cs
Normal file
111
src/Explorer.Plugin.OneDrive/OneDrivePlaceDiscovery.cs
Normal 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));
|
||||
}
|
||||
101
src/Explorer.Plugin.OneDrive/OneDrivePlaceholderState.cs
Normal file
101
src/Explorer.Plugin.OneDrive/OneDrivePlaceholderState.cs
Normal 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",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
140
src/Explorer.Plugin.OneDrive/OneDriveStorageProvider.cs
Normal file
140
src/Explorer.Plugin.OneDrive/OneDriveStorageProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
src/Explorer.Presentation/Explorer.Presentation.csproj
Normal file
19
src/Explorer.Presentation/Explorer.Presentation.csproj
Normal 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>
|
||||
26
src/Explorer.Presentation/Formatters.cs
Normal file
26
src/Explorer.Presentation/Formatters.cs
Normal 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");
|
||||
}
|
||||
50
src/Explorer.Presentation/NavigationHistory.cs
Normal file
50
src/Explorer.Presentation/NavigationHistory.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
660
src/Explorer.Presentation/ViewModels/AnalysisViewModel.cs
Normal file
660
src/Explorer.Presentation/ViewModels/AnalysisViewModel.cs
Normal 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();
|
||||
}
|
||||
79
src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs
Normal file
79
src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
Normal file
265
src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
Normal 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);
|
||||
73
src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
Normal file
73
src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
Normal 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");
|
||||
}
|
||||
75
src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
Normal file
75
src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
664
src/Explorer.Presentation/ViewModels/MainViewModel.cs
Normal file
664
src/Explorer.Presentation/ViewModels/MainViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
362
src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
Normal file
362
src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/Explorer.Presentation/ViewModels/SearchViewModel.cs
Normal file
172
src/Explorer.Presentation/ViewModels/SearchViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
12
src/Explorer.Search/Explorer.Search.csproj
Normal file
12
src/Explorer.Search/Explorer.Search.csproj
Normal 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>
|
||||
65
src/Explorer.Search/SearchService.cs
Normal file
65
src/Explorer.Search/SearchService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
334
src/Explorer.Storage.Sqlite/AnalysisHistoryHashStores.cs
Normal file
334
src/Explorer.Storage.Sqlite/AnalysisHistoryHashStores.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
40
src/Explorer.Storage.Sqlite/DapperSetup.cs
Normal file
40
src/Explorer.Storage.Sqlite/DapperSetup.cs
Normal 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)!);
|
||||
}
|
||||
353
src/Explorer.Storage.Sqlite/EntryStore.cs
Normal file
353
src/Explorer.Storage.Sqlite/EntryStore.cs
Normal 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);
|
||||
}
|
||||
17
src/Explorer.Storage.Sqlite/Explorer.Storage.Sqlite.csproj
Normal file
17
src/Explorer.Storage.Sqlite/Explorer.Storage.Sqlite.csproj
Normal 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>
|
||||
182
src/Explorer.Storage.Sqlite/MiscStores.cs
Normal file
182
src/Explorer.Storage.Sqlite/MiscStores.cs
Normal 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);
|
||||
}
|
||||
170
src/Explorer.Storage.Sqlite/Schema/V1.sql
Normal file
170
src/Explorer.Storage.Sqlite/Schema/V1.sql
Normal 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');
|
||||
196
src/Explorer.Storage.Sqlite/SchemaScript.cs
Normal file
196
src/Explorer.Storage.Sqlite/SchemaScript.cs
Normal 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')"
|
||||
];
|
||||
}
|
||||
153
src/Explorer.Storage.Sqlite/SearchStore.cs
Normal file
153
src/Explorer.Storage.Sqlite/SearchStore.cs
Normal 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);
|
||||
}
|
||||
182
src/Explorer.Storage.Sqlite/SourceStore.cs
Normal file
182
src/Explorer.Storage.Sqlite/SourceStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/Explorer.Storage.Sqlite/SqliteExec.cs
Normal file
76
src/Explorer.Storage.Sqlite/SqliteExec.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
435
src/Explorer.Storage.Sqlite/SqliteIndexStore.cs
Normal file
435
src/Explorer.Storage.Sqlite/SqliteIndexStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
10
src/Explorer.Storage.Sqlite/SqliteInsert.cs
Normal file
10
src/Explorer.Storage.Sqlite/SqliteInsert.cs
Normal 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);
|
||||
}
|
||||
160
src/Explorer.Windows/CloudFilesNative.cs
Normal file
160
src/Explorer.Windows/CloudFilesNative.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
src/Explorer.Windows/Explorer.Windows.csproj
Normal file
14
src/Explorer.Windows/Explorer.Windows.csproj
Normal 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>
|
||||
174
src/Explorer.Windows/NativeMethods.cs
Normal file
174
src/Explorer.Windows/NativeMethods.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/Explorer.Windows/WindowsAppEnvironment.cs
Normal file
22
src/Explorer.Windows/WindowsAppEnvironment.cs
Normal 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; }
|
||||
}
|
||||
173
src/Explorer.Windows/WindowsFileSystemEnumerator.cs
Normal file
173
src/Explorer.Windows/WindowsFileSystemEnumerator.cs
Normal 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;
|
||||
}
|
||||
144
src/Explorer.Windows/WindowsShellFileOperations.cs
Normal file
144
src/Explorer.Windows/WindowsShellFileOperations.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
209
src/Explorer.Windows/WindowsUsnJournal.cs
Normal file
209
src/Explorer.Windows/WindowsUsnJournal.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
205
src/Explorer.Windows/WindowsVolumeService.cs
Normal file
205
src/Explorer.Windows/WindowsVolumeService.cs
Normal 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]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user