Show folder names immediately and refresh stale index sizes without walking the whole drive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 11:21:20 +02:00
parent 6fc7506eb7
commit e2916aef9c
88 changed files with 5373 additions and 544 deletions

View File

@@ -7,12 +7,13 @@ using Microsoft.Extensions.Logging;
namespace Explorer.Analysis;
public sealed class DuplicateHashWorker : BackgroundService
public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
{
private readonly IIndexStore _store;
private readonly IHydrationGuard _hydration;
private readonly ILogger<DuplicateHashWorker> _logger;
private volatile bool _paused;
private volatile bool _paused = true;
private volatile bool _userRequested;
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
{
@@ -21,15 +22,20 @@ public sealed class DuplicateHashWorker : BackgroundService
_logger = logger;
}
public bool IsPaused => _paused && !_userRequested;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public void BeginUserRequested() => _userRequested = true;
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
=> await _store.Hashes.HasPendingAsync(cancellationToken).ConfigureAwait(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)
if ((_paused && !_userRequested) || cancellationToken.IsCancellationRequested)
{
break;
}
@@ -92,7 +98,7 @@ public sealed class DuplicateHashWorker : BackgroundService
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
if (_paused)
if (_paused && !_userRequested)
{
continue;
}
@@ -100,6 +106,10 @@ public sealed class DuplicateHashWorker : BackgroundService
try
{
await ProcessPendingAsync(stoppingToken).ConfigureAwait(false);
if (_userRequested && !await HasPendingAsync(stoppingToken).ConfigureAwait(false))
{
_userRequested = false;
}
}
catch (Exception ex)
{
@@ -123,28 +133,18 @@ public sealed class DuplicateHashWorker : BackgroundService
}
}
public sealed class HistoryRollupService : BackgroundService
public sealed class HistoryRollupService : IHistoryMaintenance
{
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)
public async Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default)
{
if ((DateTime.UtcNow - _last).TotalHours < 20)
{
return;
return false;
}
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
@@ -160,5 +160,6 @@ public sealed class HistoryRollupService : BackgroundService
var days = AppConstants.DefaultTombstoneRetentionDays;
await _store.Entries.DeleteExpiredTombstonesAsync(utc.AddDays(-days), cancellationToken).ConfigureAwait(false);
_last = DateTime.UtcNow;
return true;
}
}

View File

@@ -3,6 +3,7 @@
<RootNamespace>Explorer.Analysis</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>

View File

@@ -7,9 +7,11 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/Dark.xaml"/>
<ResourceDictionary Source="Settings/SettingsStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<FontFamily x:Key="Symbol">Segoe MDL2 Assets</FontFamily>
<BooleanToVisibilityConverter x:Key="BoolVis"/>
<local:InverseBooleanToVisibilityConverter x:Key="InvBoolVis"/>
<local:ActiveThicknessConverter x:Key="ActiveThickness"/>
<local:FractionWidthConverter x:Key="FractionWidth"/>
<local:IndentConverter x:Key="Indent"/>
@@ -141,67 +143,6 @@
</Setter.Value>
</Setter>
</Style>
<DataTemplate x:Key="PaneAddressBar">
<DockPanel LastChildFill="True" Margin="6,6,6,4">
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
Style="{StaticResource CrumbButton}"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}"
ToolTip="Up one folder"/>
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" CornerRadius="4" Padding="4,0" MinHeight="28">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Focusable="False">
<ItemsControl ItemsSource="{Binding Breadcrumb}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource CrumbButton}"
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
ToolTip="{Binding Path}">
<StackPanel Orientation="Horizontal">
<TextBlock FontFamily="{StaticResource Symbol}" Text="&#xE977;" FontSize="14"
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Label}" Value="This PC">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
</StackPanel>
</Button>
<TextBlock Text="" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLast}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</DataTemplate>
<Style x:Key="PaneChrome" TargetType="Border">
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
@@ -706,6 +647,7 @@
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
</Trigger>
<DataTrigger Binding="{Binding IsDropTarget}" Value="True">

View File

@@ -5,6 +5,15 @@ using Explorer.Domain;
namespace Explorer.App;
public sealed class InverseBooleanToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is true ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class ActiveThicknessConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)

View File

@@ -0,0 +1,79 @@
<UserControl x:Class="Explorer.App.ExplorerAddressBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Focusable="False">
<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"/>
<Grid MinHeight="28">
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" CornerRadius="4" Padding="4,0"
Cursor="IBeam"
MouseLeftButtonDown="OnBreadcrumbMouseDown"
Visibility="{Binding IsEditingPath, Converter={StaticResource InvBoolVis}}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
Focusable="False" HorizontalAlignment="Stretch">
<ItemsControl ItemsSource="{Binding Breadcrumb}" HorizontalAlignment="Left">
<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}"
Cursor="Hand"
ToolTip="{Binding Path}">
<StackPanel Orientation="Horizontal">
<TextBlock FontFamily="{StaticResource Symbol}" Text="&#xE977;" FontSize="14"
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Label}" Value="This PC">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
</StackPanel>
</Button>
<TextBlock Text="" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLast}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<ComboBox x:Name="PathCombo"
Style="{StaticResource PathComboBox}"
VerticalAlignment="Stretch"
ItemsSource="{Binding DataContext.PathHistory, RelativeSource={RelativeSource AncestorType=Window}}"
Text="{Binding PathEditText, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsEditingPath, Converter={StaticResource BoolVis}}"
PreviewKeyDown="OnPathKeyDown"
SelectionChanged="OnPathHistorySelected"
LostKeyboardFocus="OnPathLostKeyboardFocus"/>
</Grid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,178 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class ExplorerAddressBar : UserControl
{
private bool _suppressLostFocus;
public ExplorerAddressBar()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
IsVisibleChanged += (_, _) =>
{
if (IsVisible && DataContext is ExplorerPaneViewModel { IsEditingPath: true })
{
FocusEditor();
}
};
}
public void FocusEditor()
{
Dispatcher.BeginInvoke(() =>
{
PathCombo.ApplyTemplate();
if (PathCombo.Template.FindName("PART_EditableTextBox", PathCombo) is TextBox box)
{
box.Focus();
box.SelectAll();
}
else
{
PathCombo.Focus();
}
}, DispatcherPriority.Loaded);
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is ExplorerPaneViewModel previous)
{
previous.PropertyChanged -= OnPanePropertyChanged;
}
if (e.NewValue is ExplorerPaneViewModel pane)
{
pane.PropertyChanged += OnPanePropertyChanged;
if (pane.IsEditingPath)
{
FocusEditor();
}
}
}
private void OnPanePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ExplorerPaneViewModel.IsEditingPath)
&& sender is ExplorerPaneViewModel { IsEditingPath: true })
{
FocusEditor();
}
}
private void OnBreadcrumbMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
{
return;
}
if (DataContext is not ExplorerPaneViewModel pane)
{
return;
}
pane.BeginEditPath();
e.Handled = true;
}
private void OnPathKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not ExplorerPaneViewModel pane)
{
return;
}
if (e.Key == Key.Enter)
{
CommitPath();
e.Handled = true;
return;
}
if (e.Key == Key.Escape)
{
_suppressLostFocus = true;
pane.CancelEditPath();
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 (DataContext is not ExplorerPaneViewModel pane)
{
return;
}
if (NavigationTreeViewModel.PathsEqual(path, pane.CurrentPath))
{
return;
}
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
{
pane.PathEditText = path;
CommitPath();
}
}
private void OnPathLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
Dispatcher.BeginInvoke(() =>
{
if (_suppressLostFocus)
{
_suppressLostFocus = false;
return;
}
if (PathCombo.IsDropDownOpen || PathCombo.IsKeyboardFocusWithin)
{
return;
}
if (DataContext is ExplorerPaneViewModel pane)
{
pane.CancelEditPath();
}
}, DispatcherPriority.Input);
}
private void CommitPath()
{
if (Window.GetWindow(this)?.DataContext is MainViewModel vm)
{
vm.GoCommand.Execute(null);
}
}
private static bool IsInsideButton(DependencyObject origin)
{
for (var current = origin; current is not null;)
{
if (current is Button)
{
return true;
}
current = current is Visual
? VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return false;
}
}

View File

@@ -0,0 +1,462 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Application;
namespace Explorer.App;
internal sealed class ListMarquee
{
private readonly DispatcherTimer _scroll = new() { Interval = TimeSpan.FromMilliseconds(20) };
private ListView? _list;
private MarqueeAdorner? _adorner;
private Point _startContent;
private Point _currentList;
private object[] _kept = [];
private MouseButton _button;
public ListMarquee() => _scroll.Tick += (_, _) => AutoScroll();
public bool IsArmed { get; private set; }
public bool IsActive { get; private set; }
public void Arm(ListView list, MouseButtonEventArgs e, bool additive)
{
Cancel();
_list = list;
_button = e.ChangedButton;
_kept = additive ? list.SelectedItems.Cast<object>().ToArray() : [];
_startContent = ToContent(list, e.GetPosition(list));
_currentList = e.GetPosition(list);
IsArmed = true;
list.Focus();
if (!additive)
{
list.UnselectAll();
}
}
public void Disarm()
{
if (!IsActive)
{
IsArmed = false;
_list = null;
}
}
public bool TryActivate(Point currentOnList)
{
if (!IsArmed || IsActive || _list is null)
{
return false;
}
if (Math.Abs(currentOnList.X - _currentList.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(currentOnList.Y - _currentList.Y) < SystemParameters.MinimumVerticalDragDistance)
{
return false;
}
IsActive = true;
_list.CaptureMouse();
_list.Focus();
var layer = AdornerLayer.GetAdornerLayer(_list);
if (layer is not null)
{
_adorner = new MarqueeAdorner(_list);
layer.Add(_adorner);
}
_scroll.Start();
Update(currentOnList);
return true;
}
public void Update(Point currentOnList)
{
if (!IsActive || _list is null)
{
return;
}
_currentList = currentOnList;
ApplyHits();
UpdateAdorner();
}
public bool TryActivateFromMouse()
=> _list is not null && TryActivate(Mouse.GetPosition(_list));
public void UpdateFromMouse()
{
if (_list is not null)
{
Update(Mouse.GetPosition(_list));
}
}
public ListView? CompleteForContextMenu()
{
var list = _list;
var openMenu = IsActive && _button == MouseButton.Right;
Stop(restore: false);
return openMenu ? list : null;
}
public void Cancel() => Stop(restore: IsActive);
private void Stop(bool restore)
{
_scroll.Stop();
if (_list is not null)
{
if (_adorner is not null)
{
AdornerLayer.GetAdornerLayer(_list)?.Remove(_adorner);
}
if (_list.IsMouseCaptured)
{
_list.ReleaseMouseCapture();
}
if (restore)
{
Restore(_list, _kept);
}
}
_adorner = null;
_list = null;
_kept = [];
IsArmed = false;
IsActive = false;
}
private void ApplyHits()
{
if (_list is null)
{
return;
}
var next = new HashSet<object>(_kept);
foreach (var item in HitItems(_list, MarqueeListRect()))
{
next.Add(item);
}
if (next.Count == _list.SelectedItems.Count && next.SetEquals(_list.SelectedItems.Cast<object>()))
{
return;
}
_list.UnselectAll();
foreach (var item in next)
{
_list.SelectedItems.Add(item);
if (_list.ItemContainerGenerator.ContainerFromItem(item) is ListViewItem row)
{
row.IsSelected = true;
}
}
}
private Rect MarqueeListRect()
{
var start = FromContent(_list!, _startContent);
var rect = new Rect(start, _currentList);
if (rect.Width < 1)
{
rect.Width = 1;
}
if (rect.Height < 1)
{
rect.Height = 1;
}
return rect;
}
private static IEnumerable<object> HitItems(ListView list, Rect marquee)
{
var wrap = FindWrapPanel(list);
var viewport = ViewportBounds(list);
var sampleIndex = -1;
var sampleBounds = Rect.Empty;
var hits = new HashSet<int>();
for (var i = 0; i < list.Items.Count; i++)
{
if (list.ItemContainerGenerator.ContainerFromIndex(i) is not ListViewItem row
|| row.ActualHeight < 1)
{
continue;
}
var origin = row.TranslatePoint(new Point(0, 0), list);
var bounds = wrap is null
? new Rect(viewport.X, origin.Y, Math.Max(viewport.Width, 1), row.ActualHeight)
: new Rect(origin, new Size(Math.Max(row.ActualWidth, 1), Math.Max(row.ActualHeight, 1)));
if (sampleIndex < 0)
{
sampleIndex = i;
sampleBounds = bounds;
}
if (bounds.IntersectsWith(marquee))
{
hits.Add(i);
}
}
if (wrap is null && sampleIndex >= 0 && sampleBounds.Height > 0)
{
var first = (int)Math.Floor((marquee.Top - sampleBounds.Y) / sampleBounds.Height) + sampleIndex;
var last = (int)Math.Ceiling((marquee.Bottom - sampleBounds.Y) / sampleBounds.Height) - 1 + sampleIndex;
first = Math.Clamp(first, 0, list.Items.Count - 1);
last = Math.Clamp(last, 0, list.Items.Count - 1);
for (var i = first; i <= last; i++)
{
hits.Add(i);
}
}
else if (wrap is not null)
{
foreach (var index in Hits(list, ToContent(list, marquee.TopLeft), ToContent(list, marquee.BottomRight)))
{
hits.Add(index);
}
}
foreach (var index in hits.OrderBy(i => i))
{
yield return list.Items[index];
}
}
private void UpdateAdorner()
{
if (_list is null || _adorner is null)
{
return;
}
var rect = MarqueeListRect();
rect.Intersect(ViewportBounds(_list));
_adorner.Bounds = rect;
_adorner.InvalidateVisual();
}
private void AutoScroll()
{
if (_list is null || Mouse.LeftButton != MouseButtonState.Pressed && Mouse.RightButton != MouseButtonState.Pressed)
{
return;
}
var pos = Mouse.GetPosition(_list);
_currentList = pos;
var zone = 32d;
var viewer = FindScrollViewer(_list);
if (viewer is not null)
{
var local = _list.TranslatePoint(pos, viewer);
if (local.Y < zone)
{
viewer.LineUp();
}
else if (local.Y > viewer.ActualHeight - zone)
{
viewer.LineDown();
}
}
ApplyHits();
UpdateAdorner();
}
public static bool IsBackground(DependencyObject? source)
{
while (source is not null)
{
if (source is ListViewItem or GridViewColumnHeader or ScrollBar or Thumb)
{
return false;
}
if (source is ListView)
{
return true;
}
source = source is Visual
? VisualTreeHelper.GetParent(source)
: LogicalTreeHelper.GetParent(source);
}
return false;
}
public static IReadOnlyList<int> Hits(ListView list, Point startContent, Point currentContent)
{
var count = list.Items.Count;
if (FindWrapPanel(list) is { } wrap)
{
return MarqueeRange.Wrap(
startContent.X, startContent.Y, currentContent.X, currentContent.Y,
count, wrap.Columns, wrap.ItemWidth, wrap.ItemHeight);
}
return MarqueeRange.Stack(startContent.Y, currentContent.Y, count, RowHeight(list));
}
private static Point ToContent(ListView list, Point listPoint)
{
if (FindWrapPanel(list) is { } wrap)
{
var p = list.TranslatePoint(listPoint, wrap);
return new Point(p.X, p.Y + wrap.VerticalOffset);
}
var presenter = (UIElement?)FindItemsPresenter(list) ?? list;
var local = list.TranslatePoint(listPoint, presenter);
var viewer = FindScrollViewer(list);
var height = RowHeight(list);
if (viewer is { CanContentScroll: true } && height > 0)
{
return new Point(local.X, viewer.VerticalOffset * height + local.Y);
}
return new Point(local.X, (viewer?.VerticalOffset ?? 0) + local.Y);
}
private static Point FromContent(ListView list, Point content)
{
if (FindWrapPanel(list) is { } wrap)
{
return wrap.TranslatePoint(new Point(content.X, content.Y - wrap.VerticalOffset), list);
}
var presenter = FindItemsPresenter(list);
if (presenter is null)
{
return content;
}
var viewer = FindScrollViewer(list);
var height = RowHeight(list);
double y;
if (viewer is { CanContentScroll: true } && height > 0)
{
y = content.Y - viewer.VerticalOffset * height;
}
else
{
y = content.Y - (viewer?.VerticalOffset ?? 0);
}
return presenter.TranslatePoint(new Point(content.X, y), list);
}
private static Rect ViewportBounds(ListView list)
{
var presenter = FindItemsPresenter(list);
if (presenter is null)
{
return new Rect(list.RenderSize);
}
var origin = presenter.TranslatePoint(new Point(0, 0), list);
return new Rect(origin, presenter.RenderSize);
}
private static double RowHeight(ListView list)
{
for (var i = 0; i < list.Items.Count; i++)
{
if (list.ItemContainerGenerator.ContainerFromIndex(i) is ListViewItem { ActualHeight: > 1 } row)
{
return row.ActualHeight;
}
}
return 28;
}
private static void Restore(ListView list, IReadOnlyList<object> kept)
{
list.UnselectAll();
foreach (var item in kept)
{
list.SelectedItems.Add(item);
}
}
private static ItemsPresenter? FindItemsPresenter(DependencyObject root) => FindChild<ItemsPresenter>(root);
private static ScrollViewer? FindScrollViewer(DependencyObject root) => FindChild<ScrollViewer>(root);
private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root) => FindChild<VirtualizingWrapPanel>(root);
private static T? FindChild<T>(DependencyObject root)
where T : DependencyObject
{
if (root is T match)
{
return match;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var found = FindChild<T>(VisualTreeHelper.GetChild(root, i));
if (found is not null)
{
return found;
}
}
return null;
}
private sealed class MarqueeAdorner : Adorner
{
public MarqueeAdorner(UIElement adorned) : base(adorned)
{
IsHitTestVisible = false;
}
public Rect Bounds { get; set; }
protected override void OnRender(DrawingContext drawingContext)
{
if (Bounds.IsEmpty || Bounds.Width < 1 || Bounds.Height < 1)
{
return;
}
var accent = TryAccent();
var fill = new SolidColorBrush(Color.FromArgb(0x55, accent.R, accent.G, accent.B));
var stroke = new SolidColorBrush(Color.FromArgb(0xE0, accent.R, accent.G, accent.B));
fill.Freeze();
stroke.Freeze();
drawingContext.DrawRectangle(fill, new Pen(stroke, 1), Bounds);
}
private Color TryAccent()
{
if (AdornedElement is FrameworkElement fe
&& fe.TryFindResource("Accent") is SolidColorBrush brush)
{
return brush.Color;
}
return Color.FromRgb(0x60, 0xCD, 0xFF);
}
}
}

View File

@@ -23,6 +23,81 @@
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.Resources>
<ContextMenu x:Key="FolderListContextMenu" x:Shared="false">
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator/>
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<MenuItem Header="Batch rename…" Click="OnBatchRename"
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Run profile" Tag="RunProfileMenu"
IsEnabled="{Binding ShowRunProfile}">
<MenuItem Header="No profiles yet" IsEnabled="False"/>
</MenuItem>
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract here" Click="OnExtractHere"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract to…" Click="OnExtractTo"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Convert…" Click="OnConvert"
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<MenuItem Header="View changes…" Click="OnGitChanges"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Commit…" Click="OnGitCommit"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Fetch" Click="OnGitFetch"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Push" Click="OnGitPush"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to Favorites" Click="OnAddFavorite"
Visibility="{Binding ShowAddFavorite, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Favorites" Click="OnRemoveFavorite"
Visibility="{Binding ShowRemoveFavorite, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding PinCloudCommand}"
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Free up space"
Command="{Binding FreeUpCloudSpaceCommand}"
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
</ContextMenu>
</Window.Resources>
<AdornerDecorator>
<DockPanel>
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
@@ -61,6 +136,7 @@
<MenuItem Header="_Storage">
<MenuItem Header="Storage _analysis" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
<MenuItem Header="Run background _maintenance now" Command="{Binding RunMaintenanceNowCommand}"/>
</MenuItem>
<MenuItem Header="_Locations">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
@@ -184,12 +260,8 @@
</Button.Style>
</Button>
</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}"
<TextBox Grid.Column="2" Margin="12,0,0,0"
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}"/>
@@ -200,12 +272,15 @@
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
<TextBlock Text="{Binding ActivePane.ListingStatus}" Foreground="{DynamicResource Fg}"
VerticalAlignment="Center" Margin="0,0,16,0"/>
<TextBlock Grid.Column="1" Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<StackPanel Grid.Column="2" Orientation="Horizontal">
<TextBlock Text="{Binding ActivePane.GitBadge}" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}" Margin="0,0,16,0"
Visibility="{Binding ActivePane.HasGitBadge, Converter={StaticResource BoolVis}}"/>
@@ -363,6 +438,8 @@
HorizontalContentAlignment="Stretch">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem x:Name="AddFavoriteMenu" Header="Add to Favorites" Click="OnAddFavoriteFromTree"/>
<MenuItem x:Name="RemoveFavoriteMenu" Header="Remove from Favorites" Click="OnRemoveFavoriteFromTree"/>
<MenuItem x:Name="RemoveLocationMenu" Header="Remove from Explorer" Click="OnRemoveLocation"/>
</ContextMenu>
</TreeView.ContextMenu>
@@ -405,8 +482,7 @@
<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"/>
<local:ExplorerAddressBar DockPanel.Dock="Top"/>
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
<DockPanel>
@@ -417,6 +493,9 @@
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -445,78 +524,11 @@
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
</ListView.View>
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator/>
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<MenuItem Header="Batch rename…" Click="OnBatchRename"
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Run profile" Tag="RunProfileMenu"
IsEnabled="{Binding ShowRunProfile}">
<MenuItem Header="No profiles yet" IsEnabled="False"/>
</MenuItem>
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract here" Click="OnExtractHere"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract to…" Click="OnExtractTo"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
Visibility="{Binding ShowVerifyArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Convert…" Click="OnConvert"
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<MenuItem Header="View changes…" Click="OnGitChanges"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Commit…" Click="OnGitCommit"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Fetch" Click="OnGitFetch"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Push" Click="OnGitPush"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding PinCloudCommand}"
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Free up space"
Command="{Binding FreeUpCloudSpaceCommand}"
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
</ContextMenu>
</ListView.ContextMenu>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -543,6 +555,9 @@
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
@@ -587,8 +602,7 @@
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"/>
<local:ExplorerAddressBar DockPanel.Dock="Top"/>
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
<DockPanel>
@@ -599,6 +613,9 @@
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -629,6 +646,9 @@
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -655,6 +675,9 @@
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
@@ -1007,4 +1030,5 @@
</Border>
</Grid>
</DockPanel>
</AdornerDecorator>
</Window>

View File

@@ -21,6 +21,7 @@ public partial class MainWindow : Window
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
private readonly ListMarquee _marquee = new();
private bool _suppressItemContextMenu;
private bool _incomingRightDrag;
private bool _sourceRightDrag;
@@ -34,6 +35,8 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
SourceInitialized += (_, _) => MaximizedWorkArea.Hook(this);
StateChanged += (_, _) => SyncMaxRestoreButton();
DataContextChanged += (_, _) =>
{
if (_wiredVm is not null)
@@ -186,34 +189,6 @@ public partial class MainWindow : Window
}
}
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)
@@ -245,16 +220,57 @@ public partial class MainWindow : Window
}
var node = FindTreeNode(e.OriginalSource as DependencyObject);
if (node is null || !node.CanRemove)
if (node is null)
{
e.Handled = true;
return;
}
var canPin = Vm.CanPinFavorite(node);
var canUnpin = Vm.CanUnpinFavorite(node.Path);
AddFavoriteMenu.Visibility = canPin ? Visibility.Visible : Visibility.Collapsed;
RemoveFavoriteMenu.Visibility = canUnpin ? Visibility.Visible : Visibility.Collapsed;
RemoveLocationMenu.Visibility = node.CanRemove ? Visibility.Visible : Visibility.Collapsed;
if (!canPin && !canUnpin && !node.CanRemove)
{
e.Handled = true;
return;
}
node.IsSelected = true;
AddFavoriteMenu.Tag = node.Path;
RemoveFavoriteMenu.Tag = node.Path;
RemoveLocationMenu.Tag = node.Path;
}
private async void OnAddFavorite(object sender, RoutedEventArgs e)
=> await Vm.AddSelectedFavoritesAsync().ConfigureAwait(true);
private async void OnRemoveFavorite(object sender, RoutedEventArgs e)
=> await Vm.RemoveSelectedFavoritesAsync().ConfigureAwait(true);
private async void OnAddFavoriteFromTree(object sender, RoutedEventArgs e)
{
var path = (sender as FrameworkElement)?.Tag as string ?? AddFavoriteMenu.Tag as string;
if (string.IsNullOrWhiteSpace(path))
{
return;
}
await Vm.AddFavoritesAsync([path]).ConfigureAwait(true);
}
private async void OnRemoveFavoriteFromTree(object sender, RoutedEventArgs e)
{
var path = (sender as FrameworkElement)?.Tag as string ?? RemoveFavoriteMenu.Tag as string;
if (string.IsNullOrWhiteSpace(path))
{
return;
}
await Vm.RemoveFavoritesAsync([path]).ConfigureAwait(true);
}
private async void OnRemoveLocation(object sender, RoutedEventArgs e)
{
var path = (sender as FrameworkElement)?.Tag as string
@@ -560,7 +576,7 @@ public partial class MainWindow : Window
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView list)
if (sender is not ListView { IsVisible: true } list)
{
return;
}
@@ -608,6 +624,18 @@ public partial class MainWindow : Window
_dragPending = true;
_dragButton = e.ChangedButton;
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
if (sender is ListView list
&& _dragItem is null
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
{
_marquee.Arm(list, e, (Keyboard.Modifiers & ModifierKeys.Control) != 0);
e.Handled = true;
}
else
{
_marquee.Disarm();
}
TryScheduleClickRename(sender as ListView, e);
}
@@ -729,6 +757,34 @@ public partial class MainWindow : Window
base.OnPreviewMouseMove(e);
var held = _dragButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed
|| _dragButton == MouseButton.Right && e.RightButton == MouseButtonState.Pressed;
if (_marquee.IsArmed || _marquee.IsActive)
{
if (!held)
{
if (!_marquee.IsActive)
{
_marquee.Disarm();
}
return;
}
if (_marquee.IsActive)
{
_marquee.UpdateFromMouse();
e.Handled = true;
return;
}
if (_marquee.TryActivateFromMouse())
{
_dragPending = false;
CancelClickRename();
e.Handled = true;
return;
}
}
if (!_dragPending || !held)
{
return;
@@ -784,15 +840,69 @@ public partial class MainWindow : Window
e.Handled = true;
}
protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
{
if (CompleteMarqueeMouseUp(e))
{
return;
}
base.OnPreviewMouseLeftButtonUp(e);
}
protected override void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e)
{
if (CompleteMarqueeMouseUp(e))
{
return;
}
base.OnPreviewMouseRightButtonUp(e);
}
protected override void OnPreviewMouseUp(MouseButtonEventArgs e)
{
if (CompleteMarqueeMouseUp(e))
{
return;
}
base.OnPreviewMouseUp(e);
if (e.ChangedButton == _dragButton)
{
_dragPending = false;
_marquee.Disarm();
}
}
private bool CompleteMarqueeMouseUp(MouseButtonEventArgs e)
{
if (e.ChangedButton != _dragButton)
{
return false;
}
if (!_marquee.IsActive)
{
return false;
}
_dragPending = false;
var list = _marquee.CompleteForContextMenu();
e.Handled = true;
if (list?.ContextMenu is { } menu)
{
_suppressItemContextMenu = true;
menu.DataContext = DataContext;
_ = FillRunProfileMenuAsync(menu);
menu.PlacementTarget = list;
menu.Placement = PlacementMode.MousePoint;
menu.IsOpen = true;
}
return true;
}
private static bool TryGetDropFiles(DragEventArgs e, out string[] files)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] dropped && dropped.Length > 0)
@@ -909,6 +1019,15 @@ public partial class MainWindow : Window
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
var node = HitTestTreeNode(e);
if (IsFavoritesPinTarget(node) && files.Any(Directory.Exists))
{
SetListDropTarget(null);
SetTreeDropTarget(node);
e.Effects = DragDropEffects.Link;
e.Handled = true;
return;
}
var dest = node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)
? null
: node.Path;
@@ -936,6 +1055,15 @@ public partial class MainWindow : Window
return;
}
var node = HitTestTreeNode(e);
if (IsFavoritesPinTarget(node))
{
_incomingRightDrag = false;
_sourceRightDrag = false;
await Vm.AddFavoritesAsync(files).ConfigureAwait(true);
return;
}
var dest = HitTestTreePath(e);
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
{
@@ -965,6 +1093,9 @@ public partial class MainWindow : Window
SetTreeDropTarget(null);
}
private static bool IsFavoritesPinTarget(NavNodeViewModel? node)
=> node is { IsGroup: true, Path: LocationRoots.Favorites };
private string? HitTestTreePath(DragEventArgs e)
{
var node = HitTestTreeNode(e);
@@ -1705,6 +1836,43 @@ public partial class MainWindow : Window
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
if ((ctrl && e.Key == Key.L) || (alt && (e.Key == Key.D || e.SystemKey == Key.D)))
{
BeginAddressEdit();
e.Handled = true;
return;
}
if (IsAddressEditor(e.OriginalSource as DependencyObject))
{
if (e.Key == Key.Escape)
{
Vm.ActivePane.CancelEditPath();
e.Handled = true;
}
else if (e.Key == Key.Enter)
{
Vm.GoCommand.Execute(null);
e.Handled = true;
}
if (e.Key is not (Key.F1 or Key.F5))
{
return;
}
}
else if (e.OriginalSource is TextBox && e.Key is not (Key.F1 or Key.F5))
{
return;
}
if (e.Key == Key.Escape && (_marquee.IsActive || _marquee.IsArmed))
{
_marquee.Cancel();
e.Handled = true;
return;
}
if (ctrl && shift && e.Key == Key.N)
{
Vm.NewFolderCommand.Execute(null);
@@ -1741,6 +1909,11 @@ public partial class MainWindow : Window
{
await Vm.PasteAsync().ConfigureAwait(true);
}
else if (ctrl && e.Key == Key.A && e.OriginalSource is not TextBox)
{
FindActiveFileList()?.SelectAll();
e.Handled = true;
}
else if (e.Key == Key.Delete)
{
await DeleteSelectedAsync().ConfigureAwait(true);
@@ -1771,4 +1944,51 @@ public partial class MainWindow : Window
await Vm.OpenSelectedAsync().ConfigureAwait(true);
}
}
private void BeginAddressEdit()
{
Vm.ActivePane.BeginEditPath();
FindAddressBar(Vm.ActivePane)?.FocusEditor();
}
private static bool IsAddressEditor(DependencyObject? origin)
{
for (var current = origin; current is not null;)
{
if (current is ExplorerAddressBar)
{
return origin is TextBox;
}
current = current is Visual
? VisualTreeHelper.GetParent(current)
: LogicalTreeHelper.GetParent(current);
}
return false;
}
private ExplorerAddressBar? FindAddressBar(ExplorerPaneViewModel pane)
=> FindAddressBar(this, pane);
private static ExplorerAddressBar? FindAddressBar(DependencyObject root, ExplorerPaneViewModel pane)
{
var count = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
if (child is ExplorerAddressBar bar && ReferenceEquals(bar.DataContext, pane))
{
return bar;
}
var nested = FindAddressBar(child, pane);
if (nested is not null)
{
return nested;
}
}
return null;
}
}

View File

@@ -0,0 +1,105 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
namespace Explorer.App;
internal static class MaximizedWorkArea
{
private const int WmGetMinMaxInfo = 0x0024;
private const uint MonitorDefaultToNearest = 2;
public static void Hook(Window window)
{
if (PresentationSource.FromVisual(window) is not HwndSource source)
{
return;
}
source.AddHook(WndProc);
}
private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg != WmGetMinMaxInfo)
{
return IntPtr.Zero;
}
var info = Marshal.PtrToStructure<MinMaxInfo>(lParam);
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
if (monitor != IntPtr.Zero)
{
var monitorInfo = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (GetMonitorInfo(monitor, ref monitorInfo))
{
var work = monitorInfo.Work;
var display = monitorInfo.Monitor;
info.MaxPosition = new NativePoint(work.Left - display.Left, work.Top - display.Top);
info.MaxSize = new NativePoint(work.Right - work.Left, work.Bottom - work.Top);
info.MaxTrackSize = info.MaxSize;
}
}
if (HwndSource.FromHwnd(hwnd)?.RootVisual is Window window)
{
var dpi = VisualTreeHelper.GetDpi(window);
info.MinTrackSize = new NativePoint(
(int)Math.Ceiling(window.MinWidth * dpi.DpiScaleX),
(int)Math.Ceiling(window.MinHeight * dpi.DpiScaleY));
}
Marshal.StructureToPtr(info, lParam, fDeleteOld: false);
handled = true;
return IntPtr.Zero;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
public NativePoint(int x, int y)
{
X = x;
Y = y;
}
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeRect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MinMaxInfo
{
public NativePoint Reserved;
public NativePoint MaxSize;
public NativePoint MaxPosition;
public NativePoint MinTrackSize;
public NativePoint MaxTrackSize;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct MonitorInfo
{
public int Size;
public NativeRect Monitor;
public NativeRect Work;
public uint Flags;
}
}

View File

@@ -0,0 +1,23 @@
<UserControl x:Class="Explorer.App.Settings.Pages.AdvancedSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="Advanced" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Technical tool paths. Leave a path empty to search Program Files and PATH."/>
<TextBlock Text="Git" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<TextBlock Style="{StaticResource SettingsPathHelp}"
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
Click="OnBrowseGit" Margin="8,0,0,0"
AutomationProperties.Name="Browse for Git"/>
<TextBox Text="{Binding GitPath, UpdateSourceTrigger=PropertyChanged}"
AutomationProperties.Name="Git path"/>
</DockPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,24 @@
using System.Windows;
using System.Windows.Controls;
using Explorer.App.Settings;
namespace Explorer.App.Settings.Pages;
public partial class AdvancedSettingsPage : UserControl
{
public AdvancedSettingsPage() => InitializeComponent();
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"Git executable",
"Git|git.exe|Executables|*.exe|All files|*.*",
draft.GitPath,
out var path))
{
draft.GitPath = path;
}
}
}

View File

@@ -0,0 +1,19 @@
<UserControl x:Class="Explorer.App.Settings.Pages.AppearanceSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="Appearance" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Visual options for Explorer Workbench."/>
<TextBlock Text="Theme" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal">
<RadioButton Content="Dark" GroupName="SettingsTheme" Margin="0,0,16,0"
IsChecked="{Binding IsDarkTheme, Mode=TwoWay}"/>
<RadioButton Content="Light" GroupName="SettingsTheme"
IsChecked="{Binding IsLightTheme, Mode=TwoWay}"/>
</StackPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class AppearanceSettingsPage : UserControl
{
public AppearanceSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,43 @@
<UserControl x:Class="Explorer.App.Settings.Pages.FileOperationsSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="File Operations" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Queue behavior and tools used for copy, move, archive, and convert work."/>
<TextBlock Text="File operations queue" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<CheckBox Margin="0,0,0,6"
Content="Auto clear queue when done"
IsChecked="{Binding AutoClearQueueWhenDone}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="Finished copy, move, delete, and rename steps are removed automatically. Failed items stay until you dismiss or retry them. The queue is restored after restart."/>
<TextBlock Text="7-Zip" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<TextBlock Style="{StaticResource SettingsPathHelp}"
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,18">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
Click="OnBrowseSevenZip" Margin="8,0,0,0"
AutomationProperties.Name="Browse for 7-Zip"/>
<TextBox Text="{Binding SevenZipPath, UpdateSourceTrigger=PropertyChanged}"
AutomationProperties.Name="7-Zip path"/>
</DockPanel>
<TextBlock Text="FFmpeg" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<TextBlock Style="{StaticResource SettingsPathHelp}"
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
Click="OnBrowseFfmpeg" Margin="8,0,0,0"
AutomationProperties.Name="Browse for FFmpeg"/>
<TextBox Text="{Binding FfmpegPath, UpdateSourceTrigger=PropertyChanged}"
AutomationProperties.Name="FFmpeg path"/>
</DockPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,38 @@
using System.Windows;
using System.Windows.Controls;
using Explorer.App.Settings;
namespace Explorer.App.Settings.Pages;
public partial class FileOperationsSettingsPage : UserControl
{
public FileOperationsSettingsPage() => InitializeComponent();
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"7-Zip executable",
"7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
draft.SevenZipPath,
out var path))
{
draft.SevenZipPath = path;
}
}
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
{
if (DataContext is SettingsDraft draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"FFmpeg executable",
"FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
draft.FfmpegPath,
out var path))
{
draft.FfmpegPath = path;
}
}
}

View File

@@ -0,0 +1,19 @@
<UserControl x:Class="Explorer.App.Settings.Pages.GeneralSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="General" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Startup and application behavior. These options do not change Windows Explorer settings."/>
<TextBlock Text="Background host" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<CheckBox Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"
IsChecked="{Binding BackgroundHostAtLogon}"/>
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class GeneralSettingsPage : UserControl
{
public GeneralSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,51 @@
<UserControl x:Class="Explorer.App.Settings.Pages.IndexingSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="Indexing" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="What Workbench records in the local index. Online-only cloud files are never hydrated."/>
<CheckBox Margin="0,0,0,6"
Content="Include archive contents in the index"
IsChecked="{Binding IndexArchiveContents}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<CheckBox Margin="0,0,0,6"
Content="Automatically index removable drives when they appear"
IsChecked="{Binding AutoIndexRemovable}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
<TextBlock Text="Idle maintenance" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="When you are not using the PC, the background host can hash duplicates, refresh stale local indexes, and capture history. Copy, move, and other jobs you started keep running. Cloud files are never hydrated. Network and removable locations are not scanned automatically."/>
<CheckBox Margin="0,0,0,6"
Content="Enable background maintenance when idle"
IsChecked="{Binding BackgroundMaintenanceWhenIdle}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="The host watches Windows idle time even if this window is closed. Conservative defaults wait 10 minutes and prefer AC power."/>
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
<DockPanel LastChildFill="False" Margin="0,0,0,14">
<ComboBox Width="80" HorizontalAlignment="Left"
ItemsSource="{Binding IdleThresholdChoices}"
SelectedItem="{Binding IdleMaintenanceMinutes}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"
AutomationProperties.Name="Idle threshold in minutes"/>
<TextBlock Text="minutes" VerticalAlignment="Center" Margin="10,0,0,0"
Foreground="{DynamicResource FgMuted}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
</DockPanel>
<CheckBox Margin="0,0,0,6"
Content="Only run expensive maintenance on AC power"
IsChecked="{Binding IdleMaintenanceAcOnly}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
Text="Skips hashing and idle rescans on battery so a laptop is not drained in the background."/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class IndexingSettingsPage : UserControl
{
public IndexingSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,47 @@
<UserControl x:Class="Explorer.App.Settings.Pages.NavigationSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
<StackPanel>
<TextBlock Text="Navigation" Style="{StaticResource SettingsHeading}"
AutomationProperties.HeadingLevel="Level1"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Locations tree, grouping, and what Workbench shows while browsing. These options only change what Explorer Workbench shows and indexes. Windows settings and files are not modified."/>
<TextBlock Text="Locations tree" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<TextBlock Style="{StaticResource SettingsIntro}"
Text="Choose whether network and cloud locations appear as their own top-level items, or are collected under a group next to This PC."/>
<CheckBox Margin="0,0,0,6"
Content="Group network drives under Network"
IsChecked="{Binding GroupNetworkPlaces}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="Mapped letters and UNC shares become children of a Network item. This PC then shows only local and removable drives."/>
<CheckBox Margin="0,0,0,6"
Content="Group cloud locations under Cloud"
IsChecked="{Binding GroupCloudPlaces}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="OneDrive, Google Drive, and Nextcloud become children of a Cloud item. The two options are independent."/>
<CheckBox Margin="0,0,0,6"
Content="Prefer Favorites when synchronizing the locations tree"
IsChecked="{Binding PreferFavoritesInTree}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="Off (default): keep the current tree context. Opening a folder under This PC, Home, Network, or Cloud does not switch the tree to Favorites just because that folder is also pinned. When you opened the folder from a Favorite, the tree stays under Favorites. On: if the folder is under a pinned Favorite, the tree selects that Favorite. Breadcrumbs and the filesystem path are unchanged."/>
<TextBlock Text="Filesystem visibility" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
AutomationProperties.HeadingLevel="Level2"/>
<CheckBox Margin="0,0,0,6"
Content="Show hidden files"
IsChecked="{Binding ShowHiddenFiles}"/>
<TextBlock Style="{StaticResource SettingsHelp}"
Text="Items marked Hidden by Windows. Default matches the current Explorer Workbench listing."/>
<CheckBox Margin="0,0,0,6"
Content="Show protected system locations"
IsChecked="{Binding ShowProtectedSystemLocations}"/>
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
Text="System Volume Information, Recycle Bin, Recovery, and similar locations. When a folder cannot be read, its size is shown as access denied — never as 0 bytes. Administrator rights are detected but never requested automatically."/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class NavigationSettingsPage : UserControl
{
public NavigationSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,20 @@
using Explorer.App.Settings.Pages;
namespace Explorer.App.Settings;
public static class SettingsCatalog
{
public static IReadOnlyList<SettingsCategory> Create()
{
// Storage & Analysis and Network & Cloud stay out of the list until they have settings.
return
[
new(SettingsCategoryId.General, "General", new GeneralSettingsPage()),
new(SettingsCategoryId.Appearance, "Appearance", new AppearanceSettingsPage()),
new(SettingsCategoryId.Navigation, "Navigation", new NavigationSettingsPage()),
new(SettingsCategoryId.FileOperations, "File Operations", new FileOperationsSettingsPage()),
new(SettingsCategoryId.Indexing, "Indexing", new IndexingSettingsPage()),
new(SettingsCategoryId.Advanced, "Advanced", new AdvancedSettingsPage())
];
}
}

View File

@@ -0,0 +1,17 @@
using System.Windows;
namespace Explorer.App.Settings;
public sealed class SettingsCategory
{
public SettingsCategory(SettingsCategoryId id, string title, FrameworkElement page)
{
Id = id;
Title = title;
Page = page;
}
public SettingsCategoryId Id { get; }
public string Title { get; }
public FrameworkElement Page { get; }
}

View File

@@ -0,0 +1,13 @@
namespace Explorer.App.Settings;
public enum SettingsCategoryId
{
General,
Appearance,
Navigation,
FileOperations,
Indexing,
StorageAnalysis,
NetworkCloud,
Advanced
}

View File

@@ -0,0 +1,101 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
namespace Explorer.App.Settings;
public sealed partial class SettingsDraft : ObservableObject
{
[ObservableProperty] private string _theme = "Dark";
[ObservableProperty] private bool _groupNetworkPlaces;
[ObservableProperty] private bool _groupCloudPlaces;
[ObservableProperty] private bool _preferFavoritesInTree;
[ObservableProperty] private bool _showHiddenFiles = true;
[ObservableProperty] private bool _showProtectedSystemLocations;
[ObservableProperty] private bool _autoClearQueueWhenDone;
[ObservableProperty] private bool _indexArchiveContents;
[ObservableProperty] private bool _autoIndexRemovable;
[ObservableProperty] private bool _backgroundHostAtLogon;
[ObservableProperty] private bool _backgroundMaintenanceWhenIdle = true;
[ObservableProperty] private int _idleMaintenanceMinutes = 10;
[ObservableProperty] private bool _idleMaintenanceAcOnly = true;
[ObservableProperty] private string _sevenZipPath = "";
[ObservableProperty] private string _gitPath = "";
[ObservableProperty] private string _ffmpegPath = "";
public IReadOnlyList<int> IdleThresholdChoices { get; } = [5, 10, 30];
public bool IsDarkTheme
{
get => Theme != "Light";
set
{
if (value)
{
Theme = "Dark";
}
}
}
public bool IsLightTheme
{
get => Theme == "Light";
set
{
if (value)
{
Theme = "Light";
}
}
}
public static SettingsDraft From(UiPreferences preferences)
=> new()
{
Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme),
GroupNetworkPlaces = preferences.GroupNetworkPlaces,
GroupCloudPlaces = preferences.GroupCloudPlaces,
PreferFavoritesInTree = preferences.PreferFavoritesInTree,
ShowHiddenFiles = preferences.ShowHiddenFiles,
ShowProtectedSystemLocations = preferences.ShowProtectedSystemLocations,
AutoClearQueueWhenDone = preferences.AutoClearQueueWhenDone,
IndexArchiveContents = preferences.IndexArchiveContents,
AutoIndexRemovable = preferences.AutoIndexRemovable,
BackgroundHostAtLogon = preferences.BackgroundHostAtLogon,
BackgroundMaintenanceWhenIdle = preferences.BackgroundMaintenanceWhenIdle,
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
IdleMaintenanceAcOnly = preferences.IdleMaintenanceAcOnly,
SevenZipPath = preferences.SevenZipPath ?? "",
GitPath = preferences.GitPath ?? "",
FfmpegPath = preferences.FfmpegPath ?? ""
};
public UiPreferences ApplyTo(UiPreferences stored)
=> stored with
{
Theme = UiPreferencesStore.NormalizeTheme(Theme),
GroupNetworkPlaces = GroupNetworkPlaces,
GroupCloudPlaces = GroupCloudPlaces,
PreferFavoritesInTree = PreferFavoritesInTree,
ShowHiddenFiles = ShowHiddenFiles,
ShowProtectedSystemLocations = ShowProtectedSystemLocations,
AutoClearQueueWhenDone = AutoClearQueueWhenDone,
IndexArchiveContents = IndexArchiveContents,
AutoIndexRemovable = AutoIndexRemovable,
BackgroundHostAtLogon = BackgroundHostAtLogon,
BackgroundMaintenanceWhenIdle = BackgroundMaintenanceWhenIdle,
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(IdleMaintenanceMinutes),
IdleMaintenanceAcOnly = IdleMaintenanceAcOnly,
SevenZipPath = EmptyToNull(SevenZipPath),
GitPath = EmptyToNull(GitPath),
FfmpegPath = EmptyToNull(FfmpegPath)
};
partial void OnThemeChanged(string value)
{
OnPropertyChanged(nameof(IsDarkTheme));
OnPropertyChanged(nameof(IsLightTheme));
}
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,25 @@
using System.Windows;
using Microsoft.Win32;
namespace Explorer.App.Settings;
internal static class SettingsPathBrowse
{
public static bool TryPick(Window? owner, string title, string filter, string? current, out string path)
{
var dlg = new OpenFileDialog
{
Title = title,
Filter = filter,
FileName = current ?? ""
};
if (dlg.ShowDialog(owner) == true)
{
path = dlg.FileName;
return true;
}
path = "";
return false;
}
}

View File

@@ -0,0 +1,6 @@
namespace Explorer.App.Settings;
public static class SettingsSession
{
public static SettingsCategoryId? LastCategory { get; set; }
}

View File

@@ -0,0 +1,27 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Explorer.App.Settings;
public sealed partial class SettingsShell : ObservableObject
{
public SettingsShell(SettingsDraft draft, IReadOnlyList<SettingsCategory> categories)
{
Draft = draft;
Categories = categories;
_selectedCategory = Restore(categories);
}
public SettingsDraft Draft { get; }
public IReadOnlyList<SettingsCategory> Categories { get; }
[ObservableProperty] private SettingsCategory _selectedCategory;
partial void OnSelectedCategoryChanged(SettingsCategory value)
=> SettingsSession.LastCategory = value.Id;
private static SettingsCategory Restore(IReadOnlyList<SettingsCategory> categories)
{
var last = SettingsSession.LastCategory;
return categories.FirstOrDefault(c => c.Id == last) ?? categories[0];
}
}

View File

@@ -0,0 +1,25 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="SettingsHeading" TargetType="TextBlock">
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Margin" Value="0,0,0,10"/>
</Style>
<Style x:Key="SettingsIntro" TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
<Style x:Key="SettingsHelp" TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="24,0,0,14"/>
</Style>
<Style x:Key="SettingsPathHelp" TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
</ResourceDictionary>

View File

@@ -3,99 +3,71 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="560"
MinHeight="560" MinWidth="480"
Height="640" Width="820"
MinHeight="480" MinWidth="700"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
<DockPanel Margin="20">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
ResizeMode="CanResize">
<DockPanel Margin="16">
<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" Click="OnCancel"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Appearance" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="Theme" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton x:Name="ThemeDark" Content="Dark" GroupName="Theme" Margin="0,0,16,0"
Checked="OnThemeChanged"/>
<RadioButton x:Name="ThemeLight" Content="Light" GroupName="Theme"
Checked="OnThemeChanged"/>
</StackPanel>
<TextBlock Text="Locations tree" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,12"
Text="Choose whether network and cloud locations appear as their own top-level items, or are collected under a group next to This PC."/>
<CheckBox x:Name="GroupNetwork" Margin="0,0,0,6"
Content="Group network drives under Network"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,14" FontSize="12"
Text="Mapped letters and UNC shares become children of a Network item. This PC then shows only local and removable drives."/>
<CheckBox x:Name="GroupCloud" Margin="0,0,0,6"
Content="Group cloud locations under Cloud"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="OneDrive, Google Drive, and Nextcloud become children of a Cloud item. The two options are independent."/>
<TextBlock Text="Filesystem visibility" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,12"
Text="These options only change what Explorer Workbench shows and indexes. Windows settings and files are not modified."/>
<CheckBox x:Name="ShowHidden" Margin="0,0,0,6"
Content="Show hidden files"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,14" FontSize="12"
Text="Items marked Hidden by Windows. Default matches the current Explorer Workbench listing."/>
<CheckBox x:Name="ShowProtected" Margin="0,0,0,6"
Content="Show protected system locations"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="System Volume Information, Recycle Bin, Recovery, and similar locations. When a folder cannot be read, its size is shown as access denied — never as 0 bytes. Administrator rights are detected but never requested automatically."/>
<TextBlock Text="File operations queue" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="AutoClearQueue" Margin="0,0,0,6"
Content="Auto clear queue when done"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Finished copy, move, delete, and rename steps are removed automatically. Failed items stay until you dismiss or retry them. The queue is restored after restart."/>
<TextBlock Text="Indexing" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<CheckBox x:Name="AutoIndexRemovable" Margin="0,0,0,6"
Content="Automatically index removable drives when they appear"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
<TextBlock Text="Background host" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,18">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseSevenZip" Margin="8,0,0,0"/>
<TextBox x:Name="SevenZipPath"/>
</DockPanel>
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/>
</DockPanel>
<TextBlock Text="FFmpeg" FontSize="16" FontWeight="SemiBold" Margin="0,16,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseFfmpeg" Margin="8,0,0,0"/>
<TextBox x:Name="FfmpegPath"/>
</DockPanel>
</StackPanel>
</ScrollViewer>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" MinWidth="160"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*" MinWidth="360"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Categories" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ListBox x:Name="CategoryList"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
DisplayMemberPath="Title"
AutomationProperties.Name="Settings categories"
KeyboardNavigation.TabIndex="0">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}" CornerRadius="3" Margin="0,1">
<ContentPresenter HorizontalAlignment="Stretch" VerticalAlignment="Center"/>
</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>
</ListBox.ItemContainerStyle>
</ListBox>
</DockPanel>
<GridSplitter Grid.Column="1" Width="8" HorizontalAlignment="Stretch"
Background="{DynamicResource Stroke}"
ResizeBehavior="PreviousAndNext"/>
<Border Grid.Column="2" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}"
BorderThickness="1" Padding="16">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
KeyboardNavigation.TabIndex="1">
<ContentControl Content="{Binding SelectedCategory.Page}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Top"/>
</ScrollViewer>
</Border>
</Grid>
</DockPanel>
</Window>

View File

@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Windows;
using Explorer.Application;
using Explorer.App.Settings;
using Explorer.Hosting;
using Explorer.Presentation.ViewModels;
@@ -8,6 +9,7 @@ namespace Explorer.App;
public partial class SettingsWindow : Window
{
private readonly MainViewModel _vm;
private readonly SettingsDraft _draft;
private readonly string _originalTheme;
public SettingsWindow(MainViewModel vm)
@@ -16,48 +18,25 @@ public partial class SettingsWindow : Window
_vm = vm;
var prefs = vm.CurrentPreferences();
_originalTheme = prefs.Theme;
ThemeDark.IsChecked = prefs.Theme != "Light";
ThemeLight.IsChecked = prefs.Theme == "Light";
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents;
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? "";
FfmpegPath.Text = prefs.FfmpegPath ?? "";
_draft = SettingsDraft.From(prefs);
_draft.PropertyChanged += OnDraftChanged;
DataContext = new SettingsShell(_draft, SettingsCatalog.Create());
Closed += (_, _) => _draft.PropertyChanged -= OnDraftChanged;
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)
{
if (!IsLoaded)
if (e.PropertyName is nameof(SettingsDraft.Theme)
or nameof(SettingsDraft.IsDarkTheme)
or nameof(SettingsDraft.IsLightTheme))
{
return;
_vm.Theme = _draft.Theme;
}
_vm.Theme = ThemeLight.IsChecked == true ? "Light" : "Dark";
}
private async void OnOk(object sender, RoutedEventArgs e)
{
var prefs = _vm.CurrentPreferences() with
{
Theme = ThemeLight.IsChecked == true ? "Light" : "Dark",
GroupNetworkPlaces = GroupNetwork.IsChecked == true,
GroupCloudPlaces = GroupCloud.IsChecked == true,
IndexArchiveContents = IndexArchives.IsChecked == true,
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim(),
FfmpegPath = string.IsNullOrWhiteSpace(FfmpegPath.Text) ? null : FfmpegPath.Text.Trim()
};
var prefs = _draft.ApplyTo(_vm.CurrentPreferences());
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
DialogResult = true;
@@ -94,48 +73,6 @@ public partial class SettingsWindow : Window
}
}
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "7-Zip executable",
Filter = "7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
FileName = SevenZipPath.Text
};
if (dlg.ShowDialog(this) == true)
{
SevenZipPath.Text = dlg.FileName;
}
}
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Git executable",
Filter = "Git|git.exe|Executables|*.exe|All files|*.*",
FileName = GitPath.Text
};
if (dlg.ShowDialog(this) == true)
{
GitPath.Text = dlg.FileName;
}
}
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "FFmpeg executable",
Filter = "FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
FileName = FfmpegPath.Text
};
if (dlg.ShowDialog(this) == true)
{
FfmpegPath.Text = dlg.FileName;
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_vm.Theme = _originalTheme;

View File

@@ -42,6 +42,7 @@ public sealed class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
public int FirstVisibleIndex => _firstVisible;
public int LastVisibleIndex => _lastVisible;
public int Columns => Math.Max(1, _columns);
public ScrollViewer? ScrollOwner { get; set; }
public bool CanVerticallyScroll { get; set; } = true;

View File

@@ -0,0 +1,40 @@
namespace Explorer.Application;
public interface IUserIdleMonitor
{
TimeSpan GetIdleDuration();
}
public interface IPowerSourceMonitor
{
/// <summary>True when on AC or when power state cannot be determined.</summary>
bool IsOnAcPower { get; }
}
public interface IIdleIndexWork
{
bool IsBusy { get; }
bool HasIdleWork { get; }
void SetIdleAllowed(bool allowed);
void EnqueueIdleFullScan(long sourceId);
void EnqueueIdleVerify(long sourceId, string pathRel);
}
public interface IIdleHashWork
{
bool IsPaused { get; }
void Pause();
void Resume();
void BeginUserRequested();
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
}
public interface IHistoryMaintenance
{
Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default);
}
public interface IForegroundWorkSignal
{
bool HasForegroundWork();
}

View File

@@ -0,0 +1,44 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class BackgroundMaintenancePlanner
{
public static Source? NextLocalScan(
IEnumerable<Source> sources,
DateTimeOffset utc,
IReadOnlySet<long>? alreadyQueued = null)
{
var cutoff = utc - TimeSpan.FromDays(AppConstants.IdleRescanAfterDays);
return EligibleLocal(sources, alreadyQueued)
.Where(source => source.Status == SourceStatus.Stale
|| source.LastIndexedUtc is null
|| source.LastIndexedUtc < cutoff)
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
.FirstOrDefault();
}
public static Source? NextLocalVerify(
IEnumerable<Source> sources,
IReadOnlySet<long>? alreadyQueued = null)
=> EligibleLocal(sources, alreadyQueued)
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
.FirstOrDefault();
public static Source? NextHashCollisionSource(
IEnumerable<Source> sources,
IReadOnlySet<long>? alreadyEnqueued = null)
=> EligibleLocal(sources, alreadyEnqueued)
.OrderBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
.FirstOrDefault();
private static IEnumerable<Source> EligibleLocal(IEnumerable<Source> sources, IReadOnlySet<long>? skip)
=> sources.Where(source =>
source.Kind == SourceKind.NtfsLocal
&& source.IsIndexed
&& source.Status is SourceStatus.Online or SourceStatus.Stale
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
&& (skip is null || !skip.Contains(source.Id)));
}

View File

@@ -0,0 +1,97 @@
namespace Explorer.Application;
public enum UserActivityState
{
Active,
Idle
}
public enum MaintenanceSkipReason
{
None,
Disabled,
UserActive,
BelowIdleThreshold,
OnBattery,
ForegroundOperations,
AlreadyRunning
}
public readonly record struct BackgroundWorkInputs(
bool Enabled,
TimeSpan IdleThreshold,
TimeSpan IdleDuration,
bool AcOnly,
bool OnAcPower,
bool ForegroundOperations,
bool RunNowRequested);
public readonly record struct BackgroundWorkDecision(
UserActivityState Activity,
bool MaintenanceAllowed,
MaintenanceSkipReason SkipReason)
{
public static BackgroundWorkDecision Active(MaintenanceSkipReason reason)
=> new(UserActivityState.Active, false, reason);
public static BackgroundWorkDecision IdleBlocked(MaintenanceSkipReason reason)
=> new(UserActivityState.Idle, false, reason);
public static BackgroundWorkDecision Allowed(UserActivityState activity)
=> new(activity, true, MaintenanceSkipReason.None);
}
public static class BackgroundWorkPolicy
{
public static BackgroundWorkDecision Evaluate(BackgroundWorkInputs inputs)
{
var idle = inputs.IdleDuration >= inputs.IdleThreshold && inputs.IdleDuration >= TimeSpan.Zero;
var activity = idle ? UserActivityState.Idle : UserActivityState.Active;
if (inputs.RunNowRequested)
{
if (inputs.ForegroundOperations)
{
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.ForegroundOperations);
}
return BackgroundWorkDecision.Allowed(activity);
}
if (!inputs.Enabled)
{
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.Disabled);
}
if (!idle)
{
return BackgroundWorkDecision.Active(
inputs.IdleDuration <= TimeSpan.Zero
? MaintenanceSkipReason.UserActive
: MaintenanceSkipReason.BelowIdleThreshold);
}
if (inputs.AcOnly && !inputs.OnAcPower)
{
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.OnBattery);
}
if (inputs.ForegroundOperations)
{
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.ForegroundOperations);
}
return BackgroundWorkDecision.Allowed(UserActivityState.Idle);
}
public static string Describe(MaintenanceSkipReason reason) => reason switch
{
MaintenanceSkipReason.Disabled => "disabled",
MaintenanceSkipReason.UserActive => "user is active",
MaintenanceSkipReason.BelowIdleThreshold => "idle threshold not reached",
MaintenanceSkipReason.OnBattery => "on battery",
MaintenanceSkipReason.ForegroundOperations => "foreground file operations",
MaintenanceSkipReason.AlreadyRunning => "maintenance already running",
_ => "none"
};
}

View File

@@ -8,6 +8,7 @@ public static class BrowseHydration
public const int ConstrainedWorkers = 2;
public const int LocalProviderBatch = 64;
public const int ConstrainedProviderBatch = 16;
public const int FirstPublish = 1;
public const int PublishBatch = 48;
public const int NearbyWindow = 32;

View File

@@ -16,6 +16,7 @@ public sealed class BrowseService
private readonly UiPreferencesStore _preferences;
private readonly IElevatedScanService? _elevation;
private readonly IRecycleBinCatalog? _recycle;
private readonly IKnownUserFolderCatalog? _knownFolders;
public BrowseService(
IFileSystemEnumerator enumerator,
@@ -26,7 +27,8 @@ public sealed class BrowseService
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IElevatedScanService? elevation = null,
IRecycleBinCatalog? recycle = null)
IRecycleBinCatalog? recycle = null,
IKnownUserFolderCatalog? knownFolders = null)
{
_enumerator = enumerator;
_volumes = volumes;
@@ -37,6 +39,7 @@ public sealed class BrowseService
_preferences = preferences;
_elevation = elevation;
_recycle = recycle;
_knownFolders = knownFolders;
}
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
@@ -56,6 +59,41 @@ public sealed class BrowseService
return new FolderListing { Path = listing.Path, Items = items };
}
public FolderListing ListHome()
{
var items = (_knownFolders?.ListExisting() ?? [])
.Select(folder => new FileSystemItem
{
FullPath = folder.Path,
Name = folder.Name,
DisplayName = folder.Name,
IsDirectory = true,
Attributes = AttributeFlags.Directory
})
.ToList();
return new FolderListing { Path = LocationRoots.Home, Items = items };
}
public FolderListing ListFavorites()
{
var items = new List<FileSystemItem>();
foreach (var path in FavoriteFolders.Normalize(_preferences.Load().FavoriteFolders))
{
var exists = Directory.Exists(path);
var name = FavoriteDisplayName(path);
items.Add(new FileSystemItem
{
FullPath = path,
Name = name,
DisplayName = exists ? name : $"{name} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory
});
}
return new FolderListing { Path = LocationRoots.Favorites, Items = items };
}
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
{
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
@@ -172,17 +210,6 @@ public sealed class BrowseService
BrowseViewport? viewport = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
{
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
yield return CompleteDelta(archiveListing);
yield break;
}
}
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
{
yield return CompleteDelta(ListRecycleBin(path));
@@ -190,13 +217,35 @@ public sealed class BrowseService
}
var reachable = _volumes.IsPathReachable(path);
if (!reachable)
if (MightBeArchiveListing(path, reachable))
{
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
var archiveSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (archiveSource is { IsIndexed: true })
{
var archiveListing = await TryListArchiveAsync(archiveSource, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
yield return CompleteDelta(archiveListing);
yield break;
}
}
if (!reachable)
{
yield return CompleteDelta(await ListOfflineAsync(archiveSource, path, cancellationToken).ConfigureAwait(false));
yield break;
}
}
else if (!reachable)
{
var offlineSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
yield return CompleteDelta(await ListOfflineAsync(offlineSource, path, cancellationToken).ConfigureAwait(false));
yield break;
}
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
var sourceTask = _sources.FindByPathAsync(path, cancellationToken);
_ = MarkReachableInBackground(path, cancellationToken);
await foreach (var delta in ListLiveProgressiveAsync(path, sourceTask, viewport, cancellationToken).ConfigureAwait(false))
{
yield return delta;
}
@@ -205,6 +254,64 @@ public sealed class BrowseService
public bool CanBrowseArchive(string name)
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
private bool MightBeArchiveListing(string path, bool reachable)
{
if (!_preferences.Load().IndexArchiveContents)
{
return false;
}
if (!reachable)
{
return true;
}
var name = Path.GetFileName(path.TrimEnd('\\', '/'));
return !string.IsNullOrEmpty(name) && ArchiveFormats.IsArchive(name);
}
private async Task MarkReachableInBackground(string path, CancellationToken cancellationToken)
{
try
{
await _sources.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch
{
// Listing already started from the live filesystem.
}
}
private async Task<Dictionary<string, IndexEntry>> LoadIndexAfterSourceAsync(
Task<Source?> sourceTask,
string path,
CancellationToken cancellationToken)
{
Source? source;
try
{
source = await sourceTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
}
if (source is not { IsIndexed: true })
{
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
}
return await LoadIndexChildrenAsync(source, path, cancellationToken).ConfigureAwait(false);
}
private static void ApplyDelta(
List<FileSystemItem> items,
Dictionary<string, int> byPath,
@@ -293,46 +400,43 @@ public sealed class BrowseService
private async IAsyncEnumerable<BrowseDelta> ListLiveProgressiveAsync(
string path,
Source? source,
Task<Source?> sourceTask,
BrowseViewport? viewport,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var prefs = _preferences.Load();
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
var indexTask = source is { IsIndexed: true }
? LoadIndexChildrenAsync(source, path, cancellationToken)
: Task.FromResult(new Dictionary<string, IndexEntry>(StringComparer.Ordinal));
Task<Dictionary<string, IndexEntry>>? indexTask = null;
Task<Dictionary<string, IndexEntry>> KickIndex()
=> indexTask ??= LoadIndexAfterSourceAsync(sourceTask, path, cancellationToken);
var batch = new List<FileSystemItem>(BrowseHydration.PublishBatch);
var all = new List<FileSystemItem>();
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
Dictionary<string, IndexEntry>? indexMap = indexTask.IsCompletedSuccessfully ? indexTask.Result : null;
var sink = new FileEnumerationSink();
var firstFlush = true;
await foreach (var raw in StreamEnumerationAsync(path, sink, cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
if (indexMap is null && indexTask.IsCompleted)
{
indexMap = await indexTask.ConfigureAwait(false);
}
var item = Annotate(raw, sizeFromIndex: false, prefs, probeAccess: false);
var item = Annotate(
raw.Overlay(hydration: raw.Hydration & ~(ItemHydrationFlags.Index | ItemHydrationFlags.Provider)),
sizeFromIndex: false,
prefs,
probeAccess: false);
if (!LocationVisibility.ShouldShow(item.Location, prefs))
{
continue;
}
if (indexMap is not null)
{
item = OverlayIndex(item, indexMap, prefs);
}
item = AttachVolumeSpace(item, spaceCache);
Upsert(all, byPath, item);
batch.Add(item);
if (batch.Count >= BrowseHydration.PublishBatch)
var limit = firstFlush ? BrowseHydration.FirstPublish : BrowseHydration.PublishBatch;
if (batch.Count >= limit)
{
firstFlush = false;
yield return new BrowseDelta
{
Path = path,
@@ -340,6 +444,7 @@ public sealed class BrowseService
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
batch.Clear();
_ = KickIndex();
}
}
@@ -352,11 +457,7 @@ public sealed class BrowseService
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
if (indexMap is null)
{
indexMap = await indexTask.ConfigureAwait(false);
}
var indexMap = await KickIndex().ConfigureAwait(false);
var indexUpdates = OverlayPendingIndex(all, byPath, indexMap, prefs);
if (indexUpdates.Count > 0)
{
@@ -368,6 +469,7 @@ public sealed class BrowseService
};
}
var source = sourceTask.IsCompletedSuccessfully ? sourceTask.Result : await sourceTask.ConfigureAwait(false);
var accessUpdates = await ProbeAccessDeniedAsync(all, byPath, prefs, source?.Kind, cancellationToken)
.ConfigureAwait(false);
if (accessUpdates.Count > 0)
@@ -506,6 +608,7 @@ public sealed class BrowseService
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
fileId: item.FileId ?? entry.FileId,
cloud: cloud,
indexedChildCount: entry.ChildFileCount + entry.ChildDirCount,
hydration: item.Hydration | ItemHydrationFlags.Index);
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
}
@@ -757,6 +860,28 @@ public sealed class BrowseService
return item.Overlay(freeSpaceBytes: space.FreeBytes, capacityBytes: space.CapacityBytes);
}
public int CountVisibleChildren(string path)
{
var live = _enumerator.EnumerateChildrenSafe(path, out var error);
if (error is not null)
{
return -1;
}
var prefs = _preferences.Load();
var count = 0;
foreach (var item in live)
{
var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
if (LocationVisibility.ShouldShow(location, prefs) && !location.IsRecycleBin)
{
count++;
}
}
return count;
}
private IReadOnlyList<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
{
var cache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
@@ -813,6 +938,12 @@ public sealed class BrowseService
.ToList();
}
private static string FavoriteDisplayName(string path)
{
var name = PathRules.GetFileName(path.TrimEnd('\\'));
return string.IsNullOrEmpty(name) ? path : name;
}
private static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];

View File

@@ -0,0 +1,87 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class FavoriteFolders
{
public const int MaxCount = 32;
public static IReadOnlyList<string> Normalize(IEnumerable<string>? paths)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in paths ?? [])
{
if (!TryNormalize(raw, out var path) || !seen.Add(path))
{
continue;
}
result.Add(path);
if (result.Count >= MaxCount)
{
break;
}
}
return result;
}
public static bool TryNormalize(string? raw, out string path)
{
path = "";
if (string.IsNullOrWhiteSpace(raw) || LocationRoots.IsVirtual(raw))
{
return false;
}
var normalized = PathRules.FromExtended(raw).TrimEnd('\\');
if (normalized.Length == 2 && normalized[1] == ':')
{
normalized += "\\";
}
if (normalized.Length == 0)
{
return false;
}
path = normalized;
return true;
}
public static bool Contains(IEnumerable<string>? paths, string? candidate)
=> TryNormalize(candidate, out var path)
&& Normalize(paths).Any(p => p.Equals(path, StringComparison.OrdinalIgnoreCase));
public static IReadOnlyList<string> Add(IEnumerable<string>? current, IEnumerable<string> candidates)
{
var list = Normalize(current).ToList();
var seen = new HashSet<string>(list, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
if (!TryNormalize(candidate, out var path) || !seen.Add(path) || list.Count >= MaxCount)
{
continue;
}
list.Add(path);
}
return list;
}
public static IReadOnlyList<string> Remove(IEnumerable<string>? current, IEnumerable<string> candidates)
{
var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
if (TryNormalize(candidate, out var path))
{
remove.Add(path);
}
}
return Normalize(current).Where(p => !remove.Contains(p)).ToList();
}
}

View File

@@ -0,0 +1,57 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class FolderDisplayRefresh
{
public const int MaxProbes = 8;
public const long MinProbeBytes = 1024 * 1024;
public static IReadOnlyList<FileSystemItem> Pick(
IEnumerable<FileSystemItem> items,
IReadOnlyCollection<string>? visiblePaths = null)
{
var dirs = items
.Where(item => item.IsDirectory
&& item.SizeBytes >= MinProbeBytes
&& item.Cloud?.MayHydrateOnRead != true)
.ToList();
if (dirs.Count == 0)
{
return [];
}
var visible = visiblePaths is { Count: > 0 }
? new HashSet<string>(visiblePaths, StringComparer.OrdinalIgnoreCase)
: null;
IEnumerable<FileSystemItem> ordered = visible is null
? dirs.OrderByDescending(item => item.SizeBytes)
: dirs.Where(item => visible.Contains(item.FullPath))
.OrderByDescending(item => item.SizeBytes)
.Concat(dirs.Where(item => !visible.Contains(item.FullPath)).OrderByDescending(item => item.SizeBytes));
var picked = new List<FileSystemItem>(Math.Min(MaxProbes, dirs.Count));
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in ordered)
{
if (!seen.Add(item.FullPath))
{
continue;
}
picked.Add(item);
if (picked.Count >= MaxProbes)
{
break;
}
}
return picked;
}
public static bool NeedsVerify(int liveChildCount, int indexedChildCount)
=> liveChildCount >= 0 && liveChildCount != indexedChildCount;
public static bool HasIndexCounts(FileSystemItem item)
=> (item.Hydration & ItemHydrationFlags.Index) != 0;
}

View File

@@ -0,0 +1,73 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class FolderStatusText
{
public static string Format(IEnumerable<FileSystemItem> folderItems, IEnumerable<FileSystemItem> selectedItems)
{
var selected = selectedItems as IReadOnlyCollection<FileSystemItem> ?? selectedItems.ToList();
var isSelection = selected.Count > 0;
var totals = Measure(isSelection ? selected : folderItems);
var noun = totals.Count == 1 ? "item" : "items";
var prefix = isSelection
? $"{totals.Count:N0} {noun} selected"
: $"{totals.Count:N0} {noun}";
if (!ShouldShowSize(totals))
{
return prefix;
}
return $"{prefix} · {FormatSize(totals.KnownBytes)}";
}
public static FolderStatusTotals Measure(IEnumerable<FileSystemItem> items)
{
var count = 0;
var files = 0;
var knownBytes = 0L;
var hasKnownSize = false;
foreach (var item in items)
{
count++;
if (!item.IsDirectory)
{
files++;
}
if (item.SizeKnowledge == SizeKnowledge.Unknown)
{
continue;
}
knownBytes += Math.Max(0, item.SizeBytes);
hasKnownSize = true;
}
return new FolderStatusTotals(count, files, knownBytes, hasKnownSize);
}
private static bool ShouldShowSize(FolderStatusTotals totals)
=> totals.HasKnownSize && (totals.KnownBytes > 0 || totals.Files > 0);
private static string FormatSize(long bytes)
{
if (bytes < 0)
{
return string.Empty;
}
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return unit == 0 ? $"{bytes} B" : $"{value:0.##} {units[unit]}";
}
}
public readonly record struct FolderStatusTotals(int Count, int Files, long KnownBytes, bool HasKnownSize);

View File

@@ -0,0 +1,13 @@
namespace Explorer.Application;
public sealed record KnownUserFolder(string Name, string Path, string Glyph);
public interface IKnownUserFolderCatalog
{
IReadOnlyList<KnownUserFolder> ListExisting();
}
public sealed class KnownUserFolderCatalog : IKnownUserFolderCatalog
{
public IReadOnlyList<KnownUserFolder> ListExisting() => [];
}

View File

@@ -0,0 +1,80 @@
namespace Explorer.Application;
public static class MarqueeRange
{
public static IReadOnlyList<int> Stack(double top, double bottom, int count, double itemHeight)
{
if (count <= 0 || itemHeight <= 0)
{
return [];
}
var min = Math.Min(top, bottom);
var max = Math.Max(top, bottom);
if (max - min < 0.5)
{
var index = (int)Math.Floor(min / itemHeight);
return index >= 0 && index < count ? [index] : [];
}
var first = (int)Math.Floor(min / itemHeight);
var last = (int)Math.Ceiling(max / itemHeight) - 1;
first = Math.Clamp(first, 0, count - 1);
last = Math.Clamp(last, 0, count - 1);
if (last < first)
{
return [];
}
var hits = new int[last - first + 1];
for (var i = 0; i < hits.Length; i++)
{
hits[i] = first + i;
}
return hits;
}
public static IReadOnlyList<int> Wrap(double left, double top, double right, double bottom, int count, int columns, double itemWidth, double itemHeight)
{
if (count <= 0 || columns <= 0 || itemWidth <= 0 || itemHeight <= 0)
{
return [];
}
var minX = Math.Min(left, right);
var maxX = Math.Max(left, right);
var minY = Math.Min(top, bottom);
var maxY = Math.Max(top, bottom);
if (maxX - minX < 0.5 && maxY - minY < 0.5)
{
return [];
}
var firstCol = (int)Math.Floor(minX / itemWidth);
var lastCol = (int)Math.Ceiling(maxX / itemWidth) - 1;
var firstRow = (int)Math.Floor(minY / itemHeight);
var lastRow = (int)Math.Ceiling(maxY / itemHeight) - 1;
firstCol = Math.Clamp(firstCol, 0, columns - 1);
lastCol = Math.Clamp(lastCol, 0, columns - 1);
if (lastCol < firstCol || lastRow < firstRow)
{
return [];
}
var hits = new List<int>();
for (var row = firstRow; row <= lastRow; row++)
{
for (var col = firstCol; col <= lastCol; col++)
{
var index = row * columns + col;
if (index >= 0 && index < count)
{
hits.Add(index);
}
}
}
return hits;
}
}

View File

@@ -20,6 +20,8 @@ public sealed class SourceManager
private long _refreshCacheTimestamp;
private static readonly TimeSpan RefreshCacheTtl = TimeSpan.FromSeconds(2);
public event EventHandler<Source>? PresenceChanged;
public SourceManager(
IIndexStore store,
IVolumeService volumes,
@@ -176,6 +178,7 @@ public sealed class SourceManager
&& (source.Kind.IsNetwork() || PathRules.IsUnc(source.LastRootPath))
&& _volumes.IsPathReachable(source.LastRootPath))
{
await BringOnlineAsync(source, cancellationToken).ConfigureAwait(false);
continue;
}
@@ -282,6 +285,77 @@ public sealed class SourceManager
return source;
}
public async Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return null;
}
if (!_store.CanWrite)
{
if (_remote is null)
{
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
}
var current = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (current is { Status: SourceStatus.Online or SourceStatus.Stale or SourceStatus.Scanning })
{
return current;
}
var updated = await _remote.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
var source = updated ?? await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is not null)
{
RaisePresence(source);
}
return source;
}
var found = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (found is null)
{
return null;
}
await BringOnlineAsync(found, cancellationToken).ConfigureAwait(false);
return found;
}
public async Task ConfirmAccessedAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return;
}
var source = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is null || source.Status != SourceStatus.Offline)
{
return;
}
if (_volumes.IsPathReachable(path))
{
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
return;
}
for (var attempt = 0; attempt < 4; attempt++)
{
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
if (_volumes.IsPathReachable(path))
{
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
return;
}
}
}
public bool IsPresentInWindows(Source source)
{
var online = _volumes.EnumerateOnlineVolumes();
@@ -528,6 +602,42 @@ public sealed class SourceManager
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
=> _store.Sources.GetAsync(id, cancellationToken);
private async Task BringOnlineAsync(Source source, CancellationToken cancellationToken)
{
if (source.Status == SourceStatus.Scanning
&& await _store.ScanJobs.HasActiveAsync(source.Id, cancellationToken).ConfigureAwait(false))
{
return;
}
if (source.Status is SourceStatus.Online or SourceStatus.Stale)
{
return;
}
var wasOffline = source.Status == SourceStatus.Offline;
source.Status = SourceStatus.Online;
source.LastSeenUtc = _clock.UtcNow;
source.LastError = null;
await TryIndexWrite(
() => _store.Sources.UpsertAsync(source, cancellationToken),
"mark source online",
source.Id).ConfigureAwait(false);
if (source.IsIndexed && wasOffline)
{
await TryIndexWrite(
() => _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken),
"mark online",
source.Id).ConfigureAwait(false);
}
InvalidateRefreshCache();
RaisePresence(source);
}
private void RaisePresence(Source source)
=> PresenceChanged?.Invoke(this, source);
private async Task<SourceStatus> ResolveReachableStatusAsync(Source source, CancellationToken cancellationToken)
{
if (source.Status == SourceStatus.Scanning

View File

@@ -0,0 +1,102 @@
using Explorer.Domain;
namespace Explorer.Application;
/// <summary>
/// Chooses which locations-tree root should be expanded for a filesystem path.
/// Does not change the canonical path shown in the folder pane or breadcrumbs.
/// </summary>
public readonly record struct TreeRevealCandidate(string Path, bool IsFavorite);
public static class TreeRevealSelector
{
public static TreeRevealCandidate? Choose(
IReadOnlyList<TreeRevealCandidate> candidates,
string targetPath,
bool preferFavorites,
bool currentlyInFavorites)
{
if (string.IsNullOrWhiteSpace(targetPath) || candidates.Count == 0)
{
return null;
}
var matches = new List<TreeRevealCandidate>();
foreach (var candidate in candidates)
{
if (Covers(candidate.Path, targetPath))
{
matches.Add(candidate);
}
}
if (matches.Count == 0)
{
return null;
}
IReadOnlyList<TreeRevealCandidate> pool;
if (preferFavorites || currentlyInFavorites)
{
var favorites = Matches(matches, favorite: true);
pool = favorites.Count > 0 ? favorites : Matches(matches, favorite: false);
}
else
{
var others = Matches(matches, favorite: false);
pool = others.Count > 0 ? others : matches;
}
if (pool.Count == 0)
{
return null;
}
TreeRevealCandidate best = pool[0];
var bestLength = NormalizedLength(best.Path);
for (var i = 1; i < pool.Count; i++)
{
var length = NormalizedLength(pool[i].Path);
if (length > bestLength)
{
best = pool[i];
bestLength = length;
}
}
return best;
}
public static bool Covers(string rootPath, string targetPath)
{
if (string.IsNullOrWhiteSpace(rootPath) || LocationRoots.IsVirtual(rootPath))
{
return false;
}
var root = Normalize(rootPath);
var target = Normalize(targetPath);
return target.Equals(root, StringComparison.OrdinalIgnoreCase)
|| target.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase);
}
private static List<TreeRevealCandidate> Matches(List<TreeRevealCandidate> matches, bool favorite)
{
var result = new List<TreeRevealCandidate>();
foreach (var candidate in matches)
{
if (candidate.IsFavorite == favorite)
{
result.Add(candidate);
}
}
return result;
}
private static string Normalize(string path)
=> PathRules.FromExtended(path).TrimEnd('\\');
private static int NormalizedLength(string path)
=> Normalize(path).Length;
}

View File

@@ -36,8 +36,13 @@ public sealed record UiPreferences(
string? OrganizeDevelopment = null,
bool AutoIndexRemovable = false,
bool BackgroundHostAtLogon = false,
IReadOnlyList<string>? FavoriteFolders = null,
IReadOnlyList<SessionTabState>? SessionTabs = null,
int SessionActiveTab = 0)
int SessionActiveTab = 0,
bool PreferFavoritesInTree = false,
bool BackgroundMaintenanceWhenIdle = true,
int IdleMaintenanceMinutes = 10,
bool IdleMaintenanceAcOnly = true)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -84,11 +89,16 @@ public sealed class UiPreferencesStore
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
"prefer-favorites-in-tree=" + (preferences.PreferFavoritesInTree ? "true" : "false"),
"background-maintenance-when-idle=" + (preferences.BackgroundMaintenanceWhenIdle ? "true" : "false"),
"idle-maintenance-minutes=" + NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
"idle-maintenance-ac-only=" + (preferences.IdleMaintenanceAcOnly ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. FfmpegLines(preferences),
.. OrganizeLines(preferences),
.. LayoutLines(preferences),
.. FavoriteLines(preferences),
.. SessionLines(preferences)
]);
}
@@ -109,6 +119,10 @@ public sealed class UiPreferencesStore
var autoClearQueue = false;
var autoIndexRemovable = false;
var backgroundHostAtLogon = false;
var preferFavoritesInTree = false;
var backgroundMaintenanceWhenIdle = true;
var idleMaintenanceMinutes = 10;
var idleMaintenanceAcOnly = true;
string? sevenZipPath = null;
string? gitPath = null;
string? ffmpegPath = null;
@@ -127,6 +141,7 @@ public sealed class UiPreferencesStore
double? treeWidth = null;
var sessionTabs = new List<SessionTabState>();
var sessionActiveTab = 0;
var favorites = new List<string>();
foreach (var raw in lines)
{
var line = raw.Trim();
@@ -179,6 +194,23 @@ public sealed class UiPreferencesStore
{
backgroundHostAtLogon = IsTrue(value);
}
else if (key.Equals("prefer-favorites-in-tree", StringComparison.OrdinalIgnoreCase))
{
preferFavoritesInTree = IsTrue(value);
}
else if (key.Equals("background-maintenance-when-idle", StringComparison.OrdinalIgnoreCase))
{
backgroundMaintenanceWhenIdle = IsTrue(value);
}
else if (key.Equals("idle-maintenance-minutes", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var minutes))
{
idleMaintenanceMinutes = NormalizeIdleMinutes(minutes);
}
else if (key.Equals("idle-maintenance-ac-only", StringComparison.OrdinalIgnoreCase))
{
idleMaintenanceAcOnly = IsTrue(value);
}
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
@@ -243,6 +275,11 @@ public sealed class UiPreferencesStore
{
treeWidth = ParseDouble(value);
}
else if (key.Equals("favorite", StringComparison.OrdinalIgnoreCase)
&& favorites.Count < FavoriteFolders.MaxCount)
{
favorites.Add(value);
}
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
&& activeTab >= 0)
@@ -266,7 +303,9 @@ public sealed class UiPreferencesStore
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, ffmpegPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly);
}
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
@@ -364,6 +403,14 @@ public sealed class UiPreferencesStore
}
}
private static IEnumerable<string> FavoriteLines(UiPreferences preferences)
{
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
{
yield return "favorite=" + path;
}
}
private static IEnumerable<string> SessionLines(UiPreferences preferences)
{
var tabs = preferences.SessionTabs;
@@ -443,6 +490,9 @@ public sealed class UiPreferencesStore
public static string NormalizeTheme(string? theme)
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
public static int NormalizeIdleMinutes(int minutes)
=> minutes <= 5 ? 5 : minutes >= 30 ? 30 : 10;
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value;

View File

@@ -32,6 +32,8 @@ public sealed class LocalSourceHost : ISourceHost
=> _sources.EnsureForPathAsync(path, cancellationToken);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> _sources.ForgetDisconnectedAsync(path, cancellationToken);
public Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
=> _sources.MarkReachableAsync(path, cancellationToken);
}
public sealed class LocalIndexMutations : IIndexMutations

View File

@@ -0,0 +1,34 @@
namespace Explorer.Contracts;
public sealed record MaintenanceSnapshot(
string Activity,
bool Allowed,
string Message,
string? SkipReason = null)
{
public static MaintenanceSnapshot Empty { get; } = new("Active", false, "");
}
public interface IBackgroundMaintenance
{
event EventHandler<MaintenanceSnapshot>? Changed;
MaintenanceSnapshot Snapshot { get; }
void RunNow();
}
public sealed class NullBackgroundMaintenance : IBackgroundMaintenance
{
public static NullBackgroundMaintenance Instance { get; } = new();
public event EventHandler<MaintenanceSnapshot>? Changed
{
add { }
remove { }
}
public MaintenanceSnapshot Snapshot => MaintenanceSnapshot.Empty;
public void RunNow()
{
}
}

View File

@@ -16,6 +16,8 @@ public interface IIndexingHost
void EnqueueFullScan(long sourceId);
void EnqueueFolderScan(long sourceId, string pathRel);
void EnqueueReconcile(long sourceId, string pathRel);
/// <summary>Shallow reconcile, then descend into child folders whose index no longer matches disk.</summary>
void EnqueueVerify(long sourceId, string pathRel) => EnqueueReconcile(sourceId, pathRel);
void Cancel(long sourceId);
}
@@ -63,6 +65,8 @@ public interface ISourceHost
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
}
public interface IIndexMutations

View File

@@ -159,6 +159,7 @@ public sealed class SourceSnapshot
public interface IHashStore
{
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
Task<bool> HasPendingAsync(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);

View File

@@ -19,5 +19,6 @@ public static class AppConstants
public const int NetworkScanParallelism = 1;
public const int ProgressHzMilliseconds = 100;
public const int MaxArchiveEntries = 8000;
public const int IdleRescanAfterDays = 7;
public static readonly TimeSpan SyncTimestampSkew = TimeSpan.FromSeconds(2);
}

View File

@@ -149,6 +149,7 @@ public sealed class FileSystemItem
public long? CapacityBytes { get; init; }
public bool AvailableToImport { get; init; }
public ItemHydrationFlags Hydration { get; init; } = ItemHydrationFlags.All;
public int IndexedChildCount { get; init; }
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
public FileSystemItem Overlay(
@@ -166,7 +167,8 @@ public sealed class FileSystemItem
long? freeSpaceBytes = null,
long? capacityBytes = null,
bool? availableToImport = null,
ItemHydrationFlags? hydration = null)
ItemHydrationFlags? hydration = null,
int? indexedChildCount = null)
=> new()
{
FullPath = FullPath,
@@ -186,7 +188,8 @@ public sealed class FileSystemItem
FreeSpaceBytes = freeSpaceBytes ?? FreeSpaceBytes,
CapacityBytes = capacityBytes ?? CapacityBytes,
AvailableToImport = availableToImport ?? AvailableToImport,
Hydration = hydration ?? Hydration
Hydration = hydration ?? Hydration,
IndexedChildCount = indexedChildCount ?? IndexedChildCount
};
}
@@ -208,6 +211,8 @@ public sealed record ScanProgress
public long BytesSeen { get; init; }
public int ErrorCount { get; init; }
public ScanJobStatus Status { get; init; }
/// <summary>Index overlay should be reapplied; do not show a full-scan footer.</summary>
public bool IndexRefresh { get; init; }
}
public sealed class FolderListing

View File

@@ -3,10 +3,12 @@ namespace Explorer.Domain;
public static class LocationRoots
{
public const string ThisPc = "This PC";
public const string Home = "Home";
public const string Favorites = "Favorites";
public const string Network = "Network";
public const string Cloud = "Cloud";
public const string RecycleBin = "Recycle Bin";
public static bool IsVirtual(string? path)
=> path is ThisPc or Network or Cloud or RecycleBin;
=> path is ThisPc or Home or Favorites or Network or Cloud or RecycleBin;
}

View File

@@ -1,3 +1,4 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -6,7 +7,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService, ITransferHost
public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroundWorkSignal
{
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;
@@ -41,6 +42,15 @@ public sealed class TransferQueue : BackgroundService, ITransferHost
public bool IsPaused => _queuePaused;
public bool HasForegroundWork()
{
lock (_gate)
{
return _jobs.Any(j => j.Status is TransferStatus.Queued or TransferStatus.Running
or TransferStatus.Cancelling or TransferStatus.Waiting);
}
}
public IReadOnlyList<TransferJob> Snapshot()
{
lock (_gate)

View File

@@ -22,6 +22,14 @@ public static class ExplorerHostClientServices
services.AddSingleton(workbench.Transfers);
services.AddSingleton(workbench.Sources);
services.AddSingleton(workbench.Mutations);
if (workbench is IBackgroundMaintenance maintenance)
{
services.AddSingleton(maintenance);
}
else
{
services.TryAddSingleton<IBackgroundMaintenance>(_ => NullBackgroundMaintenance.Instance);
}
services.AddSingleton(workbench as ICloudOverlay ?? NullCloudOverlay.Instance);
if (workbench is IHostConnection connection)
{
@@ -52,6 +60,7 @@ public static class ExplorerHostClientServices
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<IKnownUserFolderCatalog, WindowsKnownUserFolderCatalog>();
services.AddSingleton(sp => new SourceManager(
sp.GetRequiredService<IIndexStore>(),
sp.GetRequiredService<IVolumeService>(),

View File

@@ -8,7 +8,7 @@ using Explorer.Plugin.Abstractions;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IAsyncDisposable
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IAsyncDisposable
{
private NamedPipeClientStream _pipe;
private StreamWriter _writer;
@@ -24,6 +24,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
private readonly MutationProxy _mutations;
private bool _disposed;
private bool _suppressRestart;
private MaintenanceSnapshot _maintenance = MaintenanceSnapshot.Empty;
private WorkbenchPipeClient(NamedPipeClientStream pipe, WorkbenchIpcOptions options)
{
@@ -44,6 +45,11 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
public IIndexMutations Mutations => _mutations;
public bool IsConnected => !_disposed && _pipe.IsConnected;
public event EventHandler<string>? StatusChanged;
public event EventHandler<MaintenanceSnapshot>? Changed;
public MaintenanceSnapshot Snapshot => _maintenance;
public void RunNow() => Call("Maintenance.RunNow");
public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options,
@@ -207,8 +213,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
catch (IOException) { }
}
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
internal IpcEnvelope Call(string op, long? n = null, string? s = null, bool? flag = null)
=> CallAsync(op, CancellationToken.None, n, s, flag: flag).GetAwaiter().GetResult();
internal async Task<IpcEnvelope> CallAsync(
string op,
@@ -218,16 +224,20 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
string? dest = null,
string[]? paths = null,
bool? flag = null,
string? payload = null)
string? payload = null,
TimeSpan? callTimeout = null)
{
var timeout = callTimeout ?? TimeSpan.FromSeconds(15);
try
{
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload, timeout)
.ConfigureAwait(false);
}
catch (Exception ex) when (!_disposed && !_suppressRestart && ex is IOException or ObjectDisposedException)
{
await RecycleAsync(cancellationToken).ConfigureAwait(false);
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload, timeout)
.ConfigureAwait(false);
}
}
@@ -239,7 +249,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
string? dest,
string[]? paths,
bool? flag,
string? payload)
string? payload,
TimeSpan callTimeout)
{
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -272,7 +283,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
_send.Release();
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var timeout = new CancellationTokenSource(callTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
try
@@ -285,6 +296,10 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
return reply;
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"Host call '{op}' timed out after {callTimeout.TotalSeconds:0}s.");
}
finally
{
_pending.TryRemove(id, out _);
@@ -339,6 +354,17 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
internal void RaiseChanged() => _transfers.RaiseChanged();
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
private void RaiseMaintenance(IpcEnvelope envelope)
{
var snapshot = ReadPayload<MaintenanceSnapshot>(envelope.Payload)
?? new MaintenanceSnapshot(
envelope.Flag == true ? "Idle" : "Active",
envelope.Flag == true,
envelope.S ?? "");
_maintenance = snapshot;
Changed?.Invoke(this, snapshot);
}
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
@@ -378,6 +404,9 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
case "Indexing.Progress" when envelope.Progress is not null:
RaiseProgress(envelope.Progress);
break;
case "Maintenance.Status":
RaiseMaintenance(envelope);
break;
case "Transfers.Changed":
RaiseChanged();
break;
@@ -447,6 +476,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
public void EnqueueReconcile(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
public void EnqueueVerify(long sourceId, string pathRel)
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel, flag: true);
public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
}
@@ -506,6 +537,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
public Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
=> CallSource("Sources.MarkReachable", path, cancellationToken);
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
}

View File

@@ -13,22 +13,42 @@ public static class WorkbenchHostConnector
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var started = Stopwatch.GetTimestamp();
var options = new WorkbenchIpcOptions();
logger?.LogInformation("Connecting to named pipe {Pipe}", options.PipeName);
if (await EnsureHostAsync(timeout, logger, cancellationToken).ConfigureAwait(false))
if (!await EnsureHostAsync(timeout, logger, cancellationToken).ConfigureAwait(false))
{
try
{
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(2), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
}
return null;
}
return null;
WorkbenchPipeClient? client = null;
try
{
client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(2), cancellationToken)
.ConfigureAwait(false);
var readyTimeout = timeout - Stopwatch.GetElapsedTime(started);
if (readyTimeout < TimeSpan.FromSeconds(1))
{
readyTimeout = TimeSpan.FromSeconds(1);
}
logger?.LogInformation(
"Waiting until the background host has finished starting ({Timeout}s)",
Math.Ceiling(readyTimeout.TotalSeconds));
await client.CallAsync("Host.Ready", cancellationToken, callTimeout: readyTimeout)
.ConfigureAwait(false);
return client;
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
if (client is not null)
{
await client.DisposeAsync().ConfigureAwait(false);
}
return null;
}
}
public static async Task<bool> EnsureHostAsync(

View File

@@ -0,0 +1,266 @@
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackgroundMaintenance
{
private readonly IUserIdleMonitor _idle;
private readonly IPowerSourceMonitor _power;
private readonly UiPreferencesStore _preferences;
private readonly IForegroundWorkSignal _foreground;
private readonly IIdleIndexWork _indexing;
private readonly IIdleHashWork _hash;
private readonly IHistoryMaintenance _history;
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
private readonly HashSet<long> _collisionEnqueued = [];
private readonly HashSet<long> _idleScanQueued = [];
private readonly HashSet<long> _idleVerified = [];
private readonly object _gate = new();
private volatile bool _runNow;
private UserActivityState _lastActivity = UserActivityState.Active;
private bool _lastAllowed;
private MaintenanceSkipReason _lastSkip = MaintenanceSkipReason.UserActive;
private bool _hadMaintenance;
private bool _loggedIdleComplete;
private MaintenanceSnapshot _snapshot = MaintenanceSnapshot.Empty;
private string? _workMessage;
public BackgroundMaintenanceCoordinator(
IUserIdleMonitor idle,
IPowerSourceMonitor power,
UiPreferencesStore preferences,
IForegroundWorkSignal foreground,
IIdleIndexWork indexing,
IIdleHashWork hash,
IHistoryMaintenance history,
IIndexStore store,
IVolumeService volumes,
ILogger<BackgroundMaintenanceCoordinator> logger)
{
_idle = idle;
_power = power;
_preferences = preferences;
_foreground = foreground;
_indexing = indexing;
_hash = hash;
_history = history;
_store = store;
_volumes = volumes;
_logger = logger;
}
public event EventHandler<MaintenanceSnapshot>? Changed;
public MaintenanceSnapshot Snapshot
{
get { lock (_gate) { return _snapshot; } }
}
public void RunNow() => _runNow = true;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_hash.Pause();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await TickAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Background maintenance tick failed");
}
}
}
internal async Task TickAsync(CancellationToken cancellationToken)
{
var prefs = _preferences.Load();
var idleFor = _idle.GetIdleDuration();
var hashPending = await _hash.HasPendingAsync(cancellationToken).ConfigureAwait(false);
var inputs = new BackgroundWorkInputs(
prefs.BackgroundMaintenanceWhenIdle,
TimeSpan.FromMinutes(UiPreferencesStore.NormalizeIdleMinutes(prefs.IdleMaintenanceMinutes)),
idleFor,
prefs.IdleMaintenanceAcOnly,
_power.IsOnAcPower,
_foreground.HasForegroundWork(),
_runNow);
var decision = BackgroundWorkPolicy.Evaluate(inputs);
_indexing.SetIdleAllowed(decision.MaintenanceAllowed);
if (decision.Activity != _lastActivity)
{
_logger.LogDebug("{From} -> {To}", _lastActivity, decision.Activity);
_lastActivity = decision.Activity;
}
if (!decision.MaintenanceAllowed)
{
_idleScanQueued.Clear();
_idleVerified.Clear();
if (!_hash.IsPaused)
{
_hash.Pause();
}
if (_lastAllowed)
{
_logger.LogDebug("maintenance paused ({Reason})", BackgroundWorkPolicy.Describe(decision.SkipReason));
}
else if (decision.SkipReason != _lastSkip)
{
_logger.LogDebug("maintenance skipped ({Reason})", BackgroundWorkPolicy.Describe(decision.SkipReason));
}
Publish(decision, PauseMessage(decision));
_lastAllowed = false;
_lastSkip = decision.SkipReason;
return;
}
if (!_lastAllowed)
{
_logger.LogDebug(_hadMaintenance ? "maintenance resumed" : "maintenance started");
_loggedIdleComplete = false;
}
_lastAllowed = true;
_lastSkip = MaintenanceSkipReason.None;
_hadMaintenance = true;
if (_indexing.IsBusy || _indexing.HasIdleWork)
{
if (!_hash.IsPaused)
{
_hash.Pause();
}
_workMessage = _indexing.HasIdleWork
? (_workMessage ?? "Scanning")
: "Idle maintenance";
Publish(decision, _workMessage);
return;
}
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var verify = BackgroundMaintenancePlanner.NextLocalVerify(sources, _idleVerified);
if (verify is not null && verify.LastRootPath is not null && _volumes.IsPathReachable(verify.LastRootPath))
{
if (!_hash.IsPaused)
{
_hash.Pause();
}
_idleVerified.Add(verify.Id);
_indexing.EnqueueIdleVerify(verify.Id, "");
_runNow = false;
_workMessage = "Idle maintenance · checking " + verify.DisplayName;
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
Publish(decision, _workMessage);
return;
}
var scan = BackgroundMaintenancePlanner.NextLocalScan(sources, DateTimeOffset.UtcNow, _idleScanQueued);
if (scan is not null && scan.LastRootPath is not null && _volumes.IsPathReachable(scan.LastRootPath))
{
if (!_hash.IsPaused)
{
_hash.Pause();
}
_idleScanQueued.Add(scan.Id);
_indexing.EnqueueIdleFullScan(scan.Id);
_runNow = false;
_workMessage = "Scanning " + scan.DisplayName;
_logger.LogDebug("maintenance started scan {Source}", scan.DisplayName);
Publish(decision, _workMessage);
return;
}
_hash.Resume();
if (hashPending)
{
_runNow = false;
_workMessage = "Idle maintenance";
Publish(decision, _workMessage);
return;
}
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
{
_runNow = false;
_workMessage = "Idle maintenance";
_logger.LogDebug("maintenance completed (history)");
Publish(decision, _workMessage);
return;
}
var hashSource = BackgroundMaintenancePlanner.NextHashCollisionSource(sources, _collisionEnqueued);
if (hashSource is not null)
{
_collisionEnqueued.Add(hashSource.Id);
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
_workMessage = "Idle maintenance";
Publish(decision, _workMessage);
return;
}
_runNow = false;
_workMessage = "Idle maintenance";
if (!_loggedIdleComplete)
{
_logger.LogDebug("maintenance completed");
_loggedIdleComplete = true;
}
Publish(decision, _workMessage);
}
private string PauseMessage(BackgroundWorkDecision decision)
{
if (decision.SkipReason == MaintenanceSkipReason.ForegroundOperations)
{
return _hadMaintenance ? "Paused because a file operation is running" : "";
}
if (decision.Activity == UserActivityState.Active && _hadMaintenance)
{
return "Paused because user is active";
}
return "";
}
private void Publish(BackgroundWorkDecision decision, string message)
{
var snapshot = new MaintenanceSnapshot(
decision.Activity.ToString(),
decision.MaintenanceAllowed,
message,
decision.SkipReason == MaintenanceSkipReason.None ? null : BackgroundWorkPolicy.Describe(decision.SkipReason));
lock (_gate)
{
if (_snapshot == snapshot)
{
return;
}
_snapshot = snapshot;
}
Changed?.Invoke(this, snapshot);
}
}

View File

@@ -56,6 +56,9 @@ public static class ExplorerHostServices
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<IKnownUserFolderCatalog, WindowsKnownUserFolderCatalog>();
services.AddSingleton<IUserIdleMonitor, WindowsUserIdleMonitor>();
services.AddSingleton<IPowerSourceMonitor, WindowsPowerSourceMonitor>();
services.AddSingleton(sp => new SourceManager(
sp.GetRequiredService<IIndexStore>(),
sp.GetRequiredService<IVolumeService>(),
@@ -85,6 +88,7 @@ public static class ExplorerHostServices
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>());
services.AddSingleton<IForegroundWorkSignal>(sp => sp.GetRequiredService<TransferQueue>());
services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddSingleton<IIndexMutations>(sp => new LocalIndexMutations(sp.GetRequiredService<IIndexStore>()));
services.AddSingleton<IWorkbenchHost>(sp => new WorkbenchHost(
@@ -94,10 +98,15 @@ public static class ExplorerHostServices
sp.GetRequiredService<IIndexMutations>()));
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<IIdleIndexWork>(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddSingleton<IIdleHashWork>(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddSingleton<IHistoryMaintenance>(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddSingleton<BackgroundMaintenanceCoordinator>();
services.AddSingleton<IBackgroundMaintenance>(sp => sp.GetRequiredService<BackgroundMaintenanceCoordinator>());
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(sp => sp.GetRequiredService<BackgroundMaintenanceCoordinator>());
services.AddHostedService<WatcherHostedService>();
return services;
}

View File

@@ -200,7 +200,17 @@ public sealed class WorkbenchPipeServer : BackgroundService
void OnFinished(object? sender, TransferJob job)
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
void OnMaintenance(object? sender, MaintenanceSnapshot snapshot)
=> _ = WriteAsync(writer, new IpcEnvelope
{
Evt = "Maintenance.Status",
S = snapshot.Message,
Flag = snapshot.Allowed,
Payload = JsonSerializer.Serialize(snapshot, WorkbenchIpc.Json)
}, stoppingToken);
var hooked = false;
IBackgroundMaintenance? hookedMaintenance = null;
try
{
while (!stoppingToken.IsCancellationRequested)
@@ -235,6 +245,12 @@ public sealed class WorkbenchPipeServer : BackgroundService
Workbench.Indexing.ProgressChanged += OnProgress;
Workbench.Transfers.Changed += OnChanged;
Workbench.Transfers.JobFinished += OnFinished;
if (_services.GetService<IBackgroundMaintenance>() is { } maintenance)
{
maintenance.Changed += OnMaintenance;
hookedMaintenance = maintenance;
}
hooked = true;
}
@@ -251,6 +267,11 @@ public sealed class WorkbenchPipeServer : BackgroundService
Workbench.Transfers.Changed -= OnChanged;
Workbench.Transfers.JobFinished -= OnFinished;
}
if (hookedMaintenance is not null)
{
hookedMaintenance.Changed -= OnMaintenance;
}
}
}
@@ -283,6 +304,19 @@ public sealed class WorkbenchPipeServer : BackgroundService
{
case "Ping":
return reply;
case "Host.Ready":
return reply;
case "Maintenance.RunNow":
_services.GetService<IBackgroundMaintenance>()?.RunNow();
return reply;
case "Maintenance.Snapshot":
{
var snap = _services.GetService<IBackgroundMaintenance>()?.Snapshot ?? MaintenanceSnapshot.Empty;
reply.S = snap.Message;
reply.Flag = snap.Allowed;
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
return reply;
}
case "Host.Shutdown":
await RequestShutdownAsync().ConfigureAwait(false);
return reply;
@@ -293,7 +327,15 @@ public sealed class WorkbenchPipeServer : BackgroundService
Workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
return reply;
case "Indexing.EnqueueReconcile":
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
if (request.Flag == true)
{
Workbench.Indexing.EnqueueVerify(request.N ?? 0, request.S ?? "");
}
else
{
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
}
return reply;
case "Indexing.Cancel":
Workbench.Indexing.Cancel(request.N ?? 0);
@@ -381,6 +423,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
case "Sources.Forget":
reply.Flag = await Workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.MarkReachable":
reply.Source = await Workbench.Sources.MarkReachableAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Mutations.UpsertSyncProfile":
reply.N = await Workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
return reply;
@@ -401,6 +446,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
return reply;
case "Mutations.EnqueueHashCollisions":
await Workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
_services.GetService<IIdleHashWork>()?.BeginUserRequested();
return reply;
case "Mutations.UpsertRelation":
await Workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);

View File

@@ -26,18 +26,33 @@ public sealed class FolderReconciler
_preferences = preferences;
}
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
public Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
=> ReconcileAsync(source, pathRel, cancellationToken, verifyChildren: false);
public Task<bool> ReconcileAsync(
Source source,
string pathRel,
CancellationToken cancellationToken,
bool verifyChildren)
=> ReconcileCoreAsync(source, pathRel, verifyChildren, remaining: 48, cancellationToken);
private async Task<bool> ReconcileCoreAsync(
Source source,
string pathRel,
bool verifyChildren,
int remaining,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(source.LastRootPath))
{
return;
return false;
}
var full = PathRules.Combine(source.LastRootPath, pathRel);
var parent = await _store.Entries.GetByPathAsync(source.Id, pathRel, cancellationToken).ConfigureAwait(false);
if (parent is null)
{
return;
return false;
}
if (!parent.IsDirectory)
@@ -48,20 +63,20 @@ public sealed class FolderReconciler
{
await _archives.ExpandIfNeededAsync(source, parent, DateTimeOffset.UtcNow, source.ScanGeneration, cancellationToken)
.ConfigureAwait(false);
return false;
}
else
{
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
}
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
return true;
}
return;
return false;
}
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
{
return;
return false;
}
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
@@ -73,6 +88,7 @@ public sealed class FolderReconciler
var archivesToExpand = new List<IndexEntry>();
var archivesToTomb = new List<string>();
var prefs = _preferences?.Load() ?? UiPreferences.Default;
var changed = false;
await _store.RunWriteAsync(async s =>
{
@@ -118,11 +134,13 @@ public sealed class FolderReconciler
var delta = item.SizeBytes - oldSize;
if (existing is null)
{
changed = true;
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, item.SizeBytes, 1, 0, cancellationToken)
.ConfigureAwait(false);
}
else if (delta != 0)
{
changed = true;
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
.ConfigureAwait(false);
}
@@ -134,6 +152,7 @@ public sealed class FolderReconciler
}
else if (existing is null)
{
changed = true;
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
.ConfigureAwait(false);
}
@@ -143,8 +162,13 @@ public sealed class FolderReconciler
{
if (!liveNames.Contains(old.NameNorm))
{
changed = true;
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
if (!old.IsDirectory && ArchiveFormats.IsArchive(old.Name))
if (old.IsDirectory)
{
archivesToTomb.Add(old.PathRel);
}
else if (ArchiveFormats.IsArchive(old.Name))
{
archivesToTomb.Add(old.PathRel);
}
@@ -166,5 +190,59 @@ public sealed class FolderReconciler
.ConfigureAwait(false);
}
}
if (!verifyChildren || remaining <= 0)
{
return changed;
}
changed |= await WalkSizedChildrenAsync(source, indexed, liveNames, remaining, cancellationToken)
.ConfigureAwait(false);
return changed;
}
private async Task<bool> WalkSizedChildrenAsync(
Source source,
IReadOnlyList<IndexEntry> indexed,
HashSet<string> liveNames,
int remaining,
CancellationToken cancellationToken)
{
var changed = false;
foreach (var child in indexed
.Where(e => e.IsDirectory
&& liveNames.Contains(e.NameNorm)
&& (e.AggregateSize > 0 || e.ChildFileCount > 0 || e.ChildDirCount > 0))
.OrderByDescending(e => e.AggregateSize))
{
if (remaining <= 0)
{
break;
}
remaining--;
var full = PathRules.Combine(source.LastRootPath!, child.PathRel);
var liveKids = _enumerator.EnumerateChildrenSafe(full, out var error);
if (error is not null)
{
continue;
}
var indexedKids = await _store.Entries.GetChildrenAsync(source.Id, child.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var childLiveNames = new HashSet<string>(liveKids.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
var matches = liveKids.Count == indexedKids.Count && indexedKids.All(e => childLiveNames.Contains(e.NameNorm));
if (!matches)
{
changed |= await ReconcileCoreAsync(source, child.PathRel, verifyChildren: true, remaining, cancellationToken)
.ConfigureAwait(false);
continue;
}
changed |= await WalkSizedChildrenAsync(source, indexedKids, childLiveNames, remaining, cancellationToken)
.ConfigureAwait(false);
}
return changed;
}
}

View File

@@ -1,4 +1,5 @@
using System.Threading.Channels;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -7,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdleIndexWork
{
private readonly IIndexStore _store;
private readonly FilesystemScanner _scanner;
@@ -17,8 +18,10 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
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 Dictionary<long, RunningWork> _running = new();
private readonly object _gate = new();
private volatile bool _idleAllowed;
private int _idleQueued;
public event EventHandler<ScanProgress>? ProgressChanged;
@@ -40,25 +43,89 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
_logger = logger;
}
public bool IsBusy
{
get
{
lock (_gate)
{
return _running.Values.Any(w => w.Origin != IndexWorkOrigin.Periodic);
}
}
}
public bool HasIdleWork
{
get
{
lock (_gate)
{
return _idleQueued > 0 || _running.Values.Any(w => w.Origin == IndexWorkOrigin.Idle);
}
}
}
public void SetIdleAllowed(bool allowed)
{
_idleAllowed = allowed;
if (allowed)
{
return;
}
lock (_gate)
{
foreach (var work in _running.Values.Where(w => w.Origin == IndexWorkOrigin.Idle))
{
work.Cts.Cancel();
}
}
}
public void EnqueueFullScan(long sourceId)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null));
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null, IndexWorkOrigin.User));
public void EnqueueIdleFullScan(long sourceId)
=> EnqueueIdle(new IndexWork(WorkKind.Full, sourceId, null, IndexWorkOrigin.Idle));
public void EnqueueIdleVerify(long sourceId, string pathRel)
=> EnqueueIdle(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.Idle, VerifyChildren: true));
private void EnqueueIdle(IndexWork work)
{
lock (_gate)
{
_idleQueued++;
}
if (!_work.Writer.TryWrite(work))
{
lock (_gate)
{
_idleQueued = Math.Max(0, _idleQueued - 1);
}
}
}
public void EnqueueFolderScan(long sourceId, string pathRel)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel));
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel, IndexWorkOrigin.User));
public void EnqueueReconcile(long sourceId, string pathRel)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel));
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.Watcher, VerifyChildren: false));
public void EnqueueVerify(long sourceId, string pathRel)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.User, VerifyChildren: true));
public void EnqueueUsn(long sourceId)
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null));
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null, IndexWorkOrigin.Periodic));
public void Cancel(long sourceId)
{
lock (_gate)
{
if (_running.TryGetValue(sourceId, out var cts))
if (_running.TryGetValue(sourceId, out var work))
{
cts.Cancel();
work.Cts.Cancel();
}
}
}
@@ -87,6 +154,19 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
private async Task RunAsync(IndexWork item, CancellationToken stoppingToken)
{
if (item.Origin == IndexWorkOrigin.Idle)
{
lock (_gate)
{
_idleQueued = Math.Max(0, _idleQueued - 1);
}
if (!_idleAllowed)
{
return;
}
}
var source = await _store.Sources.GetAsync(item.SourceId, stoppingToken).ConfigureAwait(false);
if (source is null)
{
@@ -96,7 +176,7 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
lock (_gate)
{
_running[item.SourceId] = linked;
_running[item.SourceId] = new RunningWork(linked, item.Origin);
}
try
@@ -116,7 +196,22 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
case WorkKind.Reconcile:
if (item.PathRel is not null)
{
await _reconciler.ReconcileAsync(source, item.PathRel, linked.Token).ConfigureAwait(false);
var changed = await _reconciler
.ReconcileAsync(source, item.PathRel, linked.Token, item.VerifyChildren)
.ConfigureAwait(false);
if (changed)
{
var full = source.LastRootPath is null
? item.PathRel
: PathRules.Combine(source.LastRootPath, item.PathRel);
ProgressChanged?.Invoke(this, new ScanProgress
{
SourceId = source.Id,
CurrentPath = full,
Status = ScanJobStatus.Done,
IndexRefresh = true
});
}
}
break;
@@ -152,5 +247,14 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
private enum WorkKind { Full, Folder, Reconcile, Usn }
private readonly record struct IndexWork(WorkKind Kind, long SourceId, string? PathRel);
private enum IndexWorkOrigin { User, Watcher, Periodic, Idle }
private readonly record struct IndexWork(
WorkKind Kind,
long SourceId,
string? PathRel,
IndexWorkOrigin Origin,
bool VerifyChildren = false);
private sealed record RunningWork(CancellationTokenSource Cts, IndexWorkOrigin Origin);
}

View File

@@ -116,7 +116,7 @@ public sealed class UsnChangeApplier
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
if (existing.IsDirectory || ArchiveFormats.IsArchive(existing.Name))
{
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
.ConfigureAwait(false);
@@ -140,7 +140,7 @@ public sealed class UsnChangeApplier
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
if (existing.IsDirectory || ArchiveFormats.IsArchive(existing.Name))
{
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
.ConfigureAwait(false);

View File

@@ -24,6 +24,10 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
private bool _didAutoSort;
private int _browseGeneration;
private Dictionary<string, FolderItemViewModel>? _rows;
private bool _skipReconcileOnComplete;
private readonly HashSet<string> _probedFolders = new(StringComparer.OrdinalIgnoreCase);
private bool _probeBusy;
private bool _probeAgain;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
@@ -38,6 +42,9 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _gitBadge = "";
[ObservableProperty] private bool _hasGitRepo;
[ObservableProperty] private string _listingStatus = "0 items";
[ObservableProperty] private bool _isEditingPath;
[ObservableProperty] private string _pathEditText = "This PC";
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
public ExplorerPaneViewModel(
@@ -56,6 +63,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_thumbnails = thumbnails;
Items = new RangeObservableCollection<FolderItemViewModel>();
SelectedItems = [];
Items.CollectionChanged += (_, _) => RefreshListingStatus();
SelectedItems.CollectionChanged += (_, _) => RefreshListingStatus();
}
partial void OnGitBadgeChanged(string value) => OnPropertyChanged(nameof(HasGitBadge));
@@ -79,13 +88,18 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_userChoseSort = false;
_awaitingSizeSort = false;
_didAutoSort = false;
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
_probedFolders.Clear();
_probeAgain = false;
_viewport = new BrowseViewport();
IsBusy = true;
StatusMessage = null;
GitBadge = "";
HasGitRepo = false;
try
{
CurrentPath = path;
PathEditText = path;
IsEditingPath = false;
if (addHistory)
{
_history.Navigate(path);
@@ -98,19 +112,16 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
if (LocationRoots.IsVirtual(path))
{
GitBadge = "";
HasGitRepo = false;
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
return;
}
Items.Clear();
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
var sizeFromIndex = CurrentSource is { IsIndexed: true };
SelectedItems.Clear();
CurrentSource = null;
ShowIndexBanner = false;
IndexBannerText = null;
var sourceTask = _sources.FindByPathAsync(path, ct);
var replaceListing = true;
var published = false;
await foreach (var delta in _browse.ListProgressiveAsync(path, _viewport, ct).ConfigureAwait(true))
@@ -120,6 +131,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return;
}
if (CurrentSource is null && sourceTask.IsCompleted)
{
ApplyCurrentSource(sourceTask);
}
var sizeFromIndex = CurrentSource is { IsIndexed: true };
IsOffline = delta.IsOffline;
if (delta.Error is not null)
{
@@ -131,24 +148,56 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
var rows = delta.Added
.Select(item => new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory))
.ToList();
foreach (var row in rows)
if (replaceListing)
{
_rows[row.FullPath] = row;
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
_rows[row.FullPath] = row;
}
Items.ReplaceAll(rows);
replaceListing = false;
}
else if (_rows is not null)
{
foreach (var row in rows)
{
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
}
Items.AddRange(rows);
published = true;
IsBusy = false;
RefreshListingStatus();
}
else if (delta.EnumerationComplete && replaceListing)
{
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
Items.Clear();
replaceListing = false;
published = true;
IsBusy = false;
RefreshListingStatus();
}
if (delta.Updated.Count > 0)
{
ApplyUpdates(delta.Updated, sizeFromIndex);
RefreshListingStatus();
}
if (delta.EnumerationComplete)
{
IsBusy = false;
if (!sourceTask.IsCompleted)
{
await sourceTask.ConfigureAwait(true);
}
ApplyCurrentSource(sourceTask);
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
@@ -160,11 +209,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_ = ApplyGitAsync(path, ct);
KickThumbnails();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
if (!_skipReconcileOnComplete
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
KickFolderProbes(src, ct);
}
}
@@ -172,6 +223,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|| delta.HydrationComplete)
{
TryAutoSort(sizeMetadataReady: true);
if (!_skipReconcileOnComplete
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } indexed
&& Directory.Exists(path))
{
KickFolderProbes(indexed, ct);
}
}
}
@@ -198,25 +255,35 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public void NotifyViewport(IReadOnlyList<string> visiblePaths)
{
_viewport?.SetVisible(visiblePaths);
if (_thumbnails is null || ViewMode != FolderViewMode.Preview || _rows is null)
if (_thumbnails is not null && ViewMode == FolderViewMode.Preview && _rows is not null)
{
return;
}
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
foreach (var path in visiblePaths)
{
if (!string.IsNullOrEmpty(path) && _rows.TryGetValue(path, out var item))
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
foreach (var path in visiblePaths)
{
visible.Add(item);
if (!string.IsNullOrEmpty(path) && _rows.TryGetValue(path, out var item))
{
visible.Add(item);
}
}
_thumbnails.OnViewportChanged(this, visible);
}
_thumbnails.OnViewportChanged(this, visible);
if (!_skipReconcileOnComplete
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online }
&& _loadCts is { IsCancellationRequested: false } cts)
{
KickFolderProbes(CurrentSource, cts.Token);
}
}
public IReadOnlyList<FolderItemViewModel> SnapshotItems() => Items.ToList();
private void RefreshListingStatus()
=> ListingStatus = FolderStatusText.Format(
Items.Select(item => item.Item),
SelectedItems.Select(item => item.Item));
private void KickThumbnails()
{
if (ViewMode != FolderViewMode.Preview || _thumbnails is null || Items.Count == 0)
@@ -227,6 +294,64 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
_thumbnails.OnViewportChanged(this, Items.Take(40).ToList());
}
private void KickFolderProbes(Source source, CancellationToken cancellationToken)
=> _ = ProbeVisibleFoldersAsync(source, cancellationToken);
private async Task ProbeVisibleFoldersAsync(Source source, CancellationToken cancellationToken)
{
if (source.LastRootPath is null || _skipReconcileOnComplete)
{
return;
}
if (_probeBusy)
{
_probeAgain = true;
return;
}
_probeBusy = true;
try
{
do
{
_probeAgain = false;
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
var visible = _viewport?.Snapshot();
var candidates = FolderDisplayRefresh.Pick(Items.Select(row => row.Item), visible);
foreach (var item in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
if (!FolderDisplayRefresh.HasIndexCounts(item) || !_probedFolders.Add(item.FullPath))
{
continue;
}
var live = await Task.Run(() => _browse.CountVisibleChildren(item.FullPath), cancellationToken)
.ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
if (!FolderDisplayRefresh.NeedsVerify(live, item.IndexedChildCount))
{
continue;
}
var rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
_indexing.EnqueueVerify(source.Id, rel);
}
}
while (_probeAgain);
}
catch (OperationCanceledException)
{
// navigated away
}
finally
{
_probeBusy = false;
}
}
partial void OnViewModeChanged(FolderViewMode value)
=> _thumbnails?.OnPreviewEnabledChanged(this);
@@ -275,11 +400,14 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
SelectedItems.Clear();
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
var listing = path switch
{
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Home => _browse.ListHome(),
LocationRoots.Favorites => _browse.ListFavorites(),
LocationRoots.RecycleBin => _browse.ListRecycleBin(),
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
};
@@ -292,6 +420,21 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
Items.AddRange(rows);
ApplyCurrentSort();
RefreshListingStatus();
}
private void ApplyCurrentSource(Task<Source?> sourceTask)
{
if (!sourceTask.IsCompletedSuccessfully)
{
return;
}
CurrentSource = sourceTask.Result;
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
}
public Task RefreshGitAsync()
@@ -376,6 +519,18 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
=> segment is null ? Task.CompletedTask : NavigateAsync(segment.Path);
public void BeginEditPath()
{
PathEditText = CurrentPath;
IsEditingPath = true;
}
public void CancelEditPath()
{
IsEditingPath = false;
PathEditText = CurrentPath;
}
[RelayCommand]
public Task UpAsync()
{
@@ -398,6 +553,19 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public async Task RefreshIndexOverlayAsync()
{
_skipReconcileOnComplete = true;
try
{
await NavigateAsync(CurrentPath, addHistory: false).ConfigureAwait(true);
}
finally
{
_skipReconcileOnComplete = false;
}
}
public async Task OpenItemAsync(FolderItemViewModel item)
{
if (item.Item.AvailableToImport)
@@ -415,6 +583,19 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
}
_ops.Open([item.FullPath]);
_ = ConfirmShareAccessAsync(item.FullPath);
}
private async Task ConfirmShareAccessAsync(string path)
{
try
{
await _sources.ConfirmAccessedAsync(path).ConfigureAwait(true);
}
catch
{
// share wake-up is best-effort
}
}
public void BuildIndex()
@@ -475,11 +656,11 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return ordered.Select(item => byPath[item.FullPath]);
}
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
private IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
{
if (path == LocationRoots.ThisPc)
if (path is LocationRoots.ThisPc or LocationRoots.Home or LocationRoots.Favorites)
{
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
return [new BreadcrumbSegment(path, path, IsLast: true)];
}
if (path is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin)
@@ -491,6 +672,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
];
}
var rooted = TryPinnedBreadcrumb(LocationRoots.Home, _browse.ListHome().Items, path)
?? TryPinnedBreadcrumb(LocationRoots.Favorites, _browse.ListFavorites().Items, path);
if (rooted is not null)
{
return rooted;
}
var parts = new List<BreadcrumbSegment> { new(LocationRoots.ThisPc, LocationRoots.ThisPc) };
var p = PathRules.FromExtended(path);
if (PathRules.IsUnc(p))
@@ -530,6 +718,58 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return parts;
}
private static IReadOnlyList<BreadcrumbSegment>? TryPinnedBreadcrumb(
string rootLabel,
IEnumerable<FileSystemItem> folders,
string path)
{
var current = PathRules.FromExtended(path).TrimEnd('\\');
FileSystemItem? best = null;
foreach (var folder in folders)
{
var root = PathRules.FromExtended(folder.FullPath).TrimEnd('\\');
if (root.Length == 0)
{
continue;
}
if (!current.Equals(root, StringComparison.OrdinalIgnoreCase)
&& !current.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (best is null || root.Length > PathRules.FromExtended(best.FullPath).TrimEnd('\\').Length)
{
best = folder;
}
}
if (best is null)
{
return null;
}
var parts = new List<BreadcrumbSegment>
{
new(rootLabel, rootLabel),
new(string.IsNullOrEmpty(best.Name) ? best.FullPath : best.Name, best.FullPath)
};
var prefix = PathRules.FromExtended(best.FullPath).TrimEnd('\\');
if (current.Length > prefix.Length)
{
var acc = prefix;
foreach (var piece in current[(prefix.Length + 1)..].Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
acc = PathRules.Combine(acc, piece);
parts.Add(new BreadcrumbSegment(piece, acc));
}
}
parts[^1] = parts[^1] with { IsLast = true };
return parts;
}
}
public sealed record BreadcrumbSegment(string Label, string Path, bool IsLast = false);

View File

@@ -36,6 +36,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IMediaConversionProvider _conversion;
private readonly IThumbnailService? _thumbnails;
private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance;
private bool _hostStopped;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -62,6 +63,8 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showGitActions;
[ObservableProperty] private bool _showAddFavorite;
[ObservableProperty] private bool _showRemoveFavorite;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
@@ -92,8 +95,10 @@ public sealed partial class MainViewModel : ObservableObject
ConversionPlanner conversionPlanner,
IFileSystemEnumerator enumerator,
IMediaConversionProvider conversion,
IKnownUserFolderCatalog? knownFolders = null,
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null)
IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null)
{
_browse = browse;
_ops = ops;
@@ -117,6 +122,7 @@ public sealed partial class MainViewModel : ObservableObject
_conversion = conversion;
_thumbnails = thumbnails;
_host = hostConnection;
_maintenance = maintenance;
if (_host is not null)
{
_host.StatusChanged += (_, status) =>
@@ -132,14 +138,47 @@ public sealed partial class MainViewModel : ObservableObject
}
};
}
if (_maintenance is not null)
{
_maintenance.Changed += (_, snapshot) =>
{
if (string.IsNullOrWhiteSpace(snapshot.Message))
{
return;
}
void Apply() => Footer = snapshot.Message;
if (_ui is null)
{
Apply();
}
else
{
_ui.Post(_ => Apply(), null);
}
};
}
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences, knownFolders);
Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(mutations, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
_sources.PresenceChanged += (_, source) =>
{
void Apply() => _ = Tree.ApplySourceStateAsync(source.Id);
if (_ui is { } ctx)
{
ctx.Post(_ => Apply(), null);
}
else
{
Apply();
}
};
Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
Tabs = [];
Clipboard = clipboard;
@@ -157,6 +196,21 @@ public sealed partial class MainViewModel : ObservableObject
};
_indexing.ProgressChanged += (_, p) =>
{
if (p.IndexRefresh)
{
void Refresh() => _ = RefreshIndexOverlayAsync();
if (_ui is { } refreshCtx)
{
refreshCtx.Post(_ => Refresh(), null);
}
else
{
Refresh();
}
return;
}
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
@@ -331,7 +385,7 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public async Task GoAsync()
{
var path = PathText.Trim();
var path = ActivePane.IsEditingPath ? ActivePane.PathEditText.Trim() : PathText.Trim();
if (string.IsNullOrEmpty(path))
{
return;
@@ -426,18 +480,47 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public Task DeleteAsync() => DeleteSelectedAsync(permanent: false);
public Task DeleteSelectedAsync(bool permanent)
public async Task DeleteSelectedAsync(bool permanent)
{
var paths = SelectedPaths()
.Where(p => !LocationRoots.IsVirtual(p) && !PathRules.IsDriveRoot(p))
.ToList();
if (paths.Count == 0)
{
return Task.CompletedTask;
return;
}
var existing = new List<string>();
foreach (var path in paths)
{
var onDisk = Directory.Exists(path) || File.Exists(path);
if (onDisk)
{
existing.Add(path);
}
if (Directory.Exists(path))
{
EnqueueVerify(path);
}
var parent = PathRules.Parent(path);
if (!string.IsNullOrWhiteSpace(parent) && !LocationRoots.IsVirtual(parent))
{
EnqueueVerify(parent);
}
}
if (existing.Count == 0)
{
Footer = "Those items are no longer on disk.";
await ActivePane.RefreshAsync().ConfigureAwait(true);
await Tree.RefreshAfterChangesAsync(paths.SelectMany(AffectedDirectories)).ConfigureAwait(true);
return;
}
Footer = permanent ? "Deleting permanently…" : "Moving to Recycle Bin…";
return _ops.DeleteAsync(paths, permanent);
await _ops.DeleteAsync(existing, permanent).ConfigureAwait(true);
}
[RelayCommand]
@@ -749,9 +832,102 @@ public sealed partial class MainViewModel : ObservableObject
ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
var favoritePaths = FavoriteCandidates();
var pinned = _preferences.Load().FavoriteFolders;
ShowAddFavorite = favoritePaths.Any(p => !FavoriteFolders.Contains(pinned, p));
ShowRemoveFavorite = FavoriteRemovalTargets().Count > 0;
_ = RefreshForgetActionAsync();
}
public async Task AddFavoritesAsync(IEnumerable<string> paths)
{
var dirs = paths
.Where(p => !string.IsNullOrWhiteSpace(p) && Directory.Exists(p))
.ToList();
if (dirs.Count == 0)
{
Footer = "Select a folder to add to Favorites.";
return;
}
var stored = _preferences.Load();
var next = FavoriteFolders.Add(stored.FavoriteFolders, dirs);
if (next.Count == FavoriteFolders.Normalize(stored.FavoriteFolders).Count)
{
Footer = dirs.Count == 1 ? "Already in Favorites." : "Those folders are already in Favorites.";
return;
}
_preferences.Save(stored with { FavoriteFolders = next });
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
RefreshCloudActions();
Footer = dirs.Count == 1 ? "Added to Favorites." : $"Added {dirs.Count} folders to Favorites.";
}
public async Task RemoveFavoritesAsync(IEnumerable<string> paths)
{
var stored = _preferences.Load();
var next = FavoriteFolders.Remove(stored.FavoriteFolders, paths);
if (next.Count == FavoriteFolders.Normalize(stored.FavoriteFolders).Count)
{
Footer = "That folder is not in Favorites.";
return;
}
_preferences.Save(stored with { FavoriteFolders = next });
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
RefreshCloudActions();
Footer = "Removed from Favorites.";
}
public Task AddSelectedFavoritesAsync()
=> AddFavoritesAsync(FavoriteCandidates());
public Task RemoveSelectedFavoritesAsync()
=> RemoveFavoritesAsync(FavoriteRemovalTargets());
public bool CanPinFavorite(NavNodeViewModel node)
=> !node.IsGroup
&& !node.IsPlaceholder
&& !node.AvailableToImport
&& !LocationRoots.IsVirtual(node.Path)
&& Directory.Exists(node.Path)
&& !FavoriteFolders.Contains(_preferences.Load().FavoriteFolders, node.Path);
public bool CanUnpinFavorite(string? path)
=> FavoriteFolders.Contains(_preferences.Load().FavoriteFolders, path);
private IReadOnlyList<string> FavoriteCandidates()
{
var selected = ActivePane.SelectedItems.Where(i => i.IsDirectory && IsRealFileSystemItem(i))
.Select(i => i.FullPath)
.ToList();
if (selected.Count > 0)
{
return selected;
}
var current = ActivePane.CurrentPath;
if (string.IsNullOrWhiteSpace(current) || LocationRoots.IsVirtual(current) || !Directory.Exists(current))
{
return [];
}
return [current];
}
private IReadOnlyList<string> FavoriteRemovalTargets()
{
var pinned = _preferences.Load().FavoriteFolders;
var selected = ActivePane.SelectedItems.Select(i => i.FullPath).Where(p => FavoriteFolders.Contains(pinned, p)).ToList();
if (selected.Count > 0)
{
return selected;
}
return FavoriteFolders.Contains(pinned, ActivePane.CurrentPath) ? [ActivePane.CurrentPath] : [];
}
public async Task RefreshForgetActionAsync()
{
ShowForgetSource = ActivePane.CurrentPath is LocationRoots.ThisPc or LocationRoots.Network
@@ -1009,6 +1185,19 @@ public sealed partial class MainViewModel : ObservableObject
Analysis.Close();
}
[RelayCommand]
public void RunMaintenanceNow()
{
if (_maintenance is null)
{
Footer = "Background host is not connected.";
return;
}
_maintenance.RunNow();
Footer = "Background maintenance requested.";
}
[RelayCommand]
public void RescanStorage()
{
@@ -1298,7 +1487,7 @@ public sealed partial class MainViewModel : ObservableObject
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
EnqueueVerify(dir);
}
if (job.Status == TransferStatus.Failed)
@@ -1326,6 +1515,16 @@ public sealed partial class MainViewModel : ObservableObject
await Tree.RefreshAfterChangesAsync(dirs).ConfigureAwait(true);
}
private async Task RefreshIndexOverlayAsync()
{
await ActivePane.RefreshIndexOverlayAsync().ConfigureAwait(true);
if (ActiveTab.IsSplit)
{
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
await other.RefreshIndexOverlayAsync().ConfigureAwait(true);
}
}
private async Task RefreshFolderViewsAsync(string directory)
{
await ActivePane.RefreshAsync().ConfigureAwait(true);
@@ -1355,12 +1554,16 @@ public sealed partial class MainViewModel : ObservableObject
}
}
private void EnqueueReconcile(string path)
private void EnqueueReconcile(string path) => EnqueueIndexUpdate(path, verify: false);
private void EnqueueVerify(string path) => EnqueueIndexUpdate(path, verify: true);
private void EnqueueIndexUpdate(string path, bool verify)
{
_ = ReconcileAsync(path);
_ = ReconcileAsync(path, verify);
}
private async Task ReconcileAsync(string path)
private async Task ReconcileAsync(string path, bool verify = true)
{
try
{
@@ -1371,7 +1574,14 @@ public sealed partial class MainViewModel : ObservableObject
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
_indexing.EnqueueReconcile(source.Id, rel);
if (verify)
{
_indexing.EnqueueVerify(source.Id, rel);
}
else
{
_indexing.EnqueueReconcile(source.Id, rel);
}
}
catch
{

View File

@@ -21,6 +21,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public bool IsFavorite { get; init; }
public bool AvailableToImport { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
@@ -33,19 +34,22 @@ public sealed class NavigationTreeViewModel
private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly IKnownUserFolderCatalog? _knownFolders;
public NavigationTreeViewModel(
SourceManager sources,
BrowseService browse,
ICloudOverlay providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
UiPreferencesStore preferences,
IKnownUserFolderCatalog? knownFolders = null)
{
_sources = sources;
_browse = browse;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
_knownFolders = knownFolders;
Roots = [];
}
@@ -60,6 +64,8 @@ public sealed class NavigationTreeViewModel
return;
}
Roots.Add(CreateGroup(LocationRoots.Favorites, "\uE735"));
Roots.Add(CreateGroup(LocationRoots.Home, "\uE80F"));
Roots.Add(new NavNodeViewModel
{
Label = LocationRoots.ThisPc,
@@ -75,14 +81,20 @@ public sealed class NavigationTreeViewModel
{
var expanded = new List<string>();
CollectExpanded(Roots, expanded);
var selected = FindSelected(Roots)?.Path;
var restore = revealPath ?? selected;
var selected = FindSelected(Roots);
var restore = revealPath ?? selected?.Path;
var previousFavorites = Roots.FirstOrDefault(r => r.Path == LocationRoots.Favorites);
var wasInFavorites = selected is not null
&& previousFavorites is not null
&& ContainsNode(previousFavorites, selected);
IsRevealing = true;
try
{
Roots.Clear();
var prefs = _preferences.Load();
Roots.Add(CreateFavoritesRoot(prefs));
Roots.Add(CreateHomeRoot());
var thisPc = new NavNodeViewModel
{
Label = LocationRoots.ThisPc,
@@ -198,7 +210,7 @@ public sealed class NavigationTreeViewModel
if (!string.IsNullOrWhiteSpace(restore))
{
await RevealPathAsync(restore).ConfigureAwait(true);
await RevealPathAsync(restore, wasInFavorites).ConfigureAwait(true);
}
}
finally
@@ -302,7 +314,7 @@ public sealed class NavigationTreeViewModel
node.ChildrenLoaded = true;
}
public async Task RevealPathAsync(string path)
public async Task RevealPathAsync(string path, bool? currentlyInFavorites = null)
{
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
{
@@ -324,7 +336,7 @@ public sealed class NavigationTreeViewModel
return;
}
var current = FindBestRoot(Roots, path);
var current = FindBestRoot(path, currentlyInFavorites);
if (current is null)
{
return;
@@ -470,47 +482,139 @@ public sealed class NavigationTreeViewModel
_ => "\uE753"
};
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
private NavNodeViewModel CreateHomeRoot()
{
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
NavNodeViewModel? best = null;
var bestLength = -1;
void Consider(NavNodeViewModel node)
var home = CreateGroup(LocationRoots.Home, "\uE80F");
foreach (var folder in _knownFolders?.ListExisting() ?? [])
{
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
var node = new NavNodeViewModel
{
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;
}
}
Label = folder.Name,
Path = folder.Path,
Glyph = folder.Glyph
};
AddPlaceholder(node);
home.Children.Add(node);
}
return home;
}
private NavNodeViewModel CreateFavoritesRoot(UiPreferences prefs)
{
var root = CreateGroup(LocationRoots.Favorites, "\uE735");
foreach (var path in FavoriteFolders.Normalize(prefs.FavoriteFolders))
{
var exists = Directory.Exists(path);
var node = new NavNodeViewModel
{
Label = FavoriteLabel(path),
Path = path,
Glyph = "\uE735",
Status = exists ? "" : "Offline",
IsOffline = !exists,
IsFavorite = true
};
if (exists)
{
AddPlaceholder(node);
}
else
{
node.ChildrenLoaded = true;
}
root.Children.Add(node);
}
return root;
}
private static NavNodeViewModel CreateGroup(string name, string glyph)
=> new()
{
Label = name,
Path = name,
Glyph = glyph,
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
};
private static string FavoriteLabel(string path)
{
var name = PathRules.GetFileName(path.TrimEnd('\\'));
return string.IsNullOrEmpty(name) ? path : name;
}
private NavNodeViewModel? FindBestRoot(string path, bool? currentlyInFavorites = null)
{
var nodes = EnumerateRevealRoots(Roots).ToList();
var prefs = _preferences.Load();
var inFavorites = currentlyInFavorites ?? IsSelectionInFavorites();
var candidates = nodes
.Select(n => new TreeRevealCandidate(n.Path, n.IsFavorite))
.ToList();
var chosen = TreeRevealSelector.Choose(
candidates,
path,
prefs.PreferFavoritesInTree,
inFavorites);
if (chosen is null)
{
return null;
}
return nodes.FirstOrDefault(n =>
n.IsFavorite == chosen.Value.IsFavorite && PathsEqual(n.Path, chosen.Value.Path));
}
private bool IsSelectionInFavorites()
{
var selected = FindSelected(Roots);
var favorites = Roots.FirstOrDefault(r => r.Path == LocationRoots.Favorites);
return selected is not null && favorites is not null && ContainsNode(favorites, selected);
}
private static IEnumerable<NavNodeViewModel> EnumerateRevealRoots(IEnumerable<NavNodeViewModel> roots)
{
foreach (var root in roots)
{
if (root.IsPlaceholder)
{
continue;
}
if (root.IsGroup || LocationRoots.IsVirtual(root.Path))
{
foreach (var child in root.Children.Where(c => !c.IsPlaceholder))
{
Consider(child);
yield return child;
}
}
else
{
Consider(root);
yield return root;
}
}
}
private static bool ContainsNode(NavNodeViewModel root, NavNodeViewModel target)
{
if (ReferenceEquals(root, target))
{
return true;
}
foreach (var child in root.Children)
{
if (ContainsNode(child, target))
{
return true;
}
}
return best;
return false;
}
private static void AddPlaceholder(NavNodeViewModel node)

View File

@@ -225,6 +225,14 @@ internal sealed class HashStore : IHashStore
return conn.ExecuteAsync(sql, new { sourceId });
}, cancellationToken);
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var count = await conn.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM hash_queue WHERE state IN ('Pending','PartialDone')").ConfigureAwait(false);
return count > 0;
}
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -198,4 +198,30 @@ internal static partial class NativeMethods
var n = Array.IndexOf(buffer, '\0');
return n < 0 ? new string(buffer) : new string(buffer, 0, n);
}
[StructLayout(LayoutKind.Sequential)]
public struct LastInputInfo
{
public uint CbSize;
public uint DwTime;
}
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetLastInputInfo(ref LastInputInfo plii);
[StructLayout(LayoutKind.Sequential)]
public struct SystemPowerStatus
{
public byte ACLineStatus;
public byte BatteryFlag;
public byte BatteryLifePercent;
public byte SystemStatusFlag;
public int BatteryLifeTime;
public int BatteryFullLifeTime;
}
[LibraryImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool GetSystemPowerStatus(out SystemPowerStatus lpSystemPowerStatus);
}

View File

@@ -0,0 +1,34 @@
using Explorer.Application;
namespace Explorer.Windows;
public sealed class WindowsUserIdleMonitor : IUserIdleMonitor
{
public TimeSpan GetIdleDuration()
{
var info = new NativeMethods.LastInputInfo { CbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf<NativeMethods.LastInputInfo>() };
if (!NativeMethods.GetLastInputInfo(ref info))
{
return TimeSpan.Zero;
}
var idleMs = unchecked((uint)Environment.TickCount) - info.DwTime;
return TimeSpan.FromMilliseconds(idleMs);
}
}
public sealed class WindowsPowerSourceMonitor : IPowerSourceMonitor
{
public bool IsOnAcPower
{
get
{
if (!NativeMethods.GetSystemPowerStatus(out var status))
{
return true;
}
return status.ACLineStatus != 0;
}
}
}

View File

@@ -0,0 +1,58 @@
using System.Runtime.InteropServices;
using Explorer.Application;
namespace Explorer.Windows;
public sealed class WindowsKnownUserFolderCatalog : IKnownUserFolderCatalog
{
private static readonly (Guid Id, string Name, string Glyph)[] Folders =
[
(new("FDD39AD0-238F-46AF-ADB4-6C85480369C7"), "Documents", "\uE8A5"),
(new("374DE290-123F-4565-9164-39C4925E467B"), "Downloads", "\uE896"),
(new("33E28130-4E1E-4676-835A-98395C3BC3BB"), "Pictures", "\uEB9F"),
(new("18989B1D-99B5-455B-841C-AB7C74E4DDFC"), "Videos", "\uE8B2"),
(new("4BD8D571-6D19-48D3-BE97-422220080E43"), "Music", "\uE8D6")
];
public IReadOnlyList<KnownUserFolder> ListExisting()
{
var result = new List<KnownUserFolder>(Folders.Length);
foreach (var (id, name, glyph) in Folders)
{
var path = TryGetPath(id);
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
{
continue;
}
result.Add(new KnownUserFolder(name, path, glyph));
}
return result;
}
private static string? TryGetPath(Guid folderId)
{
var hr = SHGetKnownFolderPath(folderId, 0, IntPtr.Zero, out var ptr);
if (hr != 0 || ptr == IntPtr.Zero)
{
return null;
}
try
{
return Marshal.PtrToStringUni(ptr);
}
finally
{
Marshal.FreeCoTaskMem(ptr);
}
}
[DllImport("shell32.dll")]
private static extern int SHGetKnownFolderPath(
[MarshalAs(UnmanagedType.LPStruct)] Guid rfid,
uint dwFlags,
IntPtr hToken,
out IntPtr pszPath);
}