Add host activity and DB browser, and keep dialogs, drag-drop, and idle maintenance responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:03:52 +02:00
parent b72c375e87
commit b33a78dbbe
45 changed files with 3254 additions and 171 deletions

View File

@@ -182,9 +182,15 @@ even when that NAS is currently offline — if the archive was indexed earlier.
---
## Development tools
**Tools → Development → Host activity…** opens a live monitor of the background host: maintenance, indexing jobs, hashing, transfers, and a rolling activity log. It refreshes about every 1.5 seconds while open and also reacts to push events; closing it stops the polling.
**Tools → Development → Database…** opens a general SQLite viewer. It starts on the Workbench index (`%LocalAppData%\ExplorerWorkbench\index.db`) in read-only mode while the host holds the write lock. Use **Open file…** for any other `.db`, and choose write mode when the file is not locked. You can browse tables, run SQL, and — when writable — edit cells, insert rows, and delete rows.
## Duplicates
**Tools → Storage → Duplicates**. Groups are hashed in the background (size → partial hash → full hash only when needed). Workbench distinguishes:
**Tools → Storage → Duplicates**. Groups come from the **index** (hashed in the background: size → partial hash → full hash only when needed), not from a live walk of the disk. The list fills from the index first (largest groups at the top); missing copies are dropped afterwards without blocking the window. A finished full scan of the drive also marks missing trees deleted; cancelling a scan does not. Unmarked groups have no class label. After you mark a group, Workbench shows:
| Class | Meaning |
| --- | --- |

View File

@@ -1,3 +1,4 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -5,6 +6,8 @@ namespace Explorer.Analysis;
public sealed class AnalysisService
{
private const int DuplicateHashChunk = 24;
private readonly IIndexStore _store;
private readonly AnalysisResultCache _cache = new();
private readonly SemaphoreSlim _ready = new(1, 1);
@@ -93,15 +96,152 @@ public sealed class AnalysisService
CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
var raw = await _store.Hashes.GetDuplicateGroupsAsync(null, null, Math.Max(take * 8, 400), ct)
.ConfigureAwait(false);
var ids = raw.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
var relations = await _store.Relations.GetAmongAsync(ids, ct).ConfigureAwait(false);
return (IReadOnlyList<ClassifiedDuplicateGroup>)raw
.Select(g => DuplicateClassifier.ClassifyGroup(g, DuplicateClassifier.RelationsFor(g.Entries, relations)))
.ToList();
var list = new List<ClassifiedDuplicateGroup>();
await StreamCoreAsync(take, list.Add, verifyPresence: true, ct).ConfigureAwait(false);
return (IReadOnlyList<ClassifiedDuplicateGroup>)list;
}, cancellationToken);
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
int take,
IProgress<ClassifiedDuplicateGroup> progress,
CancellationToken cancellationToken = default)
=> StreamClassifiedDuplicatesAsync(take, progress, verifyPresence: false, cancellationToken);
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
int take,
IProgress<ClassifiedDuplicateGroup> progress,
bool verifyPresence,
CancellationToken cancellationToken = default)
=> RunOffUiAsync(
ct => StreamCoreAsync(take, progress.Report, verifyPresence, ct),
cancellationToken);
private async Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamCoreAsync(
int take,
Action<ClassifiedDuplicateGroup> emit,
bool verifyPresence,
CancellationToken cancellationToken)
{
var limit = Math.Max(take * 8, 400);
var hashes = await _store.Hashes.GetDuplicateHashesAsync(null, null, limit, cancellationToken)
.ConfigureAwait(false);
var sources = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false))
.ToDictionary(s => s.Id);
var missing = new List<MissingCopy>();
for (var offset = 0; offset < hashes.Count; offset += DuplicateHashChunk)
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = hashes.Skip(offset).Take(DuplicateHashChunk).ToList();
var groups = await _store.Hashes.GetDuplicateGroupsByHashesAsync(chunk, cancellationToken)
.ConfigureAwait(false);
var kept = new List<DuplicateGroup>();
foreach (var group in groups)
{
var present = verifyPresence
? KeepPresentCopies(group, sources, missing)
: group.Entries.ToList();
if (present.Count < 2)
{
continue;
}
kept.Add(new DuplicateGroup
{
SizeBytes = group.SizeBytes,
Hash = group.Hash,
Entries = present,
SameFileId = DuplicateClassifier.IsHardlinkOnly(present)
});
}
var ids = kept.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
var relations = await _store.Relations.GetAmongAsync(ids, cancellationToken).ConfigureAwait(false);
foreach (var group in kept)
{
emit(DuplicateClassifier.ClassifyGroup(
group,
DuplicateClassifier.RelationsFor(group.Entries, relations)));
}
}
if (_store.CanWrite && missing.Count > 0)
{
await TombstoneMissingAsync(missing, sources, cancellationToken).ConfigureAwait(false);
}
return missing
.Select(m => (m.SourceId, IndexedPathPresence.ReconcilePath(m.PathRel, m.Prefix)))
.Distinct()
.ToList();
}
private static List<IndexEntry> KeepPresentCopies(
DuplicateGroup group,
IReadOnlyDictionary<long, Source> sources,
List<MissingCopy> missing)
{
var kept = new List<IndexEntry>(group.Entries.Count);
foreach (var entry in group.Entries)
{
if (!sources.TryGetValue(entry.SourceId, out var source)
|| string.IsNullOrWhiteSpace(source.LastRootPath)
|| !IndexedPathPresence.RootReachable(source.LastRootPath))
{
kept.Add(entry);
continue;
}
if (IndexedPathPresence.FileExists(source.LastRootPath, entry.PathRel))
{
kept.Add(entry);
continue;
}
var prefix = IndexedPathPresence.HighestMissingPrefix(source.LastRootPath, entry.PathRel)
?? entry.PathRel;
missing.Add(new MissingCopy(entry.SourceId, entry.Id, entry.PathRel, prefix, source.LastRootPath));
}
return kept;
}
private async Task TombstoneMissingAsync(
IReadOnlyList<MissingCopy> missing,
IReadOnlyDictionary<long, Source> sources,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var prefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in missing)
{
if (!item.Prefix.Equals(item.PathRel, StringComparison.OrdinalIgnoreCase)
&& sources.TryGetValue(item.SourceId, out var source)
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
&& !IndexedPathPresence.DirectoryExists(PathRules.Combine(source.LastRootPath, item.Prefix)))
{
var key = item.SourceId + "|" + item.Prefix;
if (!prefixes.Add(key))
{
continue;
}
var folder = await _store.Entries.GetByPathAsync(item.SourceId, item.Prefix, cancellationToken)
.ConfigureAwait(false);
if (folder is not null)
{
await _store.Entries.TombstoneAsync(folder.Id, now, cancellationToken).ConfigureAwait(false);
}
await _store.Entries.TombstoneByPathPrefixAsync(item.SourceId, item.Prefix, now, cancellationToken)
.ConfigureAwait(false);
}
else
{
await _store.Entries.TombstoneAsync(item.EntryId, now, cancellationToken).ConfigureAwait(false);
}
}
}
public Task MarkDuplicateGroupAsync(
IReadOnlyList<IndexEntry> entries,
FileRelationKind kind,
@@ -156,4 +296,11 @@ public sealed class AnalysisService
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
}
private readonly record struct MissingCopy(
long SourceId,
long EntryId,
string PathRel,
string Prefix,
string Root);
}

View File

@@ -11,18 +11,30 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
{
private readonly IIndexStore _store;
private readonly IHydrationGuard _hydration;
private readonly IHostActivitySink _activity;
private readonly ILogger<DuplicateHashWorker> _logger;
private readonly object _pendingGate = new();
private long _pendingCount;
private DateTimeOffset _pendingCountUtc = DateTimeOffset.MinValue;
private int _pendingRefreshBusy;
private volatile bool _paused = true;
private volatile bool _userRequested;
private volatile string? _currentPath;
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
public DuplicateHashWorker(
IIndexStore store,
IHydrationGuard hydration,
ILogger<DuplicateHashWorker> logger,
IHostActivitySink? activity = null)
{
_store = store;
_hydration = hydration;
_logger = logger;
_activity = activity ?? NullHostActivitySink.Instance;
}
public bool IsPaused => _paused && !_userRequested;
public string? CurrentPath => _currentPath;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public void BeginUserRequested() => _userRequested = true;
@@ -30,6 +42,61 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
=> await _store.Hashes.HasPendingAsync(cancellationToken).ConfigureAwait(false);
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default)
{
long cached;
var never = false;
lock (_pendingGate)
{
cached = _pendingCount;
never = _pendingCountUtc == DateTimeOffset.MinValue;
if (!never && DateTimeOffset.UtcNow - _pendingCountUtc < TimeSpan.FromSeconds(15))
{
return Task.FromResult(cached);
}
}
if (never)
{
return RefreshPendingCountAsync(cancellationToken);
}
if (Interlocked.CompareExchange(ref _pendingRefreshBusy, 1, 0) == 0)
{
_ = RefreshPendingCountInBackgroundAsync();
}
return Task.FromResult(cached);
}
private async Task RefreshPendingCountInBackgroundAsync()
{
try
{
await RefreshPendingCountAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Background hash-pending count failed");
}
finally
{
Interlocked.Exchange(ref _pendingRefreshBusy, 0);
}
}
private async Task<long> RefreshPendingCountAsync(CancellationToken cancellationToken)
{
var count = await _store.Hashes.CountPendingAsync(cancellationToken).ConfigureAwait(false);
lock (_pendingGate)
{
_pendingCount = count;
_pendingCountUtc = DateTimeOffset.UtcNow;
}
return count;
}
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
{
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
@@ -46,6 +113,8 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
}
var path = PathRules.Combine(item.RootPath, item.PathRel);
_currentPath = path;
_activity.Record("Hash", item.State + " · " + path);
try
{
if (!File.Exists(path))
@@ -90,6 +159,10 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
_logger.LogDebug(ex, "Hash failed for {Path}", path);
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
}
finally
{
_currentPath = null;
}
}
}

View File

@@ -0,0 +1,78 @@
<Window x:Class="Explorer.App.DatabaseWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Database"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="1100"
MinHeight="480" MinWidth="800"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="12">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close DB" MinWidth="80" Height="28"
Command="{Binding CloseDatabaseCommand}" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file…" MinWidth="90" Height="28"
Click="OnOpenFile" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open Workbench index" MinWidth="150" Height="28"
Command="{Binding OpenWorkbenchIndexCommand}" Margin="6,0,0,0"/>
<StackPanel>
<TextBlock Text="{Binding PathLabel}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding ModeLabel}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="↻" Width="28" Height="26"
Command="{Binding RefreshTablesCommand}" ToolTip="Refresh tables"/>
<TextBlock Text="Tables" FontWeight="SemiBold" VerticalAlignment="Center"/>
</DockPanel>
<ListBox ItemsSource="{Binding Tables}" SelectedItem="{Binding SelectedTable}"/>
</DockPanel>
<DockPanel Grid.Column="2">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Run SQL" MinWidth="80" Height="28"
Command="{Binding RunSqlCommand}" Margin="8,0,0,0"/>
<TextBox Text="{Binding Sql, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
FontFamily="Consolas"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left" Text="{Binding PageLabel}" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Prev" MinWidth="60" Height="26"
Command="{Binding PrevPageCommand}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Next" MinWidth="60" Height="26"
Command="{Binding NextPageCommand}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Edit cell…" MinWidth="80" Height="26"
Click="OnEditCell" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Insert row…" MinWidth="90" Height="26"
Click="OnInsertRow" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Delete row" MinWidth="80" Height="26"
Command="{Binding DeleteSelectedRowCommand}" IsEnabled="{Binding CanWrite}"/>
</DockPanel>
<ListView x:Name="GridViewHost"
ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow}"
MouseDoubleClick="OnRowDoubleClick">
<ListView.View>
<GridView x:Name="ResultGrid"/>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,159 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using Explorer.Presentation.ViewModels;
using Microsoft.Win32;
namespace Explorer.App;
public partial class DatabaseWindow : Window
{
public DatabaseWindow(DatabaseViewerViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
vm.GridChanged += (_, _) => RebuildColumns();
ModelessWindowClose.EnableEscape(this);
Closed += async (_, _) => await vm.DisposeAsync().ConfigureAwait(true);
Loaded += async (_, _) => await vm.OpenIndexAsync(preferWrite: false).ConfigureAwait(true);
}
public DatabaseViewerViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void RebuildColumns()
{
ResultGrid.Columns.Clear();
for (var i = 0; i < ViewModel.Columns.Count; i++)
{
var index = i;
var header = ViewModel.Columns[i];
ResultGrid.Columns.Add(new GridViewColumn
{
Header = header,
Width = header.Equals("_rowid_", StringComparison.OrdinalIgnoreCase) ? 70 : 140,
DisplayMemberBinding = new Binding($"Cells[{index}]")
});
}
}
private void OnOpenFile(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Open SQLite database",
Filter = "SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*"
};
if (dlg.ShowDialog(this) == true)
{
var write = MessageBox.Show(
this,
"Open for writing? Choose No for read-only.",
"Database",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question);
if (write == MessageBoxResult.Cancel)
{
return;
}
_ = ViewModel.OpenPathAsync(dlg.FileName, preferWrite: write == MessageBoxResult.Yes);
}
}
private async void OnEditCell(object sender, RoutedEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async Task EditSelectedCellAsync()
{
if (!ViewModel.CanWrite || ViewModel.SelectedRow is null || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
return;
}
var column = Prompt("Column to edit", string.Join(", ", columns.Take(8)) + (columns.Count > 8 ? "…" : ""), columns[0]);
if (column is null || !columns.Contains(column, StringComparer.OrdinalIgnoreCase))
{
return;
}
var index = ViewModel.Columns.ToList().FindIndex(c => c.Equals(column, StringComparison.OrdinalIgnoreCase));
var current = index >= 0 && index < ViewModel.SelectedRow.Cells.Count
? ViewModel.SelectedRow.Cells[index]
: "";
var next = Prompt("New value for " + column, "Leave empty for NULL.", current);
if (next is null)
{
return;
}
try
{
await ViewModel.UpdateSelectedCellAsync(column, string.IsNullOrEmpty(next) ? null : next)
.ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void OnInsertRow(object sender, RoutedEventArgs e)
{
if (!ViewModel.CanWrite || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
MessageBox.Show(this, "Load a table first so columns are known.", "Database");
return;
}
var values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var column in columns)
{
var value = Prompt("Value for " + column, "Cancel skips this column. Empty = NULL.", "");
if (value is null)
{
continue;
}
values[column] = string.IsNullOrEmpty(value) ? null : value;
}
if (values.Count == 0)
{
return;
}
try
{
await ViewModel.InsertRowAsync(values).ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private string? Prompt(string title, string message, string initial)
{
var dlg = new PromptWindow(title, message, initial) { Owner = this };
return dlg.ShowDialog() == true ? dlg.Value : null;
}
}

View File

@@ -9,9 +9,9 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
x:Name="OpenFileButton" Margin="8,0,0,0"/>
x:Name="OpenFileButton" IsEnabled="False" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Reload" MinWidth="88" Height="32" Click="OnReload" Margin="8,0,0,0"/>
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
Foreground="{DynamicResource FgMuted}"/>

View File

@@ -11,14 +11,19 @@ public partial class DocumentationWindow : Window
{
private FlowDocument? _document;
private bool _suppressToc;
private int _renderGeneration;
public DocumentationWindow()
{
InitializeComponent();
Loaded += (_, _) => Render();
ModelessWindowClose.EnableEscape(this);
Loaded += async (_, _) => await RenderAsync().ConfigureAwait(true);
}
private void OnReload(object sender, RoutedEventArgs e) => Render();
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnReload(object sender, RoutedEventArgs e)
=> await RenderAsync().ConfigureAwait(true);
private void OnOpenFile(object sender, RoutedEventArgs e)
{
@@ -44,10 +49,35 @@ public partial class DocumentationWindow : Window
}
}
private void Render()
private async Task RenderAsync()
{
var loaded = DocumentationLoader.Load();
var parsed = MarkdownParser.Parse(loaded.Markdown);
var generation = Interlocked.Increment(ref _renderGeneration);
SourceLabel.Text = "Loading…";
LoadedDocumentation loaded;
MarkdownDocument parsed;
try
{
(loaded, parsed) = await Task.Run(() =>
{
var document = DocumentationLoader.Load();
return (document, MarkdownParser.Parse(document.Markdown));
}).ConfigureAwait(true);
}
catch (Exception ex)
{
if (generation == _renderGeneration)
{
SourceLabel.Text = "Could not load documentation: " + ex.Message;
}
return;
}
if (generation != _renderGeneration || !IsLoaded)
{
return;
}
var brushes = new DocumentationBrushes(
Brush("Fg"),
Brush("FgMuted"),

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Commit…" MinWidth="88" Height="32"
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"

View File

@@ -12,10 +12,13 @@ public partial class GitChangesWindow : Window
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
}
public GitChangesViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await ShowDiffAsync().ConfigureAwait(true);

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
IsCancel="True" Margin="0,12,0,0"/>
Click="OnClose" Margin="0,12,0,0"/>
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>

View File

@@ -10,5 +10,8 @@ public partial class GitDiffWindow : Window
InitializeComponent();
DataContext = vm;
Title = vm.Title;
ModelessWindowClose.EnableEscape(this);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
}

View File

@@ -0,0 +1,130 @@
<Window x:Class="Explorer.App.HostActivityMonitorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Host activity"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="960"
MinHeight="420" MinWidth="720"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="CanResize"
Background="{DynamicResource Bg}"
Foreground="{DynamicResource Fg}"
UseLayoutRounding="True"
SnapsToDevicePixels="True">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="40"
ResizeBorderThickness="6"
GlassFrameThickness="0"
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<DockPanel>
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False">
<Image Width="20" Height="20" Margin="0,0,8,0" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<TextBlock Text="Host activity" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource Fg}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CaptionButton}" Content="─" Click="OnMinimize" ToolTip="Minimize"/>
<Button x:Name="MaxRestoreButton" Style="{StaticResource CaptionButton}" Content="☐"
Click="OnMaxRestore" ToolTip="Maximize"/>
<Button Style="{StaticResource CaptionCloseButton}" Content="✕" Click="OnClose" ToolTip="Close"/>
</StackPanel>
</Grid>
</Border>
<DockPanel Margin="14">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Run maintenance now" MinWidth="140" Height="30"
Command="{Binding RunMaintenanceNowCommand}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Refresh" MinWidth="88" Height="30"
Command="{Binding RefreshCommand}" Margin="8,0,0,0"/>
<TextBlock VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<Run Text="{Binding Status, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding IsLive, Mode=OneWay, StringFormat=Live: {0}}"/>
</TextBlock>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="SemiBold" FontSize="15"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<UniformGrid DockPanel.Dock="Top" Rows="2" Columns="2" Margin="0,0,0,10">
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,0,6,6">
<StackPanel>
<TextBlock Text="Maintenance" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding MaintenanceText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,0,0,6">
<StackPanel>
<TextBlock Text="Indexing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding IndexingText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,6,6,0">
<StackPanel>
<TextBlock Text="Hashing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding HashText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,6,0,0">
<StackPanel>
<TextBlock Text="Transfers" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding TransferText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</UniformGrid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Current indexing jobs" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView ItemsSource="{Binding Jobs}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,2">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Origin}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</DockPanel>
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="{Binding LogCaption}" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView x:Name="EventList" ItemsSource="{Binding Events}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.ScrollChanged="OnEventScrollChanged"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListView.View>
<GridView>
<GridViewColumn Header="Time" Width="70" DisplayMemberBinding="{Binding Time}"/>
<GridViewColumn Header="Area" Width="100" DisplayMemberBinding="{Binding Category}"/>
<GridViewColumn Header="Detail" Width="440" DisplayMemberBinding="{Binding Message}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,139 @@
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 HostActivityMonitorWindow : Window
{
private bool _stickToEnd = true;
private bool _scrollQueued;
private ScrollViewer? _eventScroll;
public HostActivityMonitorWindow(HostActivityMonitorViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
StateChanged += (_, _) => SyncMaxRestoreButton();
vm.EventsReplaced += OnEventsReplaced;
Closed += (_, _) =>
{
vm.EventsReplaced -= OnEventsReplaced;
vm.Dispose();
};
Loaded += (_, _) =>
{
SyncMaxRestoreButton();
_eventScroll = FindScrollViewer(EventList);
vm.Start();
};
}
public HostActivityMonitorViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Left)
{
return;
}
if (e.ClickCount == 2)
{
ToggleMaximized();
return;
}
DragMove();
}
private void ToggleMaximized()
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
SyncMaxRestoreButton();
}
private void SyncMaxRestoreButton()
=> MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
private void OnEventScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (e.OriginalSource is not ScrollViewer viewer)
{
return;
}
_eventScroll = viewer;
if (e.ExtentHeightChange != 0)
{
return;
}
// Only treat intentional user scroll as leaving the live tail.
if (e.VerticalChange == 0)
{
return;
}
_stickToEnd = viewer.VerticalOffset >= viewer.ScrollableHeight - 8;
}
private void OnEventsReplaced(object? sender, EventArgs e)
{
if (!_stickToEnd || _scrollQueued)
{
return;
}
_scrollQueued = true;
Dispatcher.BeginInvoke(() =>
{
_scrollQueued = false;
if (!_stickToEnd)
{
return;
}
_eventScroll ??= FindScrollViewer(EventList);
if (_eventScroll is not null)
{
_eventScroll.ScrollToEnd();
}
else if (EventList.Items.Count > 0)
{
EventList.ScrollIntoView(EventList.Items[^1]);
}
}, DispatcherPriority.Background);
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer scroll)
{
return scroll;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var child = VisualTreeHelper.GetChild(root, i);
var found = FindScrollViewer(child);
if (found is not null)
{
return found;
}
}
return null;
}
}

View File

@@ -183,6 +183,9 @@
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="_Host activity…" Click="OnHostActivity"/>
<MenuItem Header="_Database…" Click="OnDatabase"/>
<Separator/>
<MenuItem Header="View _changes…" Click="OnGitChanges"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="_Commit…" Click="OnGitCommit"
@@ -986,22 +989,55 @@
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
Margin="48" Padding="16" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Grid.Row="1" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Show intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
<ProgressBar Grid.Row="3" Margin="0,0,0,8" IsIndeterminate="True"
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
<ItemsControl.ItemTemplate>
<ListBox Grid.Row="4"
ItemsSource="{Binding Duplicates.Groups}"
Background="Transparent"
BorderThickness="0"
Padding="0"
HorizontalContentAlignment="Stretch"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Visible"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter HorizontalAlignment="Stretch"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
@@ -1016,15 +1052,8 @@
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding Summary, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
</TextBlock>
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
@@ -1042,10 +1071,9 @@
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Border>
</Grid>

View File

@@ -18,11 +18,14 @@ public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private GitChangesWindow? _gitChanges;
private HostActivityMonitorWindow? _hostActivity;
private DatabaseWindow? _database;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
private ListView? _dragList;
private bool _draggingFromHere;
private FolderItemViewModel[] _dragSelection = [];
private bool _dragFromMultiSelect;
private bool _syncingSelection;
@@ -661,7 +664,7 @@ public partial class MainWindow : Window
}
_suppressItemContextMenu = false;
_dragStart = e.GetPosition(null);
_dragStart = e.GetPosition(this);
_dragPending = true;
_dragButton = e.ChangedButton;
_dragList = sender as ListView;
@@ -739,11 +742,12 @@ public partial class MainWindow : Window
&& dest is not null
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
&& !DragDropPolicy.IsInvalidTarget(files, dest)
&& !IsRedundantLeftDrop(files, dest)
? hover
: null;
SetListDropTarget(folderTarget);
SetTreeDropTarget(null);
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest) || IsRedundantLeftDrop(files, dest))
{
e.Effects = DragDropEffects.None;
e.Handled = true;
@@ -786,6 +790,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -863,9 +872,8 @@ public partial class MainWindow : Window
return;
}
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
var pos = e.GetPosition(this);
if (!DragMovedEnough(pos, DragDropPolicy.DragStartDistance))
{
return;
}
@@ -887,11 +895,19 @@ public partial class MainWindow : Window
_sourceRightDrag = _dragButton == MouseButton.Right;
_suppressItemContextMenu = _sourceRightDrag;
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
_draggingFromHere = true;
try
{
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
}
finally
{
_draggingFromHere = false;
_sourceRightDrag = false;
_incomingRightDrag = false;
ClearDropTargets();
}
}
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
{
@@ -1017,16 +1033,58 @@ public partial class MainWindow : Window
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
{
var item = HitTestFolderItem(list, e.GetPosition(list));
if (!_draggingFromHere)
{
if (item is { IsDirectory: true })
{
return item.FullPath;
}
return CurrentListDirectory(list);
}
var pos = e.GetPosition(this);
if (item is { IsDirectory: true }
&& DragMovedEnough(pos, FolderDropDistance(list)))
{
return item.FullPath;
}
if (!ReferenceEquals(list, _dragList)
&& !DragMovedEnough(pos, DragDropPolicy.DropCrossViewDistance))
{
return null;
}
return CurrentListDirectory(list);
}
private string? CurrentListDirectory(ListView list)
{
var tab = Vm.ActiveTab;
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
return LocationRoots.IsVirtual(path) ? null : path;
}
private double FolderDropDistance(ListView list)
=> ReferenceEquals(list, _dragList)
? DragDropPolicy.DropIntoItemDistance
: DragDropPolicy.DropCrossViewDistance;
private bool DragMovedEnough(Point current, double preferred)
=> DragDropPolicy.ExceedsDistance(
current.X - _dragStart.X,
current.Y - _dragStart.Y,
Math.Max(preferred, SystemDragMinimum));
private static double SystemDragMinimum
=> Math.Max(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);
private bool IsRedundantLeftDrop(IReadOnlyList<string> files, string dest)
=> !_sourceRightDrag
&& !_incomingRightDrag
&& DragDropPolicy.AllAlreadyInDirectory(files, dest);
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
{
if (origin is not Visual visual)
@@ -1178,6 +1236,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -1848,6 +1911,48 @@ public partial class MainWindow : Window
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private void OnHostActivity(object sender, RoutedEventArgs e)
{
if (_hostActivity is { IsVisible: true })
{
_hostActivity.Activate();
return;
}
var vm = Vm.CreateHostActivityMonitorViewModel();
if (vm is null)
{
MessageBox.Show(this, "Host activity is unavailable until the background host is connected.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_hostActivity = new HostActivityMonitorWindow(vm) { Owner = this };
_hostActivity.Closed += (_, _) => _hostActivity = null;
_hostActivity.Show();
}
private void OnDatabase(object sender, RoutedEventArgs e)
{
if (_database is { IsVisible: true })
{
_database.Activate();
return;
}
var vm = Vm.CreateDatabaseViewerViewModel();
if (vm is null)
{
MessageBox.Show(this, "Database tools are unavailable.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_database = new DatabaseWindow(vm) { Owner = this };
_database.Closed += (_, _) => _database = null;
_database.Show();
}
private async void OnGitChanges(object sender, RoutedEventArgs e)
{
if (_gitChanges is { IsVisible: true })

View File

@@ -0,0 +1,23 @@
using System.Windows;
using System.Windows.Input;
namespace Explorer.App;
/// <summary>
/// Close helpers for windows opened with <see cref="Window.Show"/>.
/// <see cref="Button.IsCancel"/> sets <see cref="Window.DialogResult"/>, which throws on modeless windows.
/// </summary>
internal static class ModelessWindowClose
{
public static void EnableEscape(Window window)
=> window.PreviewKeyDown += (_, e) =>
{
if (e.Key != Key.Escape)
{
return;
}
window.Close();
e.Handled = true;
};
}

View File

@@ -23,10 +23,12 @@ public interface IIdleIndexWork
public interface IIdleHashWork
{
bool IsPaused { get; }
string? CurrentPath { get; }
void Pause();
void Resume();
void BeginUserRequested();
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
}
public interface IHistoryMaintenance

View File

@@ -0,0 +1,87 @@
using Explorer.Contracts;
namespace Explorer.Application;
public interface IHostActivitySink
{
void Record(string category, string message);
IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120);
}
public sealed class NullHostActivitySink : IHostActivitySink
{
public static NullHostActivitySink Instance { get; } = new();
public void Record(string category, string message)
{
}
public IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120) => [];
}
/// <summary>Bounded ring of host activity lines for the live monitor. Cheap to write; snapshot is a copy.</summary>
public sealed class HostActivityLog : IHostActivitySink
{
private readonly object _gate = new();
private readonly HostActivityEvent[] _ring;
private int _next;
private int _count;
private string? _lastKey;
private DateTimeOffset _lastUtc;
public HostActivityLog(int capacity = 48)
{
_ring = new HostActivityEvent[Math.Clamp(capacity, 32, 200)];
}
public void Record(string category, string message)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
var cat = string.IsNullOrWhiteSpace(category) ? "Host" : category.Trim();
var msg = message.Trim();
var key = cat + "\u001f" + msg;
var now = DateTimeOffset.UtcNow;
lock (_gate)
{
if (key == _lastKey && (now - _lastUtc) < TimeSpan.FromMilliseconds(750))
{
return;
}
_lastKey = key;
_lastUtc = now;
_ring[_next] = new HostActivityEvent { Utc = now, Category = cat, Message = msg };
_next = (_next + 1) % _ring.Length;
if (_count < _ring.Length)
{
_count++;
}
}
}
public IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120)
{
max = Math.Clamp(max, 1, _ring.Length);
lock (_gate)
{
var take = Math.Min(max, _count);
if (take == 0)
{
return [];
}
var result = new HostActivityEvent[take];
var start = (_next - take + _ring.Length) % _ring.Length;
for (var i = 0; i < take; i++)
{
result[i] = _ring[(start + i) % _ring.Length];
}
return result;
}
}
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Application;
public interface ISqliteDatabaseSessionFactory
{
ISqliteDatabaseSession Create();
}
public interface ISqliteDatabaseSession : IAsyncDisposable
{
string? Path { get; }
bool IsOpen { get; }
bool CanWrite { get; }
string ModeLabel { get; }
Task OpenAsync(string path, bool preferWrite, CancellationToken cancellationToken = default);
Task CloseAsync();
Task<IReadOnlyList<string>> ListTablesAsync(CancellationToken cancellationToken = default);
Task<SqliteTablePage> ReadTableAsync(string table, int offset, int take, CancellationToken cancellationToken = default);
Task<SqliteQueryResult> ExecuteAsync(string sql, CancellationToken cancellationToken = default);
Task UpdateCellAsync(string table, long rowId, string column, object? value, CancellationToken cancellationToken = default);
Task DeleteRowAsync(string table, long rowId, CancellationToken cancellationToken = default);
Task InsertRowAsync(string table, IReadOnlyDictionary<string, object?> values, CancellationToken cancellationToken = default);
}
public sealed class SqliteTablePage
{
public required IReadOnlyList<string> Columns { get; init; }
public required IReadOnlyList<IReadOnlyList<object?>> Rows { get; init; }
public long TotalRows { get; init; }
public int Offset { get; init; }
}
public sealed class SqliteQueryResult
{
public bool IsQuery { get; init; }
public IReadOnlyList<string> Columns { get; init; } = [];
public IReadOnlyList<IReadOnlyList<object?>> Rows { get; init; } = [];
public int RecordsAffected { get; init; }
public string Message { get; init; } = "";
}

View File

@@ -0,0 +1,116 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class IndexedPathPresence
{
public static bool FileExists(string root, string pathRel)
{
if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(pathRel))
{
return false;
}
if (TryArchiveFile(pathRel, out var archiveRel))
{
return ExistsFile(PathRules.Combine(root, archiveRel));
}
return ExistsFile(PathRules.Combine(root, pathRel));
}
public static bool RootReachable(string root)
=> !string.IsNullOrWhiteSpace(root) && ExistsDirectory(root);
public static bool DirectoryExists(string path) => ExistsDirectory(path);
public static string? HighestMissingPrefix(string root, string pathRel)
{
if (FileExists(root, pathRel))
{
return null;
}
if (TryArchiveFile(pathRel, out var archiveRel))
{
return archiveRel;
}
var rootNorm = PathRules.FromExtended(root).TrimEnd('\\');
var current = PathRules.FromExtended(PathRules.Combine(root, pathRel));
var missing = pathRel;
while (true)
{
var parent = PathRules.Parent(current);
if (string.IsNullOrEmpty(parent)
|| parent.Equals(rootNorm, StringComparison.OrdinalIgnoreCase)
|| parent.Equals(rootNorm + "\\", StringComparison.OrdinalIgnoreCase))
{
break;
}
if (ExistsDirectory(parent) || ExistsFile(parent))
{
break;
}
missing = PathRules.MakeRelative(root, parent);
current = parent;
}
return missing;
}
public static string ReconcilePath(string pathRel, string missingPrefix)
{
if (!missingPrefix.Equals(pathRel, StringComparison.OrdinalIgnoreCase))
{
return missingPrefix;
}
var slash = pathRel.LastIndexOf('\\');
return slash <= 0 ? "" : pathRel[..slash];
}
internal static bool TryArchiveFile(string pathRel, out string archiveRel)
{
archiveRel = "";
var parts = pathRel.Split('\\', StringSplitOptions.RemoveEmptyEntries);
var acc = new List<string>();
for (var i = 0; i < parts.Length; i++)
{
acc.Add(parts[i]);
if (i < parts.Length - 1 && ArchiveFormats.IsArchive(parts[i]))
{
archiveRel = string.Join('\\', acc);
return true;
}
}
return false;
}
private static bool ExistsFile(string path)
{
var normal = PathRules.FromExtended(path);
if (File.Exists(normal))
{
return true;
}
var ext = PathRules.ToExtended(normal);
return ext != normal && File.Exists(ext);
}
private static bool ExistsDirectory(string path)
{
var normal = PathRules.FromExtended(path);
if (Directory.Exists(normal))
{
return true;
}
var ext = PathRules.ToExtended(normal);
return ext != normal && Directory.Exists(ext);
}
}

View File

@@ -0,0 +1,52 @@
namespace Explorer.Contracts;
public sealed class HostActivitySnapshot
{
public DateTimeOffset Utc { get; init; } = DateTimeOffset.UtcNow;
public MaintenanceSnapshot Maintenance { get; init; } = MaintenanceSnapshot.Empty;
public bool IdleAllowed { get; init; }
public int IdleQueued { get; init; }
public bool HashPaused { get; init; }
public long HashPending { get; init; }
public string? HashCurrentPath { get; init; }
public bool TransfersPaused { get; init; }
public int TransfersActive { get; init; }
public int TransfersQueued { get; init; }
public string? TransferCurrentPath { get; init; }
public string? LastIndexPath { get; init; }
public long LastIndexFilesDone { get; init; }
public long LastIndexDirsDone { get; init; }
public string? LastIndexStatus { get; init; }
public IReadOnlyList<HostActivityJob> IndexingJobs { get; init; } = [];
public IReadOnlyList<HostActivityEvent> RecentEvents { get; init; } = [];
}
public sealed class HostActivityJob
{
public long SourceId { get; init; }
public required string SourceName { get; init; }
public required string Kind { get; init; }
public required string Origin { get; init; }
public string? PathRel { get; init; }
public bool VerifyChildren { get; init; }
}
public sealed class HostActivityEvent
{
public DateTimeOffset Utc { get; init; }
public required string Category { get; init; }
public required string Message { get; init; }
}
public interface IHostActivity
{
Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default);
}
public sealed class NullHostActivity : IHostActivity
{
public static NullHostActivity Instance { get; } = new();
public Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new HostActivitySnapshot());
}

View File

@@ -160,6 +160,7 @@ public interface IHashStore
{
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
Task<long> CountPendingAsync(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);
@@ -168,6 +169,10 @@ public interface IHashStore
Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
IReadOnlyList<byte[]> hashes,
CancellationToken cancellationToken = default);
}
public interface IFileRelationStore

View File

@@ -54,4 +54,46 @@ public static class DragDropPolicy
return false;
}
/// <summary>
/// Windows SM_CXDRAG is typically 4 DIP — enough to turn a click into a drag.
/// A real drag is still immediate; this only ignores click jitter.
/// </summary>
public const double DragStartDistance = 16;
/// <summary>Dropping into a neighboring folder row should need more than a row-edge slip.</summary>
public const double DropIntoItemDistance = 28;
/// <summary>Crossing into the other pane or the tree should not happen at the splitter.</summary>
public const double DropCrossViewDistance = 36;
public static bool ExceedsDistance(double deltaX, double deltaY, double minimum)
{
var threshold = Math.Max(minimum, 0);
return (deltaX * deltaX) + (deltaY * deltaY) >= threshold * threshold;
}
public static bool AllAlreadyInDirectory(IReadOnlyList<string> sources, string destinationDirectory)
{
if (sources.Count == 0 || string.IsNullOrWhiteSpace(destinationDirectory))
{
return false;
}
var dest = NormalizeDirectory(destinationDirectory);
foreach (var source in sources)
{
var src = PathRules.FromExtended(source).TrimEnd('\\');
var parent = Path.GetDirectoryName(src);
if (parent is null || !NormalizeDirectory(parent).Equals(dest, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
private static string NormalizeDirectory(string path)
=> PathRules.FromExtended(path).TrimEnd('\\');
}

View File

@@ -36,6 +36,15 @@ public static class ExplorerHostClientServices
services.AddSingleton(connection);
}
if (workbench is IHostActivity activity)
{
services.AddSingleton(activity);
}
else
{
services.TryAddSingleton<IHostActivity>(_ => NullHostActivity.Instance);
}
services.AddExplorerClientRuntime();
return services;
}
@@ -54,6 +63,7 @@ public static class ExplorerHostClientServices
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: true);
});
services.AddSingleton<ISqliteDatabaseSessionFactory, SqliteDatabaseSessionFactory>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();

View File

@@ -8,7 +8,7 @@ using Explorer.Plugin.Abstractions;
namespace Explorer.Hosting.Ipc;
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IAsyncDisposable
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IHostActivity, IAsyncDisposable
{
private NamedPipeClientStream _pipe;
private StreamWriter _writer;
@@ -51,6 +51,18 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
public void RunNow() => Call("Maintenance.RunNow");
public async Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
{
var reply = await CallAsync("Host.ActivitySnapshot", cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(reply.Payload))
{
return new HostActivitySnapshot { Maintenance = _maintenance };
}
return JsonSerializer.Deserialize<HostActivitySnapshot>(reply.Payload, WorkbenchIpc.Json)
?? new HostActivitySnapshot { Maintenance = _maintenance };
}
public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options,
TimeSpan timeout,

View File

@@ -17,6 +17,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
private readonly IHistoryMaintenance _history;
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly IHostActivitySink _activity;
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
private readonly HashSet<long> _collisionEnqueued = [];
private readonly HashSet<long> _idleScanQueued = [];
@@ -42,7 +43,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
IHistoryMaintenance history,
IIndexStore store,
IVolumeService volumes,
ILogger<BackgroundMaintenanceCoordinator> logger)
ILogger<BackgroundMaintenanceCoordinator> logger,
IHostActivitySink? activity = null)
{
_idle = idle;
_power = power;
@@ -54,6 +56,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_store = store;
_volumes = volumes;
_logger = logger;
_activity = activity ?? NullHostActivitySink.Instance;
}
public event EventHandler<MaintenanceSnapshot>? Changed;
@@ -149,10 +152,12 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_hash.Pause();
}
_workMessage = _indexing.HasIdleWork
? (_workMessage ?? "Scanning")
: "Idle maintenance";
Publish(decision, _workMessage);
// Keep the idle-maintenance caption only while idle work is still queued/running.
// Watcher/user jobs must not keep showing a stale "checking External" line.
var busyMessage = _indexing.HasIdleWork
? (_workMessage ?? "Idle maintenance")
: "";
Publish(decision, busyMessage);
return;
}
@@ -167,7 +172,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_idleVerified.Add(verify.Id);
_indexing.EnqueueIdleVerify(verify.Id, "");
_runNow = false;
// Keep _runNow until the whole RunNow session finishes so user activity
// does not cancel the work that was just started via the button.
_workMessage = "Idle maintenance · checking " + verify.DisplayName;
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
Publish(decision, _workMessage);
@@ -184,7 +190,6 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_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);
@@ -194,16 +199,14 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_hash.Resume();
if (hashPending)
{
_runNow = false;
_workMessage = "Idle maintenance";
_workMessage = "Idle maintenance · hashing";
Publish(decision, _workMessage);
return;
}
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
{
_runNow = false;
_workMessage = "Idle maintenance";
_workMessage = "Idle maintenance · history";
_logger.LogDebug("maintenance completed (history)");
Publish(decision, _workMessage);
return;
@@ -214,7 +217,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
{
_collisionEnqueued.Add(hashSource.Id);
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
_workMessage = "Idle maintenance";
_workMessage = "Idle maintenance · hash queue";
Publish(decision, _workMessage);
return;
}
@@ -261,6 +264,11 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
_snapshot = snapshot;
}
if (!string.IsNullOrWhiteSpace(message))
{
_activity.Record("Maintenance", message);
}
Changed?.Invoke(this, snapshot);
}
}

View File

@@ -84,6 +84,7 @@ public static class ExplorerHostServices
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
{
services.AddSingleton<IHostActivitySink, HostActivityLog>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();

View File

@@ -317,6 +317,11 @@ public sealed class WorkbenchPipeServer : BackgroundService
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
return reply;
}
case "Host.ActivitySnapshot":
{
reply.Payload = JsonSerializer.Serialize(await BuildActivitySnapshotAsync().ConfigureAwait(false), WorkbenchIpc.Json);
return reply;
}
case "Host.Shutdown":
await RequestShutdownAsync().ConfigureAwait(false);
return reply;
@@ -536,6 +541,40 @@ public sealed class WorkbenchPipeServer : BackgroundService
}
}
private async Task<HostActivitySnapshot> BuildActivitySnapshotAsync()
{
var maintenance = _services.GetService<IBackgroundMaintenance>()?.Snapshot ?? MaintenanceSnapshot.Empty;
var indexing = _services.GetService<Indexing.IndexingCoordinator>();
var hash = _services.GetService<IIdleHashWork>();
var transfers = _services.GetService<ITransferHost>();
var activity = _services.GetService<IHostActivitySink>();
var jobs = transfers?.Snapshot() ?? [];
var active = jobs.Count(j => j.Status is TransferStatus.Running or TransferStatus.Cancelling);
var queued = jobs.Count(j => j.Status is TransferStatus.Waiting or TransferStatus.Paused);
var running = jobs.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Cancelling);
var last = indexing?.LastProgress;
return new HostActivitySnapshot
{
Utc = DateTimeOffset.UtcNow,
Maintenance = maintenance,
IdleAllowed = indexing?.IdleAllowed ?? false,
IdleQueued = indexing?.IdleQueued ?? 0,
HashPaused = hash?.IsPaused ?? true,
HashPending = hash is null ? 0 : await hash.CountPendingAsync().ConfigureAwait(false),
HashCurrentPath = hash?.CurrentPath,
TransfersPaused = transfers?.IsPaused ?? false,
TransfersActive = active,
TransfersQueued = queued,
TransferCurrentPath = running?.CurrentPath ?? running?.SourcePath,
LastIndexPath = last?.CurrentPath,
LastIndexFilesDone = last?.FilesSeen ?? 0,
LastIndexDirsDone = last?.DirsSeen ?? 0,
LastIndexStatus = last?.Status.ToString(),
IndexingJobs = indexing?.GetRunningJobs() ?? [],
RecentEvents = activity?.TakeRecent(40) ?? []
};
}
private static T Read<T>(string? payload)
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);

View File

@@ -26,7 +26,7 @@ public sealed class FolderReconciler
_preferences = preferences;
}
public Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
public Task<bool> ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
=> ReconcileAsync(source, pathRel, cancellationToken, verifyChildren: false);
public Task<bool> ReconcileAsync(
@@ -74,11 +74,25 @@ public sealed class FolderReconciler
return false;
}
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
if (LocationClassifier.IsRecycleBinName(parent.Name))
{
return false;
}
if (!IndexedPathPresence.DirectoryExists(full))
{
if (string.IsNullOrEmpty(pathRel))
{
return false;
}
var gone = DateTimeOffset.UtcNow;
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, gone, cancellationToken)
.ConfigureAwait(false);
await _store.Entries.TombstoneAsync(parent.Id, gone, cancellationToken).ConfigureAwait(false);
return true;
}
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
.ConfigureAwait(false);
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)

View File

@@ -16,12 +16,15 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
private readonly UsnChangeApplier _usn;
private readonly IUsnJournal _journal;
private readonly IVolumeService _volumes;
private readonly IHostActivitySink _activity;
private readonly ILogger<IndexingCoordinator> _logger;
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
private readonly Dictionary<long, RunningWork> _running = new();
private readonly object _gate = new();
private volatile bool _idleAllowed;
private int _idleQueued;
private ScanProgress? _lastProgress;
private DateTimeOffset _lastActivityLogUtc;
public event EventHandler<ScanProgress>? ProgressChanged;
@@ -32,7 +35,8 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
UsnChangeApplier usn,
IUsnJournal journal,
IVolumeService volumes,
ILogger<IndexingCoordinator> logger)
ILogger<IndexingCoordinator> logger,
IHostActivitySink? activity = null)
{
_store = store;
_scanner = scanner;
@@ -41,6 +45,7 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
_journal = journal;
_volumes = volumes;
_logger = logger;
_activity = activity ?? NullHostActivitySink.Instance;
}
public bool IsBusy
@@ -65,6 +70,36 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
}
}
public int IdleQueued
{
get { lock (_gate) { return _idleQueued; } }
}
public bool IdleAllowed => _idleAllowed;
public ScanProgress? LastProgress
{
get { lock (_gate) { return _lastProgress; } }
}
public IReadOnlyList<HostActivityJob> GetRunningJobs()
{
lock (_gate)
{
return _running.Values
.Select(w => new HostActivityJob
{
SourceId = w.SourceId,
SourceName = w.SourceName,
Kind = w.Kind.ToString(),
Origin = w.Origin.ToString(),
PathRel = w.PathRel,
VerifyChildren = w.VerifyChildren
})
.ToList();
}
}
public void SetIdleAllowed(bool allowed)
{
_idleAllowed = allowed;
@@ -176,9 +211,22 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
lock (_gate)
{
_running[item.SourceId] = new RunningWork(linked, item.Origin);
_running[item.SourceId] = new RunningWork(
linked,
item.Origin,
item.Kind,
item.SourceId,
source.DisplayName,
item.PathRel,
item.VerifyChildren);
}
_activity.Record(
"Indexing",
item.Kind + " · " + source.DisplayName
+ (string.IsNullOrEmpty(item.PathRel) ? "" : " · " + item.PathRel)
+ " (" + item.Origin + ")");
try
{
switch (item.Kind)
@@ -189,7 +237,16 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
source,
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
item.PathRel,
new Progress<ScanProgress>(p => ProgressChanged?.Invoke(this, p)),
new Progress<ScanProgress>(p =>
{
lock (_gate)
{
_lastProgress = p;
}
NoteProgress(p);
ProgressChanged?.Invoke(this, p);
}),
linked.Token).ConfigureAwait(false);
EnqueueUsn(source.Id);
break;
@@ -204,13 +261,19 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
var full = source.LastRootPath is null
? item.PathRel
: PathRules.Combine(source.LastRootPath, item.PathRel);
ProgressChanged?.Invoke(this, new ScanProgress
var done = new ScanProgress
{
SourceId = source.Id,
CurrentPath = full,
Status = ScanJobStatus.Done,
IndexRefresh = true
});
};
lock (_gate)
{
_lastProgress = done;
}
ProgressChanged?.Invoke(this, done);
}
}
@@ -229,6 +292,29 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
}
}
private void NoteProgress(ScanProgress progress)
{
if (progress.IndexRefresh || string.IsNullOrWhiteSpace(progress.CurrentPath))
{
return;
}
var now = DateTimeOffset.UtcNow;
lock (_gate)
{
if ((now - _lastActivityLogUtc) < TimeSpan.FromSeconds(1.5))
{
return;
}
_lastActivityLogUtc = now;
}
_activity.Record(
"Indexing",
progress.FilesSeen + " files · " + progress.DirsSeen + " dirs · " + progress.CurrentPath);
}
private async Task PeriodicAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
@@ -256,5 +342,12 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
IndexWorkOrigin Origin,
bool VerifyChildren = false);
private sealed record RunningWork(CancellationTokenSource Cts, IndexWorkOrigin Origin);
private sealed record RunningWork(
CancellationTokenSource Cts,
IndexWorkOrigin Origin,
WorkKind Kind,
long SourceId,
string SourceName,
string? PathRel,
bool VerifyChildren);
}

View File

@@ -0,0 +1,327 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class DatabaseViewerViewModel : ObservableObject, IAsyncDisposable
{
private readonly ISqliteDatabaseSessionFactory _factory;
private readonly IAppEnvironment _environment;
private ISqliteDatabaseSession? _session;
private const int PageSize = 100;
[ObservableProperty] private string _pathLabel = "No database open";
[ObservableProperty] private string _modeLabel = "Closed";
[ObservableProperty] private string _status = "";
[ObservableProperty] private string? _selectedTable;
[ObservableProperty] private string _sql = "SELECT name, type FROM sqlite_master ORDER BY name;";
[ObservableProperty] private bool _canWrite;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private int _pageOffset;
[ObservableProperty] private long _totalRows;
[ObservableProperty] private string _pageLabel = "";
[ObservableProperty] private SqliteGridRow? _selectedRow;
public DatabaseViewerViewModel(ISqliteDatabaseSessionFactory factory, IAppEnvironment environment)
{
_factory = factory;
_environment = environment;
Tables = [];
Columns = [];
Rows = [];
}
public ObservableCollection<string> Tables { get; }
public ObservableCollection<string> Columns { get; }
public ObservableCollection<SqliteGridRow> Rows { get; }
public string DefaultIndexPath => _environment.DatabasePath;
public event EventHandler? GridChanged;
public async Task OpenIndexAsync(bool preferWrite = false)
=> await OpenPathAsync(_environment.DatabasePath, preferWrite).ConfigureAwait(true);
public async Task OpenPathAsync(string path, bool preferWrite)
{
Status = "Opening…";
try
{
_session ??= _factory.Create();
await _session.OpenAsync(path, preferWrite).ConfigureAwait(true);
PathLabel = _session.Path ?? path;
ModeLabel = _session.ModeLabel;
CanWrite = _session.CanWrite;
IsOpen = true;
await ReloadTablesAsync().ConfigureAwait(true);
Status = ModeLabel;
}
catch (Exception ex)
{
Status = "Open failed: " + ex.Message;
IsOpen = false;
}
}
[RelayCommand]
private Task OpenWorkbenchIndexAsync() => OpenIndexAsync(preferWrite: false);
[RelayCommand]
private async Task CloseDatabaseAsync()
{
if (_session is not null)
{
await _session.CloseAsync().ConfigureAwait(true);
}
Tables.Clear();
ClearGrid();
SelectedTable = null;
IsOpen = false;
CanWrite = false;
PathLabel = "No database open";
ModeLabel = "Closed";
Status = "Closed";
}
[RelayCommand]
private async Task RefreshTablesAsync()
{
if (_session is not { IsOpen: true })
{
return;
}
await ReloadTablesAsync().ConfigureAwait(true);
}
[RelayCommand]
private Task LoadSelectedTableAsync() => LoadTablePageAsync(0);
[RelayCommand]
private Task NextPageAsync() => LoadTablePageAsync(PageOffset + PageSize);
[RelayCommand]
private Task PrevPageAsync() => LoadTablePageAsync(Math.Max(0, PageOffset - PageSize));
[RelayCommand]
private async Task RunSqlAsync()
{
if (_session is not { IsOpen: true })
{
return;
}
try
{
var result = await _session.ExecuteAsync(Sql).ConfigureAwait(true);
if (result.IsQuery)
{
ApplyGrid(result.Columns, result.Rows, result.Rows.Count, 0);
}
else
{
Status = result.Message;
if (!string.IsNullOrWhiteSpace(SelectedTable))
{
await LoadTablePageAsync(PageOffset).ConfigureAwait(true);
}
}
if (result.IsQuery)
{
Status = result.Message;
}
}
catch (Exception ex)
{
Status = "SQL failed: " + ex.Message;
}
}
[RelayCommand]
private async Task DeleteSelectedRowAsync()
{
if (_session is not { IsOpen: true, CanWrite: true }
|| string.IsNullOrWhiteSpace(SelectedTable)
|| SelectedRow is null
|| SelectedRow.RowId is null)
{
Status = "Select a table row with a rowid to delete.";
return;
}
try
{
await _session.DeleteRowAsync(SelectedTable, SelectedRow.RowId.Value).ConfigureAwait(true);
Status = "Row deleted.";
await LoadTablePageAsync(PageOffset).ConfigureAwait(true);
}
catch (Exception ex)
{
Status = "Delete failed: " + ex.Message;
}
}
public async Task UpdateSelectedCellAsync(string column, object? value)
{
if (_session is not { IsOpen: true, CanWrite: true }
|| string.IsNullOrWhiteSpace(SelectedTable)
|| SelectedRow?.RowId is null)
{
throw new InvalidOperationException("Select a writable row first.");
}
await _session.UpdateCellAsync(SelectedTable, SelectedRow.RowId.Value, column, value).ConfigureAwait(true);
Status = "Cell updated.";
await LoadTablePageAsync(PageOffset).ConfigureAwait(true);
}
public async Task InsertRowAsync(IReadOnlyDictionary<string, object?> values)
{
if (_session is not { IsOpen: true, CanWrite: true } || string.IsNullOrWhiteSpace(SelectedTable))
{
throw new InvalidOperationException("Open a writable table first.");
}
await _session.InsertRowAsync(SelectedTable, values).ConfigureAwait(true);
Status = "Row inserted.";
await LoadTablePageAsync(PageOffset).ConfigureAwait(true);
}
public IReadOnlyList<string> EditableColumns()
=> Columns.Where(c => !c.Equals("_rowid_", StringComparison.OrdinalIgnoreCase)).ToList();
partial void OnSelectedTableChanged(string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
_ = LoadTablePageAsync(0);
}
}
private async Task ReloadTablesAsync()
{
if (_session is not { IsOpen: true })
{
return;
}
var tables = await _session.ListTablesAsync().ConfigureAwait(true);
Tables.Clear();
foreach (var table in tables)
{
Tables.Add(table);
}
if (SelectedTable is null || !Tables.Contains(SelectedTable))
{
SelectedTable = Tables.FirstOrDefault();
}
}
private async Task LoadTablePageAsync(int offset)
{
if (_session is not { IsOpen: true } || string.IsNullOrWhiteSpace(SelectedTable))
{
return;
}
try
{
var page = await _session.ReadTableAsync(SelectedTable, offset, PageSize).ConfigureAwait(true);
PageOffset = page.Offset;
TotalRows = page.TotalRows;
PageLabel = page.TotalRows == 0
? "0 rows"
: $"{page.Offset + 1}{page.Offset + page.Rows.Count} of {page.TotalRows}";
ApplyGrid(page.Columns, page.Rows, page.TotalRows, page.Offset);
Status = "Table " + SelectedTable + " · " + PageLabel;
}
catch (Exception ex)
{
Status = "Load failed: " + ex.Message;
}
}
private void ApplyGrid(
IReadOnlyList<string> columns,
IReadOnlyList<IReadOnlyList<object?>> rows,
long total,
int offset)
{
Columns.Clear();
foreach (var column in columns)
{
Columns.Add(column);
}
Rows.Clear();
var rowIdIndex = -1;
for (var i = 0; i < columns.Count; i++)
{
if (columns[i].Equals("_rowid_", StringComparison.OrdinalIgnoreCase))
{
rowIdIndex = i;
break;
}
}
foreach (var row in rows)
{
long? rowId = null;
if (rowIdIndex >= 0 && rowIdIndex < row.Count && row[rowIdIndex] is not null)
{
rowId = Convert.ToInt64(row[rowIdIndex], System.Globalization.CultureInfo.InvariantCulture);
}
Rows.Add(new SqliteGridRow
{
RowId = rowId,
Cells = row.Select(FormatCell).ToList()
});
}
TotalRows = total;
PageOffset = offset;
GridChanged?.Invoke(this, EventArgs.Empty);
}
private void ClearGrid()
{
Columns.Clear();
Rows.Clear();
PageOffset = 0;
TotalRows = 0;
PageLabel = "";
GridChanged?.Invoke(this, EventArgs.Empty);
}
private static string FormatCell(object? value)
=> value switch
{
null => "",
byte[] bytes => "BLOB (" + bytes.Length + " bytes)",
DateTime dt => dt.ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
IFormattable f => f.ToString(null, System.Globalization.CultureInfo.InvariantCulture) ?? "",
_ => value.ToString() ?? ""
};
public async ValueTask DisposeAsync()
{
if (_session is not null)
{
await _session.DisposeAsync().ConfigureAwait(false);
_session = null;
}
}
}
public sealed class SqliteGridRow
{
public long? RowId { get; init; }
public required IReadOnlyList<string> Cells { get; init; }
}

View File

@@ -1,18 +1,22 @@
using System.Collections.ObjectModel;
using System.Threading.Channels;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexMutations _mutations;
private readonly IIndexingHost _indexing;
private readonly SourceManager _sources;
private readonly AnalysisService _analysis;
private CancellationTokenSource? _load;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
@@ -20,9 +24,14 @@ public sealed partial class DuplicateViewModel : ObservableObject
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
public DuplicateViewModel(
IIndexMutations mutations,
IIndexingHost indexing,
SourceManager sources,
AnalysisService analysis)
{
_mutations = mutations;
_indexing = indexing;
_sources = sources;
_analysis = analysis;
Groups = [];
@@ -35,6 +44,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
[RelayCommand]
public Task CloseAsync()
{
_load?.Cancel();
IsOpen = false;
return Task.CompletedTask;
}
@@ -42,32 +52,98 @@ public sealed partial class DuplicateViewModel : ObservableObject
[RelayCommand]
public async Task OpenAsync()
{
_load?.Cancel();
var cts = new CancellationTokenSource();
_load = cts;
var ct = cts.Token;
IsOpen = true;
IsBusy = true;
Status = "Finding size collisions…";
Groups.Clear();
try
{
var groups = await Task.Run(async () =>
{
await _mutations.EnqueueHashCollisionsAsync(null).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
return classified
.Select(g => DuplicateGroupViewModel.From(g, sources))
.ToList();
}).ConfigureAwait(true);
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
foreach (var group in groups)
try
{
await _mutations.EnqueueHashCollisionsAsync(null, ct).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
Status = "Loading duplicate groups…";
var sources = (await _sources.RefreshOnlineStateAsync(ct).ConfigureAwait(true))
.ToDictionary(s => s.Id);
var channel = Channel.CreateUnbounded<ClassifiedDuplicateGroup>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
var stream = Task.Run(async () =>
{
try
{
return await _analysis.StreamClassifiedDuplicatesAsync(
200,
new DirectProgress<ClassifiedDuplicateGroup>(g => channel.Writer.TryWrite(g)),
verifyPresence: false,
ct)
.ConfigureAwait(false);
}
finally
{
channel.Writer.TryComplete();
}
}, ct);
var added = 0;
await foreach (var classified in channel.Reader.ReadAllAsync(ct).ConfigureAwait(true))
{
ApplyGroup(classified, sources, ref hiddenIntentional, ref hiddenHardlinks);
added++;
if (added % 24 == 0)
{
await Task.Yield();
}
}
await stream.ConfigureAwait(true);
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
IsBusy = false;
_ = VerifyOnDiskAsync(sources, cts, hiddenIntentional, hiddenHardlinks);
}
catch (OperationCanceledException)
{
if (IsOpen && ReferenceEquals(_load, cts))
{
Status = Groups.Count > 0
? BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks)
: "Cancelled.";
}
}
catch (Exception)
{
if (IsOpen && ReferenceEquals(_load, cts))
{
Status = "Could not load duplicates.";
}
}
finally
{
if (ReferenceEquals(_load, cts))
{
IsBusy = false;
}
}
}
private void ApplyGroup(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources,
ref int hiddenIntentional,
ref int hiddenHardlinks)
{
var group = DuplicateGroupViewModel.From(classified, sources);
if (group.Classification == DuplicateClass.Hardlink)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
continue;
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
return;
}
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
@@ -75,23 +151,129 @@ public sealed partial class DuplicateViewModel : ObservableObject
if (!ShowIntentional)
{
hiddenIntentional++;
continue;
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
return;
}
}
Groups.Add(group);
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
private async Task VerifyOnDiskAsync(
IReadOnlyDictionary<long, Source> sources,
CancellationTokenSource cts,
int hiddenIntentional,
int hiddenHardlinks)
{
var ct = cts.Token;
List<DuplicateGroupViewModel> snapshot;
try
{
snapshot = Groups.ToList();
}
catch (Exception)
{
return;
}
if (snapshot.Count == 0)
{
return;
}
IReadOnlyList<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> changes;
HashSet<(long SourceId, string PathRel)> reconcile;
try
{
(changes, reconcile) = await Task.Run(
() => ScanMissing(snapshot, sources, ct),
ct)
.ConfigureAwait(true);
}
catch (OperationCanceledException)
{
return;
}
if (!IsOpen || !ReferenceEquals(_load, cts) || ct.IsCancellationRequested)
{
return;
}
foreach (var (old, next) in changes)
{
var index = Groups.IndexOf(old);
if (index < 0)
{
continue;
}
if (next is null)
{
Groups.RemoveAt(index);
}
else
{
Groups[index] = next;
}
}
foreach (var (sourceId, pathRel) in reconcile)
{
_indexing.EnqueueReconcile(sourceId, pathRel);
}
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
catch (Exception)
private static (
List<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> Changes,
HashSet<(long SourceId, string PathRel)> Reconcile)
ScanMissing(
IReadOnlyList<DuplicateGroupViewModel> groups,
IReadOnlyDictionary<long, Source> sources,
CancellationToken cancellationToken)
{
Status = "Could not load duplicates.";
}
finally
var changes = new List<(DuplicateGroupViewModel, DuplicateGroupViewModel?)>();
var reconcile = new HashSet<(long, string)>();
foreach (var group in groups)
{
IsBusy = false;
cancellationToken.ThrowIfCancellationRequested();
var kept = new List<IndexEntry>(group.Entries.Count);
var dropped = false;
foreach (var entry in group.Entries)
{
if (!sources.TryGetValue(entry.SourceId, out var source)
|| string.IsNullOrWhiteSpace(source.LastRootPath)
|| !IndexedPathPresence.RootReachable(source.LastRootPath)
|| IndexedPathPresence.FileExists(source.LastRootPath, entry.PathRel))
{
kept.Add(entry);
continue;
}
dropped = true;
var prefix = IndexedPathPresence.HighestMissingPrefix(source.LastRootPath, entry.PathRel)
?? entry.PathRel;
reconcile.Add((entry.SourceId, IndexedPathPresence.ReconcilePath(entry.PathRel, prefix)));
}
if (!dropped)
{
continue;
}
if (kept.Count < 2)
{
changes.Add((group, null));
continue;
}
changes.Add((group, group.WithEntries(kept, sources)));
}
return (changes, reconcile);
}
[RelayCommand]
@@ -147,6 +329,19 @@ public sealed partial class DuplicateViewModel : ObservableObject
}
}
private static string LiveStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
var extra = hiddenIntentional + hiddenHardlinks;
if (visible == 0 && extra == 0)
{
return "Loading duplicate groups…";
}
return extra == 0
? $"{visible} duplicate groups so far…"
: $"{visible} duplicate groups so far · {extra} hidden";
}
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
if (visible > 0)
@@ -164,12 +359,18 @@ public sealed partial class DuplicateViewModel : ObservableObject
return "No confirmed duplicates yet. Hashing continues in the background.";
}
private sealed class DirectProgress<T>(Action<T> action) : IProgress<T>
{
public void Report(T value) => action(value);
}
}
public sealed class DuplicateGroupViewModel
{
public required IReadOnlyList<IndexEntry> Entries { get; init; }
public DuplicateClass Classification { get; init; }
public required string Header { get; init; }
public required string ClassLabel { get; init; }
public required string SizeLabel { get; init; }
public required string Summary { get; init; }
@@ -178,6 +379,30 @@ public sealed class DuplicateGroupViewModel
public bool CanMarkAccidental { get; init; }
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
public DuplicateGroupViewModel WithEntries(
IReadOnlyList<IndexEntry> entries,
IReadOnlyDictionary<long, Source> sources)
{
var unique = DuplicateClassifier.UniqueFileCount(entries);
var size = entries.Count > 0 ? entries[0].SizeBytes : 0;
return From(
new ClassifiedDuplicateGroup
{
Group = new DuplicateGroup
{
SizeBytes = size,
Entries = entries,
SameFileId = DuplicateClassifier.IsHardlinkOnly(entries)
},
Classification = Classification,
UniqueFileCount = unique,
WastedBytes = Classification == DuplicateClass.Hardlink || unique <= 1
? 0
: size * (unique - 1)
},
sources);
}
public static DuplicateGroupViewModel From(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources)
@@ -197,16 +422,27 @@ public sealed class DuplicateGroupViewModel
};
}).ToList();
var classLabel = classified.Classification == DuplicateClass.Unknown
? ""
: DuplicateClassifier.Label(classified.Classification);
var sizeLabel = Formatters.Size(classified.Group.SizeBytes);
var summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names";
var wasted = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space";
var header = string.IsNullOrEmpty(classLabel)
? $"{sizeLabel} · {summary} · {wasted}"
: $"{classLabel} · {sizeLabel} · {summary} · {wasted}";
return new DuplicateGroupViewModel
{
Entries = classified.Group.Entries,
Classification = classified.Classification,
ClassLabel = DuplicateClassifier.Label(classified.Classification),
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
WastedLabel = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space",
Header = header,
ClassLabel = classLabel,
SizeLabel = sizeLabel,
Summary = summary,
WastedLabel = wasted,
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink

View File

@@ -0,0 +1,324 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Contracts;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed partial class HostActivityMonitorViewModel : ObservableObject, IDisposable
{
public const int LiveTailLimit = 40;
private readonly IHostActivity _activity;
private readonly IBackgroundMaintenance? _maintenance;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
private CancellationTokenSource? _loop;
private int _refreshBusy;
private int _applyQueued;
private HostActivitySnapshot? _pendingSnap;
private string _eventsFingerprint = "";
[ObservableProperty] private string _summary = "Connecting…";
[ObservableProperty] private string _maintenanceText = "";
[ObservableProperty] private string _indexingText = "";
[ObservableProperty] private string _hashText = "";
[ObservableProperty] private string _transferText = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _isLive;
[ObservableProperty] private string _logCaption = "Activity log · live";
public HostActivityMonitorViewModel(
IHostActivity activity,
IBackgroundMaintenance? maintenance = null,
IIndexingHost? indexing = null,
ITransferHost? transfers = null)
{
_activity = activity;
_maintenance = maintenance;
_ = indexing;
_ = transfers;
Jobs = [];
Events = [];
}
public ObservableCollection<HostActivityJobRow> Jobs { get; }
public ObservableCollection<HostActivityEventRow> Events { get; }
public event EventHandler? EventsReplaced;
public void Start()
{
if (_loop is not null)
{
return;
}
_loop = new CancellationTokenSource();
IsLive = true;
_ = RunLoopAsync(_loop.Token);
}
public void Dispose()
{
_loop?.Cancel();
_loop?.Dispose();
_loop = null;
IsLive = false;
}
[RelayCommand]
private Task RefreshAsync() => RefreshCoreAsync(CancellationToken.None);
[RelayCommand]
private void RunMaintenanceNow() => _maintenance?.RunNow();
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
try
{
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// window closed
}
catch (Exception ex)
{
Post(() =>
{
Status = "Monitor stopped: " + ex.Message;
IsLive = false;
});
}
}
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
{
if (Interlocked.CompareExchange(ref _refreshBusy, 1, 0) != 0)
{
return;
}
try
{
var snap = await _activity.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
{
return;
}
Volatile.Write(ref _pendingSnap, snap);
if (Interlocked.Exchange(ref _applyQueued, 1) == 0)
{
Post(FlushPendingSnapshot);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
Post(() => Status = "Could not refresh: " + ex.Message);
}
finally
{
Interlocked.Exchange(ref _refreshBusy, 0);
}
}
private void FlushPendingSnapshot()
{
Interlocked.Exchange(ref _applyQueued, 0);
var snap = Interlocked.Exchange(ref _pendingSnap, null);
if (snap is null || _loop is null)
{
return;
}
Apply(snap);
Status = "Updated " + snap.Utc.ToLocalTime().ToString("HH:mm:ss");
IsLive = true;
if (Volatile.Read(ref _pendingSnap) is not null && Interlocked.Exchange(ref _applyQueued, 1) == 0)
{
Post(FlushPendingSnapshot);
}
}
private void Apply(HostActivitySnapshot snap)
{
var maint = snap.Maintenance.Message;
if (string.IsNullOrWhiteSpace(maint))
{
maint = snap.Maintenance.Allowed ? "Idle allowed" : "Idle paused";
if (!string.IsNullOrWhiteSpace(snap.Maintenance.SkipReason))
{
maint += " · " + snap.Maintenance.SkipReason;
}
}
MaintenanceText = maint;
IndexingText = FormatIndexing(snap);
HashText = FormatHash(snap);
TransferText = FormatTransfers(snap);
Summary = BuildSummary(snap, maint);
Jobs.Clear();
foreach (var job in snap.IndexingJobs)
{
Jobs.Add(new HostActivityJobRow
{
Title = job.Kind + " · " + job.SourceName,
Detail = (job.PathRel ?? "") + (job.VerifyChildren ? " · verify children" : ""),
Origin = job.Origin
});
}
ReplaceEvents(snap.RecentEvents);
}
private void ReplaceEvents(IReadOnlyList<HostActivityEvent> recent)
{
var tail = recent.Count <= LiveTailLimit
? recent
: recent.Skip(recent.Count - LiveTailLimit).ToList();
var fingerprint = tail.Count == 0
? ""
: tail.Count + "|" + tail[^1].Utc.UtcTicks + "|" + tail[^1].Message;
if (fingerprint == _eventsFingerprint)
{
return;
}
_eventsFingerprint = fingerprint;
Events.Clear();
foreach (var evt in tail)
{
Events.Add(new HostActivityEventRow
{
Time = evt.Utc.ToLocalTime().ToString("HH:mm:ss"),
Category = evt.Category,
Message = evt.Message
});
}
LogCaption = Events.Count == 0
? "Activity log · live"
: "Activity log · last " + Events.Count + " (newest at bottom)";
EventsReplaced?.Invoke(this, EventArgs.Empty);
}
private static string FormatIndexing(HostActivitySnapshot snap)
{
if (snap.IndexingJobs.Count > 0)
{
var job = snap.IndexingJobs[0];
return job.Kind + " · " + job.SourceName
+ (string.IsNullOrEmpty(job.PathRel) ? "" : " · " + job.PathRel)
+ " · " + job.Origin
+ (string.IsNullOrEmpty(snap.LastIndexPath) ? "" : " · " + snap.LastIndexPath);
}
if (!string.IsNullOrWhiteSpace(snap.LastIndexPath))
{
return (snap.LastIndexStatus ?? "Idle") + " · " + snap.LastIndexFilesDone + " files · " + snap.LastIndexPath;
}
return snap.IdleQueued > 0
? "Idle queue " + snap.IdleQueued + (snap.IdleAllowed ? " (allowed)" : " (held)")
: "Idle";
}
private static string FormatHash(HostActivitySnapshot snap)
{
if (snap.HashPaused && snap.HashPending == 0)
{
return "Paused";
}
if (snap.HashPaused)
{
return "Paused · " + snap.HashPending.ToString("N0") + " pending";
}
if (!string.IsNullOrWhiteSpace(snap.HashCurrentPath))
{
return snap.HashPending.ToString("N0") + " pending · " + snap.HashCurrentPath;
}
return snap.HashPending > 0 ? snap.HashPending.ToString("N0") + " pending" : "Idle";
}
private static string FormatTransfers(HostActivitySnapshot snap)
{
var text = snap.TransfersActive + " active · " + snap.TransfersQueued + " queued"
+ (snap.TransfersPaused ? " · paused" : "");
if (!string.IsNullOrWhiteSpace(snap.TransferCurrentPath))
{
text += " · " + snap.TransferCurrentPath;
}
return text;
}
private static string BuildSummary(HostActivitySnapshot snap, string maintenanceText)
{
if (snap.IndexingJobs.Count > 0)
{
var job = snap.IndexingJobs[0];
var more = snap.IndexingJobs.Count > 1 ? " (+" + (snap.IndexingJobs.Count - 1) + " more)" : "";
return job.Kind + " · " + job.SourceName + " · " + job.Origin + more;
}
if (!string.IsNullOrWhiteSpace(maintenanceText)
&& !maintenanceText.StartsWith("Idle allowed", StringComparison.Ordinal)
&& !maintenanceText.StartsWith("Idle paused", StringComparison.Ordinal))
{
return maintenanceText;
}
if (!snap.HashPaused && snap.HashPending > 0)
{
return "Hashing · " + snap.HashPending.ToString("N0") + " pending";
}
if (snap.TransfersActive > 0)
{
return "Transfers · " + snap.TransfersActive + " active";
}
return string.IsNullOrWhiteSpace(maintenanceText) ? "Host idle" : maintenanceText;
}
private void Post(Action action)
{
if (_ui is null)
{
action();
return;
}
_ui.Post(_ => action(), null);
}
}
public sealed class HostActivityJobRow
{
public required string Title { get; init; }
public required string Detail { get; init; }
public required string Origin { get; init; }
}
public sealed class HostActivityEventRow
{
public required string Time { get; init; }
public required string Category { get; init; }
public required string Message { get; init; }
}

View File

@@ -37,11 +37,18 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IThumbnailService? _thumbnails;
private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance;
private readonly ITransferHost _transferHost;
private readonly IHostActivity? _hostActivity;
private readonly ISqliteDatabaseSessionFactory? _databaseSessions;
private readonly IAppEnvironment? _environment;
private readonly IShellContextMenu? _shellMenu;
private readonly IMediaTagService? _tags;
private bool _hostStopped;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
private int _overlayDirty;
private int _overlayRunning;
private DateTimeOffset _lastOverlayRefreshUtc = DateTimeOffset.MinValue;
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
[ObservableProperty] private string _pathText = "";
@@ -104,12 +111,19 @@ public sealed partial class MainViewModel : ObservableObject
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null,
IHostActivity? hostActivity = null,
ISqliteDatabaseSessionFactory? databaseSessions = null,
IAppEnvironment? environment = null,
IShellContextMenu? shellMenu = null,
IMediaTagService? tags = null)
{
_browse = browse;
_ops = ops;
_indexing = workbench.Indexing;
_transferHost = workbench.Transfers;
_hostActivity = hostActivity;
_databaseSessions = databaseSessions;
_environment = environment;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
@@ -174,7 +188,7 @@ public sealed partial class MainViewModel : ObservableObject
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 = new DuplicateViewModel(mutations, _indexing, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
_sources.PresenceChanged += (_, source) =>
{
@@ -207,16 +221,7 @@ public sealed partial class MainViewModel : ObservableObject
{
if (p.IndexRefresh)
{
void Refresh() => _ = RefreshIndexOverlayAsync();
if (_ui is { } refreshCtx)
{
refreshCtx.Post(_ => Refresh(), null);
}
else
{
Refresh();
}
ScheduleIndexOverlayRefresh();
return;
}
@@ -243,6 +248,53 @@ public sealed partial class MainViewModel : ObservableObject
};
}
private void ScheduleIndexOverlayRefresh()
{
Interlocked.Exchange(ref _overlayDirty, 1);
if (Interlocked.CompareExchange(ref _overlayRunning, 1, 0) != 0)
{
return;
}
void Start() => _ = RunIndexOverlayRefreshAsync();
if (_ui is { } ctx)
{
ctx.Post(_ => Start(), null);
}
else
{
Start();
}
}
private async Task RunIndexOverlayRefreshAsync()
{
try
{
do
{
Interlocked.Exchange(ref _overlayDirty, 0);
var wait = TimeSpan.FromSeconds(2.5) - (DateTimeOffset.UtcNow - _lastOverlayRefreshUtc);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait).ConfigureAwait(true);
}
_lastOverlayRefreshUtc = DateTimeOffset.UtcNow;
await RefreshIndexOverlayAsync().ConfigureAwait(true);
}
while (Volatile.Read(ref _overlayDirty) == 1);
}
finally
{
Interlocked.Exchange(ref _overlayRunning, 0);
if (Interlocked.Exchange(ref _overlayDirty, 0) == 1)
{
ScheduleIndexOverlayRefresh();
}
}
}
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
public ObservableCollection<string> PathHistory { get; }
public NavigationTreeViewModel Tree { get; }
@@ -688,6 +740,16 @@ public sealed partial class MainViewModel : ObservableObject
public GitChangesViewModel CreateGitChangesViewModel()
=> new(_gitCommands, _workspace, _hydration);
public HostActivityMonitorViewModel? CreateHostActivityMonitorViewModel()
=> _hostActivity is null
? null
: new HostActivityMonitorViewModel(_hostActivity, _maintenance, _indexing, _transferHost);
public DatabaseViewerViewModel? CreateDatabaseViewerViewModel()
=> _databaseSessions is null || _environment is null
? null
: new DatabaseViewerViewModel(_databaseSessions, _environment);
public async Task<GitCommitViewModel?> CreateGitCommitViewModelAsync()
{
var path = GitWorkspacePath();

View File

@@ -228,9 +228,16 @@ internal sealed class HashStore : IHashStore
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var count = await conn.ExecuteScalarAsync<long>(
var hit = await conn.ExecuteScalarAsync<long?>(
"SELECT 1 FROM hash_queue WHERE state IN ('Pending','PartialDone') LIMIT 1").ConfigureAwait(false);
return hit is 1;
}
public async Task<long> CountPendingAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
return 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)
@@ -306,36 +313,100 @@ internal sealed class HashStore : IHashStore
}, cancellationToken);
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
{
var hashes = await GetDuplicateHashesAsync(sourceId, pathPrefix, take, cancellationToken).ConfigureAwait(false);
return await GetDuplicateGroupsByHashesAsync(hashes, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(
long? sourceId,
string? pathPrefix,
int take,
CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var sql = """
SELECT * FROM entries
SELECT content_hash
FROM entries
WHERE is_dir=0 AND status=0 AND hash_state=2 AND content_hash IS NOT NULL
""";
if (sourceId is not null) sql += " AND source_id=@sourceId";
if (sourceId is not null)
{
sql += " AND source_id=@sourceId";
}
if (!string.IsNullOrEmpty(pathPrefix))
{
sql += " AND (path_rel=@pathPrefix OR path_rel LIKE @like ESCAPE '\\')";
sql += " ORDER BY content_hash, file_id";
}
sql += """
GROUP BY content_hash
HAVING COUNT(*) > 1
ORDER BY MAX(size_bytes) DESC
LIMIT @take
""";
var like = string.IsNullOrEmpty(pathPrefix) ? null : pathPrefix.Replace("\\", "\\\\") + "\\\\%";
var rows = (await conn.QueryAsync<EntryRow>(sql, new { sourceId, pathPrefix, like }).ConfigureAwait(false))
var rows = await conn.QueryAsync<HashBlobRow>(sql, new { sourceId, pathPrefix, like, take })
.ConfigureAwait(false);
return rows.Select(r => r.content_hash).Where(h => h.Length > 0).ToList();
}
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
IReadOnlyList<byte[]> hashes,
CancellationToken cancellationToken = default)
{
if (hashes.Count == 0)
{
return [];
}
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var parameters = new DynamicParameters();
var ors = new string[hashes.Count];
for (var i = 0; i < hashes.Count; i++)
{
var name = "h" + i;
ors[i] = "content_hash=@" + name;
parameters.Add(name, hashes[i]);
}
var sql = $"""
SELECT * FROM entries
WHERE is_dir=0 AND status=0 AND hash_state=2 AND ({string.Join(" OR ", ors)})
ORDER BY content_hash, file_id
""";
var rows = (await conn.QueryAsync<EntryRow>(sql, parameters).ConfigureAwait(false))
.Select(r => r.ToModel())
.ToList();
return rows
.GroupBy(e => Convert.ToHexString(e.ContentHash!))
.Where(g => g.Count() > 1)
.Take(take)
.Select(g =>
var byHash = rows
.Where(e => e.ContentHash is { Length: > 0 })
.GroupBy(e => Convert.ToHexString(e.ContentHash!), StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var result = new List<DuplicateGroup>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var hash in hashes)
{
var list = g.ToList();
return new DuplicateGroup
var hex = Convert.ToHexString(hash);
if (!seen.Add(hex) || !byHash.TryGetValue(hex, out var list) || list.Count < 2)
{
continue;
}
result.Add(new DuplicateGroup
{
SizeBytes = list[0].SizeBytes,
Hash = list[0].ContentHash,
Entries = list,
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
};
})
.ToList();
});
}
return result;
}
private sealed class HashBlobRow
{
public byte[] content_hash { get; set; } = [];
}
}

View File

@@ -173,6 +173,7 @@ internal static class SchemaScript
state TEXT NOT NULL
)
""",
"CREATE INDEX IF NOT EXISTS ix_hash_queue_state ON hash_queue(state, priority, size_bytes)",
"""
CREATE TABLE source_stats_history (
id INTEGER PRIMARY KEY,

View File

@@ -0,0 +1,336 @@
using System.Data;
using System.Globalization;
using Explorer.Application;
using Explorer.Domain.Abstractions;
using Microsoft.Data.Sqlite;
namespace Explorer.Storage.Sqlite;
public sealed class SqliteDatabaseSessionFactory(IAppEnvironment environment) : ISqliteDatabaseSessionFactory
{
public ISqliteDatabaseSession Create() => new SqliteDatabaseSession(environment.DatabasePath);
}
public sealed class SqliteDatabaseSession : ISqliteDatabaseSession
{
private readonly string _defaultIndexPath;
private SqliteConnection? _connection;
private IndexStoreLock? _writeLock;
public SqliteDatabaseSession(string defaultIndexPath) => _defaultIndexPath = defaultIndexPath;
public string? Path { get; private set; }
public bool IsOpen => _connection is { State: ConnectionState.Open };
public bool CanWrite { get; private set; }
public string ModeLabel { get; private set; } = "Closed";
public async Task OpenAsync(string path, bool preferWrite, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
await CloseAsync().ConfigureAwait(false);
var full = System.IO.Path.GetFullPath(path);
var isIndex = string.Equals(full, System.IO.Path.GetFullPath(_defaultIndexPath), StringComparison.OrdinalIgnoreCase);
var held = IndexStoreLock.IsHeld(full);
var wantWrite = preferWrite && !(isIndex && held);
if (wantWrite)
{
try
{
if (isIndex)
{
_writeLock = IndexStoreLock.Acquire(full, TimeSpan.Zero);
}
_connection = new SqliteConnection(SqliteIndexStore.BuildConnectionString(full, readOnly: false));
await _connection.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyWritePragmas(_connection);
CanWrite = true;
ModeLabel = isIndex ? "Read-write (Workbench index)" : "Read-write";
}
catch
{
_writeLock?.Dispose();
_writeLock = null;
wantWrite = false;
}
}
if (!wantWrite)
{
_connection = new SqliteConnection(SqliteIndexStore.BuildConnectionString(full, readOnly: true));
await _connection.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(_connection);
CanWrite = false;
ModeLabel = isIndex && held
? "Read-only (index in use by host)"
: preferWrite
? "Read-only (could not open for write)"
: "Read-only";
}
Path = full;
}
public Task CloseAsync()
{
if (_connection is not null)
{
_connection.Dispose();
_connection = null;
}
_writeLock?.Dispose();
_writeLock = null;
Path = null;
CanWrite = false;
ModeLabel = "Closed";
return Task.CompletedTask;
}
public async ValueTask DisposeAsync() => await CloseAsync().ConfigureAwait(false);
public async Task<IReadOnlyList<string>> ListTablesAsync(CancellationToken cancellationToken = default)
{
var conn = RequireOpen();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name COLLATE NOCASE
""";
var list = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
list.Add(reader.GetString(0));
}
return list;
}
public async Task<SqliteTablePage> ReadTableAsync(
string table,
int offset,
int take,
CancellationToken cancellationToken = default)
{
var conn = RequireOpen();
var ident = QuoteIdent(table);
take = Math.Clamp(take, 1, 500);
offset = Math.Max(0, offset);
long total;
await using (var count = conn.CreateCommand())
{
count.CommandText = "SELECT COUNT(*) FROM " + ident;
total = Convert.ToInt64(await count.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), CultureInfo.InvariantCulture);
}
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT rowid AS _rowid_, * FROM " + ident + " LIMIT $take OFFSET $offset";
cmd.Parameters.AddWithValue("$take", take);
cmd.Parameters.AddWithValue("$offset", offset);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var (columns, rows) = await ReadGridAsync(reader, cancellationToken).ConfigureAwait(false);
return new SqliteTablePage
{
Columns = columns,
Rows = rows,
TotalRows = total,
Offset = offset
};
}
public async Task<SqliteQueryResult> ExecuteAsync(string sql, CancellationToken cancellationToken = default)
{
var conn = RequireOpen();
if (string.IsNullOrWhiteSpace(sql))
{
return new SqliteQueryResult { Message = "Empty SQL." };
}
var trimmed = sql.Trim().TrimEnd(';');
var isQuery = LooksLikeQuery(trimmed);
if (!CanWrite && !isQuery)
{
throw new InvalidOperationException("Database is open read-only.");
}
await using var cmd = conn.CreateCommand();
cmd.CommandText = trimmed;
if (isQuery)
{
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var (columns, rows) = await ReadGridAsync(reader, cancellationToken).ConfigureAwait(false);
return new SqliteQueryResult
{
IsQuery = true,
Columns = columns,
Rows = rows,
Message = rows.Count + " row(s)"
};
}
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return new SqliteQueryResult
{
IsQuery = false,
RecordsAffected = affected,
Message = affected + " row(s) affected"
};
}
public async Task UpdateCellAsync(
string table,
long rowId,
string column,
object? value,
CancellationToken cancellationToken = default)
{
EnsureWritable();
if (column.Equals("_rowid_", StringComparison.OrdinalIgnoreCase) || column.Equals("rowid", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Cannot edit rowid.");
}
var conn = RequireOpen();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE " + QuoteIdent(table) + " SET " + QuoteIdent(column) + " = $v WHERE rowid = $id";
cmd.Parameters.AddWithValue("$v", value ?? DBNull.Value);
cmd.Parameters.AddWithValue("$id", rowId);
var n = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (n == 0)
{
throw new InvalidOperationException("No row updated.");
}
}
public async Task DeleteRowAsync(string table, long rowId, CancellationToken cancellationToken = default)
{
EnsureWritable();
var conn = RequireOpen();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM " + QuoteIdent(table) + " WHERE rowid = $id";
cmd.Parameters.AddWithValue("$id", rowId);
var n = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (n == 0)
{
throw new InvalidOperationException("No row deleted.");
}
}
public async Task InsertRowAsync(
string table,
IReadOnlyDictionary<string, object?> values,
CancellationToken cancellationToken = default)
{
EnsureWritable();
if (values.Count == 0)
{
throw new InvalidOperationException("No column values provided.");
}
var cols = values.Keys.Select(QuoteIdent).ToList();
var pars = values.Keys.Select((_, i) => "$p" + i).ToList();
var conn = RequireOpen();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO " + QuoteIdent(table) + " (" + string.Join(", ", cols) + ") VALUES ("
+ string.Join(", ", pars) + ")";
var i = 0;
foreach (var value in values.Values)
{
cmd.Parameters.AddWithValue("$p" + i, value ?? DBNull.Value);
i++;
}
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private SqliteConnection RequireOpen()
=> _connection ?? throw new InvalidOperationException("No database is open.");
private void EnsureWritable()
{
if (!CanWrite)
{
throw new InvalidOperationException("Database is open read-only.");
}
}
private static bool LooksLikeQuery(string sql)
{
var span = sql.AsSpan().TrimStart();
return span.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
|| span.StartsWith("WITH", StringComparison.OrdinalIgnoreCase)
|| span.StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase)
|| span.StartsWith("EXPLAIN", StringComparison.OrdinalIgnoreCase);
}
private static string QuoteIdent(string name)
{
if (string.IsNullOrWhiteSpace(name)
|| name.Any(c => !(char.IsLetterOrDigit(c) || c is '_' or '$')))
{
throw new InvalidOperationException("Invalid identifier: " + name);
}
return "\"" + name.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
private static async Task<(IReadOnlyList<string> Columns, IReadOnlyList<IReadOnlyList<object?>> Rows)> ReadGridAsync(
SqliteDataReader reader,
CancellationToken cancellationToken)
{
var columns = new string[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
columns[i] = reader.GetName(i);
}
var rows = new List<IReadOnlyList<object?>>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var cells = new object?[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
cells[i] = reader.IsDBNull(i) ? null : reader.GetValue(i);
}
rows.Add(cells);
}
return (columns, rows);
}
private static void ApplyReadPragmas(SqliteConnection conn)
{
foreach (var pragma in new[]
{
"PRAGMA query_only = ON;",
"PRAGMA foreign_keys = ON;",
"PRAGMA busy_timeout = 5000;"
})
{
using var cmd = conn.CreateCommand();
cmd.CommandText = pragma;
cmd.ExecuteNonQuery();
}
}
private static void ApplyWritePragmas(SqliteConnection conn)
{
foreach (var pragma in new[]
{
"PRAGMA foreign_keys = ON;",
"PRAGMA busy_timeout = 5000;",
"PRAGMA journal_mode = WAL;"
})
{
using var cmd = conn.CreateCommand();
cmd.CommandText = pragma;
cmd.ExecuteNonQuery();
}
}
}

View File

@@ -592,6 +592,20 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
EnsureColumn(conn, "operation_profiles", "convert_kind", "TEXT NOT NULL DEFAULT 'VideoToMp4'");
SetUserVersion(conn, 9);
_logger.LogInformation("Migrated SQLite schema to v9 (conversion profiles)");
version = 9;
}
if (version < 10)
{
using (var idx = conn.CreateCommand())
{
idx.CommandText =
"CREATE INDEX IF NOT EXISTS ix_hash_queue_state ON hash_queue(state, priority, size_bytes)";
idx.ExecuteNonQuery();
}
SetUserVersion(conn, 10);
_logger.LogInformation("Migrated SQLite schema to v10 (hash queue state index)");
}
}

View File

@@ -178,10 +178,44 @@ public class AnalysisTests
}
}
[Fact]
public async Task Missing_files_are_tombstoned_when_loading_duplicates()
{
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
await using (store)
{
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
var classified = await analysis.GetClassifiedDuplicatesAsync();
Assert.Empty(classified);
var gone = await store.Entries.GetByPathAsync(source.Id, "a.bin");
Assert.Equal(EntryStatus.Deleted, gone!.Status);
}
}
[Fact]
public async Task Remaining_copies_still_show_when_one_duplicate_is_gone()
{
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
await using (store)
{
File.WriteAllBytes(Path.Combine(source.LastRootPath!, "c.bin"), new byte[64]);
var root = await store.Entries.GetByPathAsync(source.Id, "");
await store.Entries.UpsertAsync(Hashed("c.bin", 3, source.Id, root!.Id, Enumerable.Repeat((byte)7, 32).ToArray()));
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
var classified = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
Assert.Equal(2, classified.Group.Entries.Count);
Assert.DoesNotContain(classified.Group.Entries, e => e.PathRel == "a.bin");
}
}
private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync(
long fileIdA, long fileIdB)
{
var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db");
var rootPath = Path.Combine(Path.GetTempPath(), "ew-dup-fs", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(rootPath);
File.WriteAllBytes(Path.Combine(rootPath, "a.bin"), new byte[64]);
File.WriteAllBytes(Path.Combine(rootPath, "b.bin"), new byte[64]);
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
await store.OpenAsync();
var source = new Source
@@ -189,7 +223,7 @@ public class AnalysisTests
StableKey = "d",
DisplayName = "D",
Kind = SourceKind.NtfsLocal,
LastRootPath = @"C:\d",
LastRootPath = rootPath,
Status = SourceStatus.Online
};
source.Id = await store.Sources.UpsertAsync(source);

View File

@@ -0,0 +1,31 @@
using Explorer.Application;
using Explorer.Contracts;
namespace Explorer.Application.Tests;
public class HostActivityLogTests
{
[Fact]
public void Ring_keeps_recent_events_in_order()
{
var log = new HostActivityLog(8);
for (var i = 0; i < 10; i++)
{
log.Record("Indexing", "file-" + i);
}
var recent = log.TakeRecent(5);
Assert.Equal(5, recent.Count);
Assert.Equal("file-5", recent[0].Message);
Assert.Equal("file-9", recent[^1].Message);
}
[Fact]
public void Duplicate_messages_are_coalesced()
{
var log = new HostActivityLog();
log.Record("Maintenance", "same");
log.Record("Maintenance", "same");
Assert.Single(log.TakeRecent());
}
}

View File

@@ -0,0 +1,85 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class IndexedPathPresenceTests
{
[Fact]
public void Missing_tree_returns_the_highest_gone_folder()
{
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
Assert.Equal(
"gone",
IndexedPathPresence.HighestMissingPrefix(root, @"gone\deep\file.txt"));
Assert.Equal("nope.txt", IndexedPathPresence.HighestMissingPrefix(root, "nope.txt"));
File.WriteAllText(Path.Combine(root, "keep.txt"), "x");
Assert.Null(IndexedPathPresence.HighestMissingPrefix(root, "keep.txt"));
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public void Archive_inner_path_follows_the_archive_file()
{
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var zip = Path.Combine(root, "pack.zip");
File.WriteAllBytes(zip, [1, 2, 3]);
try
{
Assert.True(IndexedPathPresence.FileExists(root, @"pack.zip\inner.txt"));
File.Delete(zip);
Assert.False(IndexedPathPresence.FileExists(root, @"pack.zip\inner.txt"));
Assert.Equal("pack.zip", IndexedPathPresence.HighestMissingPrefix(root, @"pack.zip\inner.txt"));
}
finally
{
if (Directory.Exists(root))
{
Directory.Delete(root, true);
}
}
}
[Fact]
public void Reconcile_path_uses_the_parent_for_a_missing_file()
{
Assert.Equal("gone", IndexedPathPresence.ReconcilePath(@"gone\deep\file.txt", "gone"));
Assert.Equal("", IndexedPathPresence.ReconcilePath("nope.txt", "nope.txt"));
Assert.Equal("keep", IndexedPathPresence.ReconcilePath(@"keep\a.txt", @"keep\a.txt"));
}
[Fact]
public void Long_paths_are_detected_with_the_extended_prefix()
{
var root = Path.Combine(Path.GetTempPath(), "ew-idx", Guid.NewGuid().ToString("N"));
var relative = Path.Combine(new string('a', 140), new string('b', 140), "file.txt");
var full = PathRules.Combine(root, relative);
Directory.CreateDirectory(PathRules.ToExtended(PathRules.Parent(full)));
File.WriteAllText(PathRules.ToExtended(full), "x");
try
{
Assert.True(IndexedPathPresence.FileExists(root, relative));
File.Delete(PathRules.ToExtended(full));
Assert.False(IndexedPathPresence.FileExists(root, relative));
}
finally
{
try
{
Directory.Delete(PathRules.ToExtended(root), true);
}
catch
{
// ignore
}
}
}
}

View File

@@ -299,6 +299,25 @@ public class DragDropPolicyTests
Assert.False(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"D:\Other"));
}
[Fact]
public void Click_jitter_does_not_start_a_drag()
{
Assert.False(DragDropPolicy.ExceedsDistance(4, 3, DragDropPolicy.DragStartDistance));
Assert.False(DragDropPolicy.ExceedsDistance(12, 8, DragDropPolicy.DragStartDistance));
Assert.True(DragDropPolicy.ExceedsDistance(16, 0, DragDropPolicy.DragStartDistance));
Assert.True(DragDropPolicy.ExceedsDistance(12, 12, DragDropPolicy.DragStartDistance));
}
[Fact]
public void Same_folder_drop_is_redundant()
{
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Docs\b.txt"], @"C:\Docs"));
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Docs\"));
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Other"));
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Other\b.txt"], @"C:\Docs"));
Assert.False(DragDropPolicy.AllAlreadyInDirectory([], @"C:\Docs"));
}
[Fact]
public void Same_volume_uses_drive_or_unc_share()
{

View File

@@ -120,6 +120,21 @@ public class BackgroundMaintenanceCoordinatorTests
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
}
[Fact]
public async Task Run_now_keeps_running_while_user_stays_active()
{
var hash = new FakeHash();
var indexing = new FakeIndexing();
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, sources: [StaleLocal()]);
coordinator.RunNow();
await coordinator.TickAsync(CancellationToken.None);
Assert.Contains("checking", coordinator.Snapshot.Message, StringComparison.OrdinalIgnoreCase);
await coordinator.TickAsync(CancellationToken.None);
Assert.True(indexing.IdleAllowed);
Assert.DoesNotContain("Paused because user is active", coordinator.Snapshot.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Idle_verifies_a_fresh_online_source()
{
@@ -220,10 +235,12 @@ public class BackgroundMaintenanceCoordinatorTests
{
public bool Paused { get; set; } = true;
public bool IsPaused => Paused;
public string? CurrentPath => null;
public void Pause() => Paused = true;
public void Resume() => Paused = false;
public void BeginUserRequested() { }
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
}
private sealed class FakeHistory : IHistoryMaintenance
@@ -303,6 +320,13 @@ public class BackgroundMaintenanceCoordinatorTests
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
public Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<byte[]>>([]);
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
IReadOnlyList<byte[]> hashes, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
}
private sealed class TempEnv : IAppEnvironment

View File

@@ -327,6 +327,30 @@ public class ScannerTests
}
}
[Fact]
public async Task Folder_reconcile_tombs_removed_directory_tree()
{
var root = CreateTree();
try
{
await using var store = await OpenStore();
var source = await AddSource(store, root);
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
Directory.Delete(Path.Combine(root, "Movies"), recursive: true);
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
Assert.True(await reconciler.ReconcileAsync(source, "Movies", CancellationToken.None));
var folder = await store.Entries.GetByPathAsync(source.Id, "Movies");
var child = await store.Entries.GetByPathAsync(source.Id, @"Movies\b.txt");
Assert.Equal(EntryStatus.Deleted, folder!.Status);
Assert.Equal(EntryStatus.Deleted, child!.Status);
}
finally
{
TryDelete(root);
}
}
[Fact]
public async Task Folder_reconcile_tombs_removed_file()
{

View File

@@ -0,0 +1,51 @@
using Explorer.Application;
using Explorer.Storage.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
namespace Explorer.Storage.Tests;
public class SqliteDatabaseSessionTests
{
[Fact]
public async Task Can_browse_and_edit_a_standalone_database()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-sql", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "sample.db");
var indexPath = Path.Combine(dir, "index.db");
await using var writer = new SqliteIndexStore(indexPath, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await writer.CloseAsync();
await using var session = new SqliteDatabaseSession(indexPath);
await session.OpenAsync(path, preferWrite: true);
Assert.True(session.CanWrite);
await session.ExecuteAsync("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);");
await session.InsertRowAsync("notes", new Dictionary<string, object?> { ["body"] = "hello" });
var page = await session.ReadTableAsync("notes", 0, 50);
Assert.Contains("body", page.Columns);
Assert.Single(page.Rows);
var rowId = Convert.ToInt64(page.Rows[0][0]);
await session.UpdateCellAsync("notes", rowId, "body", "world");
var query = await session.ExecuteAsync("SELECT body FROM notes");
Assert.Equal("world", query.Rows[0][0]?.ToString());
await session.DeleteRowAsync("notes", rowId);
Assert.Equal(0, (await session.ReadTableAsync("notes", 0, 10)).TotalRows);
}
[Fact]
public async Task Index_opens_read_only_while_writer_holds_lock()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-sql", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "index.db");
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await using var session = new SqliteDatabaseSession(path);
await session.OpenAsync(path, preferWrite: true);
Assert.False(session.CanWrite);
Assert.Contains("Read-only", session.ModeLabel, StringComparison.OrdinalIgnoreCase);
var tables = await session.ListTablesAsync();
Assert.Contains("sources", tables);
}
}