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:
@@ -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
|
## 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 |
|
| Class | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
@@ -5,6 +6,8 @@ namespace Explorer.Analysis;
|
|||||||
|
|
||||||
public sealed class AnalysisService
|
public sealed class AnalysisService
|
||||||
{
|
{
|
||||||
|
private const int DuplicateHashChunk = 24;
|
||||||
|
|
||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly AnalysisResultCache _cache = new();
|
private readonly AnalysisResultCache _cache = new();
|
||||||
private readonly SemaphoreSlim _ready = new(1, 1);
|
private readonly SemaphoreSlim _ready = new(1, 1);
|
||||||
@@ -93,15 +96,152 @@ public sealed class AnalysisService
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
=> RunOffUiAsync(async ct =>
|
=> RunOffUiAsync(async ct =>
|
||||||
{
|
{
|
||||||
var raw = await _store.Hashes.GetDuplicateGroupsAsync(null, null, Math.Max(take * 8, 400), ct)
|
var list = new List<ClassifiedDuplicateGroup>();
|
||||||
.ConfigureAwait(false);
|
await StreamCoreAsync(take, list.Add, verifyPresence: true, ct).ConfigureAwait(false);
|
||||||
var ids = raw.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
|
return (IReadOnlyList<ClassifiedDuplicateGroup>)list;
|
||||||
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();
|
|
||||||
}, cancellationToken);
|
}, 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(
|
public Task MarkDuplicateGroupAsync(
|
||||||
IReadOnlyList<IndexEntry> entries,
|
IReadOnlyList<IndexEntry> entries,
|
||||||
FileRelationKind kind,
|
FileRelationKind kind,
|
||||||
@@ -156,4 +296,11 @@ public sealed class AnalysisService
|
|||||||
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
|
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct MissingCopy(
|
||||||
|
long SourceId,
|
||||||
|
long EntryId,
|
||||||
|
string PathRel,
|
||||||
|
string Prefix,
|
||||||
|
string Root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,30 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
|||||||
{
|
{
|
||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly IHydrationGuard _hydration;
|
private readonly IHydrationGuard _hydration;
|
||||||
|
private readonly IHostActivitySink _activity;
|
||||||
private readonly ILogger<DuplicateHashWorker> _logger;
|
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 _paused = true;
|
||||||
private volatile bool _userRequested;
|
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;
|
_store = store;
|
||||||
_hydration = hydration;
|
_hydration = hydration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_activity = activity ?? NullHostActivitySink.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsPaused => _paused && !_userRequested;
|
public bool IsPaused => _paused && !_userRequested;
|
||||||
|
public string? CurrentPath => _currentPath;
|
||||||
public void Pause() => _paused = true;
|
public void Pause() => _paused = true;
|
||||||
public void Resume() => _paused = false;
|
public void Resume() => _paused = false;
|
||||||
public void BeginUserRequested() => _userRequested = true;
|
public void BeginUserRequested() => _userRequested = true;
|
||||||
@@ -30,6 +42,61 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
|||||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||||
=> await _store.Hashes.HasPendingAsync(cancellationToken).ConfigureAwait(false);
|
=> 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)
|
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
|
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);
|
var path = PathRules.Combine(item.RootPath, item.PathRel);
|
||||||
|
_currentPath = path;
|
||||||
|
_activity.Record("Hash", item.State + " · " + path);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
@@ -90,6 +159,10 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
|||||||
_logger.LogDebug(ex, "Hash failed for {Path}", path);
|
_logger.LogDebug(ex, "Hash failed for {Path}", path);
|
||||||
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_currentPath = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
78
src/Explorer.App/DatabaseWindow.xaml
Normal file
78
src/Explorer.App/DatabaseWindow.xaml
Normal 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>
|
||||||
159
src/Explorer.App/DatabaseWindow.xaml.cs
Normal file
159
src/Explorer.App/DatabaseWindow.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
|
<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"
|
<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"/>
|
<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"
|
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource FgMuted}"/>
|
Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ public partial class DocumentationWindow : Window
|
|||||||
{
|
{
|
||||||
private FlowDocument? _document;
|
private FlowDocument? _document;
|
||||||
private bool _suppressToc;
|
private bool _suppressToc;
|
||||||
|
private int _renderGeneration;
|
||||||
|
|
||||||
public DocumentationWindow()
|
public DocumentationWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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)
|
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 generation = Interlocked.Increment(ref _renderGeneration);
|
||||||
var parsed = MarkdownParser.Parse(loaded.Markdown);
|
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(
|
var brushes = new DocumentationBrushes(
|
||||||
Brush("Fg"),
|
Brush("Fg"),
|
||||||
Brush("FgMuted"),
|
Brush("FgMuted"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||||
<DockPanel Margin="16">
|
<DockPanel Margin="16">
|
||||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
<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"
|
<Button DockPanel.Dock="Right" Content="Commit…" MinWidth="88" Height="32"
|
||||||
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
|
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
|
||||||
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"
|
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ public partial class GitChangesWindow : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = vm;
|
DataContext = vm;
|
||||||
ViewModel = vm;
|
ViewModel = vm;
|
||||||
|
ModelessWindowClose.EnableEscape(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public GitChangesViewModel ViewModel { get; }
|
public GitChangesViewModel ViewModel { get; }
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
|
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
=> await ShowDiffAsync().ConfigureAwait(true);
|
=> await ShowDiffAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||||
<DockPanel Margin="16">
|
<DockPanel Margin="16">
|
||||||
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
|
<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"
|
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
|
||||||
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
|
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
|
||||||
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ public partial class GitDiffWindow : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContext = vm;
|
DataContext = vm;
|
||||||
Title = vm.Title;
|
Title = vm.Title;
|
||||||
|
ModelessWindowClose.EnableEscape(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/Explorer.App/HostActivityMonitorWindow.xaml
Normal file
130
src/Explorer.App/HostActivityMonitorWindow.xaml
Normal 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>
|
||||||
139
src/Explorer.App/HostActivityMonitorWindow.xaml.cs
Normal file
139
src/Explorer.App/HostActivityMonitorWindow.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,9 @@
|
|||||||
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
|
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="_Development">
|
<MenuItem Header="_Development">
|
||||||
|
<MenuItem Header="_Host activity…" Click="OnHostActivity"/>
|
||||||
|
<MenuItem Header="_Database…" Click="OnDatabase"/>
|
||||||
|
<Separator/>
|
||||||
<MenuItem Header="View _changes…" Click="OnGitChanges"
|
<MenuItem Header="View _changes…" Click="OnGitChanges"
|
||||||
IsEnabled="{Binding ShowGitActions}"/>
|
IsEnabled="{Binding ShowGitActions}"/>
|
||||||
<MenuItem Header="_Commit…" Click="OnGitCommit"
|
<MenuItem Header="_Commit…" Click="OnGitCommit"
|
||||||
@@ -986,22 +989,55 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
|
<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">
|
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
|
||||||
<DockPanel>
|
Margin="48" Padding="16" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
|
||||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
<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}"/>
|
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
|
||||||
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
|
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
<TextBlock Grid.Row="1" 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">
|
<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 intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
|
||||||
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
|
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
|
||||||
</StackPanel>
|
</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}}"/>
|
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ListBox Grid.Row="4"
|
||||||
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
|
ItemsSource="{Binding Duplicates.Groups}"
|
||||||
<ItemsControl.ItemTemplate>
|
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>
|
<DataTemplate>
|
||||||
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
|
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@@ -1016,15 +1052,8 @@
|
|||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
|
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
|
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
|
||||||
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
|
TextTrimming="CharacterEllipsis"/>
|
||||||
<Run Text=" · "/>
|
|
||||||
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
|
|
||||||
<Run Text=" · "/>
|
|
||||||
<Run Text="{Binding Summary, Mode=OneWay}"/>
|
|
||||||
<Run Text=" · "/>
|
|
||||||
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
|
|
||||||
</TextBlock>
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
<ItemsControl ItemsSource="{Binding Files}">
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
@@ -1042,10 +1071,9 @@
|
|||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ItemsControl>
|
</ListBox>
|
||||||
</ScrollViewer>
|
</Grid>
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
private DocumentationWindow? _docs;
|
private DocumentationWindow? _docs;
|
||||||
private GitChangesWindow? _gitChanges;
|
private GitChangesWindow? _gitChanges;
|
||||||
|
private HostActivityMonitorWindow? _hostActivity;
|
||||||
|
private DatabaseWindow? _database;
|
||||||
private Point _dragStart;
|
private Point _dragStart;
|
||||||
private bool _dragPending;
|
private bool _dragPending;
|
||||||
private MouseButton _dragButton;
|
private MouseButton _dragButton;
|
||||||
private FolderItemViewModel? _dragItem;
|
private FolderItemViewModel? _dragItem;
|
||||||
private ListView? _dragList;
|
private ListView? _dragList;
|
||||||
|
private bool _draggingFromHere;
|
||||||
private FolderItemViewModel[] _dragSelection = [];
|
private FolderItemViewModel[] _dragSelection = [];
|
||||||
private bool _dragFromMultiSelect;
|
private bool _dragFromMultiSelect;
|
||||||
private bool _syncingSelection;
|
private bool _syncingSelection;
|
||||||
@@ -661,7 +664,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
_suppressItemContextMenu = false;
|
_suppressItemContextMenu = false;
|
||||||
_dragStart = e.GetPosition(null);
|
_dragStart = e.GetPosition(this);
|
||||||
_dragPending = true;
|
_dragPending = true;
|
||||||
_dragButton = e.ChangedButton;
|
_dragButton = e.ChangedButton;
|
||||||
_dragList = sender as ListView;
|
_dragList = sender as ListView;
|
||||||
@@ -739,11 +742,12 @@ public partial class MainWindow : Window
|
|||||||
&& dest is not null
|
&& dest is not null
|
||||||
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
|
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
|
||||||
&& !DragDropPolicy.IsInvalidTarget(files, dest)
|
&& !DragDropPolicy.IsInvalidTarget(files, dest)
|
||||||
|
&& !IsRedundantLeftDrop(files, dest)
|
||||||
? hover
|
? hover
|
||||||
: null;
|
: null;
|
||||||
SetListDropTarget(folderTarget);
|
SetListDropTarget(folderTarget);
|
||||||
SetTreeDropTarget(null);
|
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.Effects = DragDropEffects.None;
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -786,6 +790,11 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsRedundantLeftDrop(files, dest))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,9 +872,8 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var pos = e.GetPosition(null);
|
var pos = e.GetPosition(this);
|
||||||
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
if (!DragMovedEnough(pos, DragDropPolicy.DragStartDistance))
|
||||||
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -887,11 +895,19 @@ public partial class MainWindow : Window
|
|||||||
_sourceRightDrag = _dragButton == MouseButton.Right;
|
_sourceRightDrag = _dragButton == MouseButton.Right;
|
||||||
_suppressItemContextMenu = _sourceRightDrag;
|
_suppressItemContextMenu = _sourceRightDrag;
|
||||||
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
|
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
|
||||||
|
_draggingFromHere = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_draggingFromHere = false;
|
||||||
_sourceRightDrag = false;
|
_sourceRightDrag = false;
|
||||||
_incomingRightDrag = false;
|
_incomingRightDrag = false;
|
||||||
ClearDropTargets();
|
ClearDropTargets();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -1017,16 +1033,58 @@ public partial class MainWindow : Window
|
|||||||
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
|
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
|
||||||
{
|
{
|
||||||
var item = HitTestFolderItem(list, e.GetPosition(list));
|
var item = HitTestFolderItem(list, e.GetPosition(list));
|
||||||
|
if (!_draggingFromHere)
|
||||||
|
{
|
||||||
if (item is { IsDirectory: true })
|
if (item is { IsDirectory: true })
|
||||||
{
|
{
|
||||||
return item.FullPath;
|
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 tab = Vm.ActiveTab;
|
||||||
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
|
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
|
||||||
return LocationRoots.IsVirtual(path) ? null : path;
|
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)
|
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
|
||||||
{
|
{
|
||||||
if (origin is not Visual visual)
|
if (origin is not Visual visual)
|
||||||
@@ -1178,6 +1236,11 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsRedundantLeftDrop(files, dest))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
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)
|
private void OnAbout(object sender, RoutedEventArgs e)
|
||||||
=> new AboutWindow { Owner = this }.ShowDialog();
|
=> 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)
|
private async void OnGitChanges(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_gitChanges is { IsVisible: true })
|
if (_gitChanges is { IsVisible: true })
|
||||||
|
|||||||
23
src/Explorer.App/ModelessWindowClose.cs
Normal file
23
src/Explorer.App/ModelessWindowClose.cs
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,10 +23,12 @@ public interface IIdleIndexWork
|
|||||||
public interface IIdleHashWork
|
public interface IIdleHashWork
|
||||||
{
|
{
|
||||||
bool IsPaused { get; }
|
bool IsPaused { get; }
|
||||||
|
string? CurrentPath { get; }
|
||||||
void Pause();
|
void Pause();
|
||||||
void Resume();
|
void Resume();
|
||||||
void BeginUserRequested();
|
void BeginUserRequested();
|
||||||
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IHistoryMaintenance
|
public interface IHistoryMaintenance
|
||||||
|
|||||||
87
src/Explorer.Application/HostActivityLog.cs
Normal file
87
src/Explorer.Application/HostActivityLog.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/Explorer.Application/ISqliteDatabaseSession.cs
Normal file
39
src/Explorer.Application/ISqliteDatabaseSession.cs
Normal 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; } = "";
|
||||||
|
}
|
||||||
116
src/Explorer.Application/IndexedPathPresence.cs
Normal file
116
src/Explorer.Application/IndexedPathPresence.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Explorer.Contracts/HostActivity.cs
Normal file
52
src/Explorer.Contracts/HostActivity.cs
Normal 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());
|
||||||
|
}
|
||||||
@@ -160,6 +160,7 @@ public interface IHashStore
|
|||||||
{
|
{
|
||||||
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
||||||
Task<bool> HasPendingAsync(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<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
|
||||||
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||||
Task CompleteFullAsync(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 MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
|
||||||
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, 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<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
|
public interface IFileRelationStore
|
||||||
|
|||||||
@@ -54,4 +54,46 @@ public static class DragDropPolicy
|
|||||||
|
|
||||||
return false;
|
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('\\');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ public static class ExplorerHostClientServices
|
|||||||
services.AddSingleton(connection);
|
services.AddSingleton(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (workbench is IHostActivity activity)
|
||||||
|
{
|
||||||
|
services.AddSingleton(activity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.TryAddSingleton<IHostActivity>(_ => NullHostActivity.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
services.AddExplorerClientRuntime();
|
services.AddExplorerClientRuntime();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -54,6 +63,7 @@ public static class ExplorerHostClientServices
|
|||||||
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
||||||
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: true);
|
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: true);
|
||||||
});
|
});
|
||||||
|
services.AddSingleton<ISqliteDatabaseSessionFactory, SqliteDatabaseSessionFactory>();
|
||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||||
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Explorer.Plugin.Abstractions;
|
|||||||
|
|
||||||
namespace Explorer.Hosting.Ipc;
|
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 NamedPipeClientStream _pipe;
|
||||||
private StreamWriter _writer;
|
private StreamWriter _writer;
|
||||||
@@ -51,6 +51,18 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
|||||||
|
|
||||||
public void RunNow() => Call("Maintenance.RunNow");
|
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(
|
public static async Task<WorkbenchPipeClient> ConnectAsync(
|
||||||
WorkbenchIpcOptions options,
|
WorkbenchIpcOptions options,
|
||||||
TimeSpan timeout,
|
TimeSpan timeout,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
private readonly IHistoryMaintenance _history;
|
private readonly IHistoryMaintenance _history;
|
||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly IVolumeService _volumes;
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly IHostActivitySink _activity;
|
||||||
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
|
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
|
||||||
private readonly HashSet<long> _collisionEnqueued = [];
|
private readonly HashSet<long> _collisionEnqueued = [];
|
||||||
private readonly HashSet<long> _idleScanQueued = [];
|
private readonly HashSet<long> _idleScanQueued = [];
|
||||||
@@ -42,7 +43,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
IHistoryMaintenance history,
|
IHistoryMaintenance history,
|
||||||
IIndexStore store,
|
IIndexStore store,
|
||||||
IVolumeService volumes,
|
IVolumeService volumes,
|
||||||
ILogger<BackgroundMaintenanceCoordinator> logger)
|
ILogger<BackgroundMaintenanceCoordinator> logger,
|
||||||
|
IHostActivitySink? activity = null)
|
||||||
{
|
{
|
||||||
_idle = idle;
|
_idle = idle;
|
||||||
_power = power;
|
_power = power;
|
||||||
@@ -54,6 +56,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
_store = store;
|
_store = store;
|
||||||
_volumes = volumes;
|
_volumes = volumes;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_activity = activity ?? NullHostActivitySink.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<MaintenanceSnapshot>? Changed;
|
public event EventHandler<MaintenanceSnapshot>? Changed;
|
||||||
@@ -149,10 +152,12 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
_hash.Pause();
|
_hash.Pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
_workMessage = _indexing.HasIdleWork
|
// Keep the idle-maintenance caption only while idle work is still queued/running.
|
||||||
? (_workMessage ?? "Scanning")
|
// Watcher/user jobs must not keep showing a stale "checking External" line.
|
||||||
: "Idle maintenance";
|
var busyMessage = _indexing.HasIdleWork
|
||||||
Publish(decision, _workMessage);
|
? (_workMessage ?? "Idle maintenance")
|
||||||
|
: "";
|
||||||
|
Publish(decision, busyMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +172,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
|
|
||||||
_idleVerified.Add(verify.Id);
|
_idleVerified.Add(verify.Id);
|
||||||
_indexing.EnqueueIdleVerify(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;
|
_workMessage = "Idle maintenance · checking " + verify.DisplayName;
|
||||||
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
|
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
|
||||||
Publish(decision, _workMessage);
|
Publish(decision, _workMessage);
|
||||||
@@ -184,7 +190,6 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
|
|
||||||
_idleScanQueued.Add(scan.Id);
|
_idleScanQueued.Add(scan.Id);
|
||||||
_indexing.EnqueueIdleFullScan(scan.Id);
|
_indexing.EnqueueIdleFullScan(scan.Id);
|
||||||
_runNow = false;
|
|
||||||
_workMessage = "Scanning " + scan.DisplayName;
|
_workMessage = "Scanning " + scan.DisplayName;
|
||||||
_logger.LogDebug("maintenance started scan {Source}", scan.DisplayName);
|
_logger.LogDebug("maintenance started scan {Source}", scan.DisplayName);
|
||||||
Publish(decision, _workMessage);
|
Publish(decision, _workMessage);
|
||||||
@@ -194,16 +199,14 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
_hash.Resume();
|
_hash.Resume();
|
||||||
if (hashPending)
|
if (hashPending)
|
||||||
{
|
{
|
||||||
_runNow = false;
|
_workMessage = "Idle maintenance · hashing";
|
||||||
_workMessage = "Idle maintenance";
|
|
||||||
Publish(decision, _workMessage);
|
Publish(decision, _workMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
|
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
_runNow = false;
|
_workMessage = "Idle maintenance · history";
|
||||||
_workMessage = "Idle maintenance";
|
|
||||||
_logger.LogDebug("maintenance completed (history)");
|
_logger.LogDebug("maintenance completed (history)");
|
||||||
Publish(decision, _workMessage);
|
Publish(decision, _workMessage);
|
||||||
return;
|
return;
|
||||||
@@ -214,7 +217,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
{
|
{
|
||||||
_collisionEnqueued.Add(hashSource.Id);
|
_collisionEnqueued.Add(hashSource.Id);
|
||||||
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
|
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
|
||||||
_workMessage = "Idle maintenance";
|
_workMessage = "Idle maintenance · hash queue";
|
||||||
Publish(decision, _workMessage);
|
Publish(decision, _workMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,6 +264,11 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
|||||||
_snapshot = snapshot;
|
_snapshot = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
_activity.Record("Maintenance", message);
|
||||||
|
}
|
||||||
|
|
||||||
Changed?.Invoke(this, snapshot);
|
Changed?.Invoke(this, snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ public static class ExplorerHostServices
|
|||||||
|
|
||||||
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
|
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IHostActivitySink, HostActivityLog>();
|
||||||
services.AddSingleton<IndexingCoordinator>();
|
services.AddSingleton<IndexingCoordinator>();
|
||||||
services.AddSingleton<DirectoryWatcherHub>();
|
services.AddSingleton<DirectoryWatcherHub>();
|
||||||
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
|
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
|
||||||
|
|||||||
@@ -317,6 +317,11 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
|||||||
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
|
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
|
||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
|
case "Host.ActivitySnapshot":
|
||||||
|
{
|
||||||
|
reply.Payload = JsonSerializer.Serialize(await BuildActivitySnapshotAsync().ConfigureAwait(false), WorkbenchIpc.Json);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
case "Host.Shutdown":
|
case "Host.Shutdown":
|
||||||
await RequestShutdownAsync().ConfigureAwait(false);
|
await RequestShutdownAsync().ConfigureAwait(false);
|
||||||
return reply;
|
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)
|
private static T Read<T>(string? payload)
|
||||||
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
|
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
|
||||||
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
|
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class FolderReconciler
|
|||||||
_preferences = preferences;
|
_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);
|
=> ReconcileAsync(source, pathRel, cancellationToken, verifyChildren: false);
|
||||||
|
|
||||||
public Task<bool> ReconcileAsync(
|
public Task<bool> ReconcileAsync(
|
||||||
@@ -74,11 +74,25 @@ public sealed class FolderReconciler
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
|
if (LocationClassifier.IsRecycleBinName(parent.Name))
|
||||||
{
|
{
|
||||||
return false;
|
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)
|
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
private readonly UsnChangeApplier _usn;
|
private readonly UsnChangeApplier _usn;
|
||||||
private readonly IUsnJournal _journal;
|
private readonly IUsnJournal _journal;
|
||||||
private readonly IVolumeService _volumes;
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly IHostActivitySink _activity;
|
||||||
private readonly ILogger<IndexingCoordinator> _logger;
|
private readonly ILogger<IndexingCoordinator> _logger;
|
||||||
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
|
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
|
||||||
private readonly Dictionary<long, RunningWork> _running = new();
|
private readonly Dictionary<long, RunningWork> _running = new();
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private volatile bool _idleAllowed;
|
private volatile bool _idleAllowed;
|
||||||
private int _idleQueued;
|
private int _idleQueued;
|
||||||
|
private ScanProgress? _lastProgress;
|
||||||
|
private DateTimeOffset _lastActivityLogUtc;
|
||||||
|
|
||||||
public event EventHandler<ScanProgress>? ProgressChanged;
|
public event EventHandler<ScanProgress>? ProgressChanged;
|
||||||
|
|
||||||
@@ -32,7 +35,8 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
UsnChangeApplier usn,
|
UsnChangeApplier usn,
|
||||||
IUsnJournal journal,
|
IUsnJournal journal,
|
||||||
IVolumeService volumes,
|
IVolumeService volumes,
|
||||||
ILogger<IndexingCoordinator> logger)
|
ILogger<IndexingCoordinator> logger,
|
||||||
|
IHostActivitySink? activity = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_scanner = scanner;
|
_scanner = scanner;
|
||||||
@@ -41,6 +45,7 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
_journal = journal;
|
_journal = journal;
|
||||||
_volumes = volumes;
|
_volumes = volumes;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_activity = activity ?? NullHostActivitySink.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsBusy
|
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)
|
public void SetIdleAllowed(bool allowed)
|
||||||
{
|
{
|
||||||
_idleAllowed = allowed;
|
_idleAllowed = allowed;
|
||||||
@@ -176,9 +211,22 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
lock (_gate)
|
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
|
try
|
||||||
{
|
{
|
||||||
switch (item.Kind)
|
switch (item.Kind)
|
||||||
@@ -189,7 +237,16 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
source,
|
source,
|
||||||
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
|
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
|
||||||
item.PathRel,
|
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);
|
linked.Token).ConfigureAwait(false);
|
||||||
EnqueueUsn(source.Id);
|
EnqueueUsn(source.Id);
|
||||||
break;
|
break;
|
||||||
@@ -204,13 +261,19 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
var full = source.LastRootPath is null
|
var full = source.LastRootPath is null
|
||||||
? item.PathRel
|
? item.PathRel
|
||||||
: PathRules.Combine(source.LastRootPath, item.PathRel);
|
: PathRules.Combine(source.LastRootPath, item.PathRel);
|
||||||
ProgressChanged?.Invoke(this, new ScanProgress
|
var done = new ScanProgress
|
||||||
{
|
{
|
||||||
SourceId = source.Id,
|
SourceId = source.Id,
|
||||||
CurrentPath = full,
|
CurrentPath = full,
|
||||||
Status = ScanJobStatus.Done,
|
Status = ScanJobStatus.Done,
|
||||||
IndexRefresh = true
|
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)
|
private async Task PeriodicAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||||
@@ -256,5 +342,12 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
|||||||
IndexWorkOrigin Origin,
|
IndexWorkOrigin Origin,
|
||||||
bool VerifyChildren = false);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
327
src/Explorer.Presentation/ViewModels/DatabaseViewerViewModel.cs
Normal file
327
src/Explorer.Presentation/ViewModels/DatabaseViewerViewModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading.Channels;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Explorer.Analysis;
|
using Explorer.Analysis;
|
||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Contracts;
|
using Explorer.Contracts;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Presentation.ViewModels;
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
public sealed partial class DuplicateViewModel : ObservableObject
|
public sealed partial class DuplicateViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IIndexMutations _mutations;
|
private readonly IIndexMutations _mutations;
|
||||||
|
private readonly IIndexingHost _indexing;
|
||||||
private readonly SourceManager _sources;
|
private readonly SourceManager _sources;
|
||||||
private readonly AnalysisService _analysis;
|
private readonly AnalysisService _analysis;
|
||||||
|
private CancellationTokenSource? _load;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isOpen;
|
[ObservableProperty] private bool _isOpen;
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -20,9 +24,14 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _showIntentional;
|
[ObservableProperty] private bool _showIntentional;
|
||||||
[ObservableProperty] private bool _showHardlinks;
|
[ObservableProperty] private bool _showHardlinks;
|
||||||
|
|
||||||
public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
|
public DuplicateViewModel(
|
||||||
|
IIndexMutations mutations,
|
||||||
|
IIndexingHost indexing,
|
||||||
|
SourceManager sources,
|
||||||
|
AnalysisService analysis)
|
||||||
{
|
{
|
||||||
_mutations = mutations;
|
_mutations = mutations;
|
||||||
|
_indexing = indexing;
|
||||||
_sources = sources;
|
_sources = sources;
|
||||||
_analysis = analysis;
|
_analysis = analysis;
|
||||||
Groups = [];
|
Groups = [];
|
||||||
@@ -35,6 +44,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public Task CloseAsync()
|
public Task CloseAsync()
|
||||||
{
|
{
|
||||||
|
_load?.Cancel();
|
||||||
IsOpen = false;
|
IsOpen = false;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -42,32 +52,98 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task OpenAsync()
|
public async Task OpenAsync()
|
||||||
{
|
{
|
||||||
|
_load?.Cancel();
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_load = cts;
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
IsOpen = true;
|
IsOpen = true;
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
Status = "Finding size collisions…";
|
Status = "Finding size collisions…";
|
||||||
Groups.Clear();
|
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 hiddenIntentional = 0;
|
||||||
var hiddenHardlinks = 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 (group.Classification == DuplicateClass.Hardlink)
|
||||||
{
|
{
|
||||||
if (!ShowHardlinks)
|
if (!ShowHardlinks)
|
||||||
{
|
{
|
||||||
hiddenHardlinks++;
|
hiddenHardlinks++;
|
||||||
continue;
|
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (DuplicateClassifier.IsIntentional(group.Classification))
|
else if (DuplicateClassifier.IsIntentional(group.Classification))
|
||||||
@@ -75,23 +151,129 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
|||||||
if (!ShowIntentional)
|
if (!ShowIntentional)
|
||||||
{
|
{
|
||||||
hiddenIntentional++;
|
hiddenIntentional++;
|
||||||
continue;
|
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Groups.Add(group);
|
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);
|
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.";
|
var changes = new List<(DuplicateGroupViewModel, DuplicateGroupViewModel?)>();
|
||||||
}
|
var reconcile = new HashSet<(long, string)>();
|
||||||
finally
|
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]
|
[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)
|
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
|
||||||
{
|
{
|
||||||
if (visible > 0)
|
if (visible > 0)
|
||||||
@@ -164,12 +359,18 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
|||||||
|
|
||||||
return "No confirmed duplicates yet. Hashing continues in the background.";
|
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 sealed class DuplicateGroupViewModel
|
||||||
{
|
{
|
||||||
public required IReadOnlyList<IndexEntry> Entries { get; init; }
|
public required IReadOnlyList<IndexEntry> Entries { get; init; }
|
||||||
public DuplicateClass Classification { get; init; }
|
public DuplicateClass Classification { get; init; }
|
||||||
|
public required string Header { get; init; }
|
||||||
public required string ClassLabel { get; init; }
|
public required string ClassLabel { get; init; }
|
||||||
public required string SizeLabel { get; init; }
|
public required string SizeLabel { get; init; }
|
||||||
public required string Summary { get; init; }
|
public required string Summary { get; init; }
|
||||||
@@ -178,6 +379,30 @@ public sealed class DuplicateGroupViewModel
|
|||||||
public bool CanMarkAccidental { get; init; }
|
public bool CanMarkAccidental { get; init; }
|
||||||
public IReadOnlyList<DuplicateFileViewModel> Files { 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(
|
public static DuplicateGroupViewModel From(
|
||||||
ClassifiedDuplicateGroup classified,
|
ClassifiedDuplicateGroup classified,
|
||||||
IReadOnlyDictionary<long, Source> sources)
|
IReadOnlyDictionary<long, Source> sources)
|
||||||
@@ -197,16 +422,27 @@ public sealed class DuplicateGroupViewModel
|
|||||||
};
|
};
|
||||||
}).ToList();
|
}).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
|
return new DuplicateGroupViewModel
|
||||||
{
|
{
|
||||||
Entries = classified.Group.Entries,
|
Entries = classified.Group.Entries,
|
||||||
Classification = classified.Classification,
|
Classification = classified.Classification,
|
||||||
ClassLabel = DuplicateClassifier.Label(classified.Classification),
|
Header = header,
|
||||||
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
|
ClassLabel = classLabel,
|
||||||
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
|
SizeLabel = sizeLabel,
|
||||||
WastedLabel = classified.WastedBytes > 0
|
Summary = summary,
|
||||||
? Formatters.Size(classified.WastedBytes) + " wasted"
|
WastedLabel = wasted,
|
||||||
: "No extra space",
|
|
||||||
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
|
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
|
||||||
&& classified.Classification != DuplicateClass.Intentional,
|
&& classified.Classification != DuplicateClass.Intentional,
|
||||||
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
|
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -37,11 +37,18 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly IThumbnailService? _thumbnails;
|
private readonly IThumbnailService? _thumbnails;
|
||||||
private readonly IHostConnection? _host;
|
private readonly IHostConnection? _host;
|
||||||
private readonly IBackgroundMaintenance? _maintenance;
|
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 IShellContextMenu? _shellMenu;
|
||||||
private readonly IMediaTagService? _tags;
|
private readonly IMediaTagService? _tags;
|
||||||
private bool _hostStopped;
|
private bool _hostStopped;
|
||||||
private List<string> _clipboard = [];
|
private List<string> _clipboard = [];
|
||||||
private bool _clipboardIsCut;
|
private bool _clipboardIsCut;
|
||||||
|
private int _overlayDirty;
|
||||||
|
private int _overlayRunning;
|
||||||
|
private DateTimeOffset _lastOverlayRefreshUtc = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
|
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
|
||||||
[ObservableProperty] private string _pathText = "";
|
[ObservableProperty] private string _pathText = "";
|
||||||
@@ -104,12 +111,19 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IThumbnailService? thumbnails = null,
|
IThumbnailService? thumbnails = null,
|
||||||
IHostConnection? hostConnection = null,
|
IHostConnection? hostConnection = null,
|
||||||
IBackgroundMaintenance? maintenance = null,
|
IBackgroundMaintenance? maintenance = null,
|
||||||
|
IHostActivity? hostActivity = null,
|
||||||
|
ISqliteDatabaseSessionFactory? databaseSessions = null,
|
||||||
|
IAppEnvironment? environment = null,
|
||||||
IShellContextMenu? shellMenu = null,
|
IShellContextMenu? shellMenu = null,
|
||||||
IMediaTagService? tags = null)
|
IMediaTagService? tags = null)
|
||||||
{
|
{
|
||||||
_browse = browse;
|
_browse = browse;
|
||||||
_ops = ops;
|
_ops = ops;
|
||||||
_indexing = workbench.Indexing;
|
_indexing = workbench.Indexing;
|
||||||
|
_transferHost = workbench.Transfers;
|
||||||
|
_hostActivity = hostActivity;
|
||||||
|
_databaseSessions = databaseSessions;
|
||||||
|
_environment = environment;
|
||||||
_sources = sources;
|
_sources = sources;
|
||||||
_pathHistory = pathHistory;
|
_pathHistory = pathHistory;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
@@ -174,7 +188,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences, knownFolders);
|
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences, knownFolders);
|
||||||
Search = new SearchViewModel(search, sources, volumes);
|
Search = new SearchViewModel(search, sources, volumes);
|
||||||
Analysis = new AnalysisViewModel(analysis);
|
Analysis = new AnalysisViewModel(analysis);
|
||||||
Duplicates = new DuplicateViewModel(mutations, sources, analysis);
|
Duplicates = new DuplicateViewModel(mutations, _indexing, sources, analysis);
|
||||||
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
|
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
|
||||||
_sources.PresenceChanged += (_, source) =>
|
_sources.PresenceChanged += (_, source) =>
|
||||||
{
|
{
|
||||||
@@ -207,16 +221,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (p.IndexRefresh)
|
if (p.IndexRefresh)
|
||||||
{
|
{
|
||||||
void Refresh() => _ = RefreshIndexOverlayAsync();
|
ScheduleIndexOverlayRefresh();
|
||||||
if (_ui is { } refreshCtx)
|
|
||||||
{
|
|
||||||
refreshCtx.Post(_ => Refresh(), null);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
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<ExplorerTabViewModel> Tabs { get; }
|
||||||
public ObservableCollection<string> PathHistory { get; }
|
public ObservableCollection<string> PathHistory { get; }
|
||||||
public NavigationTreeViewModel Tree { get; }
|
public NavigationTreeViewModel Tree { get; }
|
||||||
@@ -688,6 +740,16 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
public GitChangesViewModel CreateGitChangesViewModel()
|
public GitChangesViewModel CreateGitChangesViewModel()
|
||||||
=> new(_gitCommands, _workspace, _hydration);
|
=> 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()
|
public async Task<GitCommitViewModel?> CreateGitCommitViewModelAsync()
|
||||||
{
|
{
|
||||||
var path = GitWorkspacePath();
|
var path = GitWorkspacePath();
|
||||||
|
|||||||
@@ -228,9 +228,16 @@ internal sealed class HashStore : IHashStore
|
|||||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
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);
|
"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)
|
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||||
@@ -306,36 +313,100 @@ internal sealed class HashStore : IHashStore
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
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);
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var sql = """
|
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
|
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))
|
if (!string.IsNullOrEmpty(pathPrefix))
|
||||||
|
{
|
||||||
sql += " AND (path_rel=@pathPrefix OR path_rel LIKE @like ESCAPE '\\')";
|
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 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())
|
.Select(r => r.ToModel())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var byHash = rows
|
||||||
return rows
|
.Where(e => e.ContentHash is { Length: > 0 })
|
||||||
.GroupBy(e => Convert.ToHexString(e.ContentHash!))
|
.GroupBy(e => Convert.ToHexString(e.ContentHash!), StringComparer.Ordinal)
|
||||||
.Where(g => g.Count() > 1)
|
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
|
||||||
.Take(take)
|
var result = new List<DuplicateGroup>();
|
||||||
.Select(g =>
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var hash in hashes)
|
||||||
{
|
{
|
||||||
var list = g.ToList();
|
var hex = Convert.ToHexString(hash);
|
||||||
return new DuplicateGroup
|
if (!seen.Add(hex) || !byHash.TryGetValue(hex, out var list) || list.Count < 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new DuplicateGroup
|
||||||
{
|
{
|
||||||
SizeBytes = list[0].SizeBytes,
|
SizeBytes = list[0].SizeBytes,
|
||||||
Hash = list[0].ContentHash,
|
Hash = list[0].ContentHash,
|
||||||
Entries = list,
|
Entries = list,
|
||||||
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
|
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
|
||||||
};
|
});
|
||||||
})
|
}
|
||||||
.ToList();
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HashBlobRow
|
||||||
|
{
|
||||||
|
public byte[] content_hash { get; set; } = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ internal static class SchemaScript
|
|||||||
state TEXT NOT NULL
|
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 (
|
CREATE TABLE source_stats_history (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|||||||
336
src/Explorer.Storage.Sqlite/SqliteDatabaseSession.cs
Normal file
336
src/Explorer.Storage.Sqlite/SqliteDatabaseSession.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -592,6 +592,20 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
|
|||||||
EnsureColumn(conn, "operation_profiles", "convert_kind", "TEXT NOT NULL DEFAULT 'VideoToMp4'");
|
EnsureColumn(conn, "operation_profiles", "convert_kind", "TEXT NOT NULL DEFAULT 'VideoToMp4'");
|
||||||
SetUserVersion(conn, 9);
|
SetUserVersion(conn, 9);
|
||||||
_logger.LogInformation("Migrated SQLite schema to v9 (conversion profiles)");
|
_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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync(
|
||||||
long fileIdA, long fileIdB)
|
long fileIdA, long fileIdB)
|
||||||
{
|
{
|
||||||
var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db");
|
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);
|
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||||
await store.OpenAsync();
|
await store.OpenAsync();
|
||||||
var source = new Source
|
var source = new Source
|
||||||
@@ -189,7 +223,7 @@ public class AnalysisTests
|
|||||||
StableKey = "d",
|
StableKey = "d",
|
||||||
DisplayName = "D",
|
DisplayName = "D",
|
||||||
Kind = SourceKind.NtfsLocal,
|
Kind = SourceKind.NtfsLocal,
|
||||||
LastRootPath = @"C:\d",
|
LastRootPath = rootPath,
|
||||||
Status = SourceStatus.Online
|
Status = SourceStatus.Online
|
||||||
};
|
};
|
||||||
source.Id = await store.Sources.UpsertAsync(source);
|
source.Id = await store.Sources.UpsertAsync(source);
|
||||||
|
|||||||
31
tests/Explorer.Application.Tests/HostActivityLogTests.cs
Normal file
31
tests/Explorer.Application.Tests/HostActivityLogTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Explorer.Application.Tests/IndexedPathPresenceTests.cs
Normal file
85
tests/Explorer.Application.Tests/IndexedPathPresenceTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,25 @@ public class DragDropPolicyTests
|
|||||||
Assert.False(DragDropPolicy.IsInvalidTarget([@"C:\Docs"], @"D:\Other"));
|
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]
|
[Fact]
|
||||||
public void Same_volume_uses_drive_or_unc_share()
|
public void Same_volume_uses_drive_or_unc_share()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,6 +120,21 @@ public class BackgroundMaintenanceCoordinatorTests
|
|||||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
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]
|
[Fact]
|
||||||
public async Task Idle_verifies_a_fresh_online_source()
|
public async Task Idle_verifies_a_fresh_online_source()
|
||||||
{
|
{
|
||||||
@@ -220,10 +235,12 @@ public class BackgroundMaintenanceCoordinatorTests
|
|||||||
{
|
{
|
||||||
public bool Paused { get; set; } = true;
|
public bool Paused { get; set; } = true;
|
||||||
public bool IsPaused => Paused;
|
public bool IsPaused => Paused;
|
||||||
|
public string? CurrentPath => null;
|
||||||
public void Pause() => Paused = true;
|
public void Pause() => Paused = true;
|
||||||
public void Resume() => Paused = false;
|
public void Resume() => Paused = false;
|
||||||
public void BeginUserRequested() { }
|
public void BeginUserRequested() { }
|
||||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
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
|
private sealed class FakeHistory : IHistoryMaintenance
|
||||||
@@ -303,6 +320,13 @@ public class BackgroundMaintenanceCoordinatorTests
|
|||||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
||||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
=> 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
|
private sealed class TempEnv : IAppEnvironment
|
||||||
|
|||||||
@@ -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]
|
[Fact]
|
||||||
public async Task Folder_reconcile_tombs_removed_file()
|
public async Task Folder_reconcile_tombs_removed_file()
|
||||||
{
|
{
|
||||||
|
|||||||
51
tests/Explorer.Storage.Tests/SqliteDatabaseSessionTests.cs
Normal file
51
tests/Explorer.Storage.Tests/SqliteDatabaseSessionTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user