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
|
||||
|
||||
**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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
@@ -5,6 +6,8 @@ namespace Explorer.Analysis;
|
||||
|
||||
public sealed class AnalysisService
|
||||
{
|
||||
private const int DuplicateHashChunk = 24;
|
||||
|
||||
private readonly IIndexStore _store;
|
||||
private readonly AnalysisResultCache _cache = new();
|
||||
private readonly SemaphoreSlim _ready = new(1, 1);
|
||||
@@ -93,15 +96,152 @@ public sealed class AnalysisService
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunOffUiAsync(async ct =>
|
||||
{
|
||||
var raw = await _store.Hashes.GetDuplicateGroupsAsync(null, null, Math.Max(take * 8, 400), ct)
|
||||
.ConfigureAwait(false);
|
||||
var ids = raw.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
|
||||
var relations = await _store.Relations.GetAmongAsync(ids, ct).ConfigureAwait(false);
|
||||
return (IReadOnlyList<ClassifiedDuplicateGroup>)raw
|
||||
.Select(g => DuplicateClassifier.ClassifyGroup(g, DuplicateClassifier.RelationsFor(g.Entries, relations)))
|
||||
.ToList();
|
||||
var list = new List<ClassifiedDuplicateGroup>();
|
||||
await StreamCoreAsync(take, list.Add, verifyPresence: true, ct).ConfigureAwait(false);
|
||||
return (IReadOnlyList<ClassifiedDuplicateGroup>)list;
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
|
||||
int take,
|
||||
IProgress<ClassifiedDuplicateGroup> progress,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StreamClassifiedDuplicatesAsync(take, progress, verifyPresence: false, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
|
||||
int take,
|
||||
IProgress<ClassifiedDuplicateGroup> progress,
|
||||
bool verifyPresence,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunOffUiAsync(
|
||||
ct => StreamCoreAsync(take, progress.Report, verifyPresence, ct),
|
||||
cancellationToken);
|
||||
|
||||
private async Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamCoreAsync(
|
||||
int take,
|
||||
Action<ClassifiedDuplicateGroup> emit,
|
||||
bool verifyPresence,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var limit = Math.Max(take * 8, 400);
|
||||
var hashes = await _store.Hashes.GetDuplicateHashesAsync(null, null, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var sources = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
.ToDictionary(s => s.Id);
|
||||
var missing = new List<MissingCopy>();
|
||||
for (var offset = 0; offset < hashes.Count; offset += DuplicateHashChunk)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var chunk = hashes.Skip(offset).Take(DuplicateHashChunk).ToList();
|
||||
var groups = await _store.Hashes.GetDuplicateGroupsByHashesAsync(chunk, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var kept = new List<DuplicateGroup>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var present = verifyPresence
|
||||
? KeepPresentCopies(group, sources, missing)
|
||||
: group.Entries.ToList();
|
||||
if (present.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
kept.Add(new DuplicateGroup
|
||||
{
|
||||
SizeBytes = group.SizeBytes,
|
||||
Hash = group.Hash,
|
||||
Entries = present,
|
||||
SameFileId = DuplicateClassifier.IsHardlinkOnly(present)
|
||||
});
|
||||
}
|
||||
|
||||
var ids = kept.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
|
||||
var relations = await _store.Relations.GetAmongAsync(ids, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var group in kept)
|
||||
{
|
||||
emit(DuplicateClassifier.ClassifyGroup(
|
||||
group,
|
||||
DuplicateClassifier.RelationsFor(group.Entries, relations)));
|
||||
}
|
||||
}
|
||||
|
||||
if (_store.CanWrite && missing.Count > 0)
|
||||
{
|
||||
await TombstoneMissingAsync(missing, sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return missing
|
||||
.Select(m => (m.SourceId, IndexedPathPresence.ReconcilePath(m.PathRel, m.Prefix)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<IndexEntry> KeepPresentCopies(
|
||||
DuplicateGroup group,
|
||||
IReadOnlyDictionary<long, Source> sources,
|
||||
List<MissingCopy> missing)
|
||||
{
|
||||
var kept = new List<IndexEntry>(group.Entries.Count);
|
||||
foreach (var entry in group.Entries)
|
||||
{
|
||||
if (!sources.TryGetValue(entry.SourceId, out var source)
|
||||
|| string.IsNullOrWhiteSpace(source.LastRootPath)
|
||||
|| !IndexedPathPresence.RootReachable(source.LastRootPath))
|
||||
{
|
||||
kept.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IndexedPathPresence.FileExists(source.LastRootPath, entry.PathRel))
|
||||
{
|
||||
kept.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
var prefix = IndexedPathPresence.HighestMissingPrefix(source.LastRootPath, entry.PathRel)
|
||||
?? entry.PathRel;
|
||||
missing.Add(new MissingCopy(entry.SourceId, entry.Id, entry.PathRel, prefix, source.LastRootPath));
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
private async Task TombstoneMissingAsync(
|
||||
IReadOnlyList<MissingCopy> missing,
|
||||
IReadOnlyDictionary<long, Source> sources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var prefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in missing)
|
||||
{
|
||||
if (!item.Prefix.Equals(item.PathRel, StringComparison.OrdinalIgnoreCase)
|
||||
&& sources.TryGetValue(item.SourceId, out var source)
|
||||
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
|
||||
&& !IndexedPathPresence.DirectoryExists(PathRules.Combine(source.LastRootPath, item.Prefix)))
|
||||
{
|
||||
var key = item.SourceId + "|" + item.Prefix;
|
||||
if (!prefixes.Add(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var folder = await _store.Entries.GetByPathAsync(item.SourceId, item.Prefix, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (folder is not null)
|
||||
{
|
||||
await _store.Entries.TombstoneAsync(folder.Id, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _store.Entries.TombstoneByPathPrefixAsync(item.SourceId, item.Prefix, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _store.Entries.TombstoneAsync(item.EntryId, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task MarkDuplicateGroupAsync(
|
||||
IReadOnlyList<IndexEntry> entries,
|
||||
FileRelationKind kind,
|
||||
@@ -156,4 +296,11 @@ public sealed class AnalysisService
|
||||
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private readonly record struct MissingCopy(
|
||||
long SourceId,
|
||||
long EntryId,
|
||||
string PathRel,
|
||||
string Prefix,
|
||||
string Root);
|
||||
}
|
||||
|
||||
@@ -11,18 +11,30 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly IHydrationGuard _hydration;
|
||||
private readonly IHostActivitySink _activity;
|
||||
private readonly ILogger<DuplicateHashWorker> _logger;
|
||||
private readonly object _pendingGate = new();
|
||||
private long _pendingCount;
|
||||
private DateTimeOffset _pendingCountUtc = DateTimeOffset.MinValue;
|
||||
private int _pendingRefreshBusy;
|
||||
private volatile bool _paused = true;
|
||||
private volatile bool _userRequested;
|
||||
private volatile string? _currentPath;
|
||||
|
||||
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
|
||||
public DuplicateHashWorker(
|
||||
IIndexStore store,
|
||||
IHydrationGuard hydration,
|
||||
ILogger<DuplicateHashWorker> logger,
|
||||
IHostActivitySink? activity = null)
|
||||
{
|
||||
_store = store;
|
||||
_hydration = hydration;
|
||||
_logger = logger;
|
||||
_activity = activity ?? NullHostActivitySink.Instance;
|
||||
}
|
||||
|
||||
public bool IsPaused => _paused && !_userRequested;
|
||||
public string? CurrentPath => _currentPath;
|
||||
public void Pause() => _paused = true;
|
||||
public void Resume() => _paused = false;
|
||||
public void BeginUserRequested() => _userRequested = true;
|
||||
@@ -30,6 +42,61 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||
=> await _store.Hashes.HasPendingAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
long cached;
|
||||
var never = false;
|
||||
lock (_pendingGate)
|
||||
{
|
||||
cached = _pendingCount;
|
||||
never = _pendingCountUtc == DateTimeOffset.MinValue;
|
||||
if (!never && DateTimeOffset.UtcNow - _pendingCountUtc < TimeSpan.FromSeconds(15))
|
||||
{
|
||||
return Task.FromResult(cached);
|
||||
}
|
||||
}
|
||||
|
||||
if (never)
|
||||
{
|
||||
return RefreshPendingCountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _pendingRefreshBusy, 1, 0) == 0)
|
||||
{
|
||||
_ = RefreshPendingCountInBackgroundAsync();
|
||||
}
|
||||
|
||||
return Task.FromResult(cached);
|
||||
}
|
||||
|
||||
private async Task RefreshPendingCountInBackgroundAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await RefreshPendingCountAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Background hash-pending count failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _pendingRefreshBusy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> RefreshPendingCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await _store.Hashes.CountPendingAsync(cancellationToken).ConfigureAwait(false);
|
||||
lock (_pendingGate)
|
||||
{
|
||||
_pendingCount = count;
|
||||
_pendingCountUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
|
||||
@@ -46,6 +113,8 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
||||
}
|
||||
|
||||
var path = PathRules.Combine(item.RootPath, item.PathRel);
|
||||
_currentPath = path;
|
||||
_activity.Record("Hash", item.State + " · " + path);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
@@ -90,6 +159,10 @@ public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
||||
_logger.LogDebug(ex, "Hash failed for {Path}", path);
|
||||
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_currentPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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}">
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
|
||||
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
|
||||
x:Name="OpenFileButton" Margin="8,0,0,0"/>
|
||||
x:Name="OpenFileButton" IsEnabled="False" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Reload" MinWidth="88" Height="32" Click="OnReload" Margin="8,0,0,0"/>
|
||||
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource FgMuted}"/>
|
||||
|
||||
@@ -11,14 +11,19 @@ public partial class DocumentationWindow : Window
|
||||
{
|
||||
private FlowDocument? _document;
|
||||
private bool _suppressToc;
|
||||
private int _renderGeneration;
|
||||
|
||||
public DocumentationWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => Render();
|
||||
ModelessWindowClose.EnableEscape(this);
|
||||
Loaded += async (_, _) => await RenderAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private void OnReload(object sender, RoutedEventArgs e) => Render();
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void OnReload(object sender, RoutedEventArgs e)
|
||||
=> await RenderAsync().ConfigureAwait(true);
|
||||
|
||||
private void OnOpenFile(object sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -44,10 +49,35 @@ public partial class DocumentationWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void Render()
|
||||
private async Task RenderAsync()
|
||||
{
|
||||
var loaded = DocumentationLoader.Load();
|
||||
var parsed = MarkdownParser.Parse(loaded.Markdown);
|
||||
var generation = Interlocked.Increment(ref _renderGeneration);
|
||||
SourceLabel.Text = "Loading…";
|
||||
LoadedDocumentation loaded;
|
||||
MarkdownDocument parsed;
|
||||
try
|
||||
{
|
||||
(loaded, parsed) = await Task.Run(() =>
|
||||
{
|
||||
var document = DocumentationLoader.Load();
|
||||
return (document, MarkdownParser.Parse(document.Markdown));
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (generation == _renderGeneration)
|
||||
{
|
||||
SourceLabel.Text = "Could not load documentation: " + ex.Message;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation != _renderGeneration || !IsLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var brushes = new DocumentationBrushes(
|
||||
Brush("Fg"),
|
||||
Brush("FgMuted"),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||
<DockPanel Margin="16">
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Commit…" MinWidth="88" Height="32"
|
||||
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
|
||||
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"
|
||||
|
||||
@@ -12,10 +12,13 @@ public partial class GitChangesWindow : Window
|
||||
InitializeComponent();
|
||||
DataContext = vm;
|
||||
ViewModel = vm;
|
||||
ModelessWindowClose.EnableEscape(this);
|
||||
}
|
||||
|
||||
public GitChangesViewModel ViewModel { get; }
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
=> await ShowDiffAsync().ConfigureAwait(true);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
|
||||
<DockPanel Margin="16">
|
||||
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
|
||||
IsCancel="True" Margin="0,12,0,0"/>
|
||||
Click="OnClose" Margin="0,12,0,0"/>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
|
||||
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>
|
||||
|
||||
@@ -10,5 +10,8 @@ public partial class GitDiffWindow : Window
|
||||
InitializeComponent();
|
||||
DataContext = vm;
|
||||
Title = vm.Title;
|
||||
ModelessWindowClose.EnableEscape(this);
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
|
||||
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>
|
||||
<MenuItem Header="_Development">
|
||||
<MenuItem Header="_Host activity…" Click="OnHostActivity"/>
|
||||
<MenuItem Header="_Database…" Click="OnDatabase"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="View _changes…" Click="OnGitChanges"
|
||||
IsEnabled="{Binding ShowGitActions}"/>
|
||||
<MenuItem Header="_Commit…" Click="OnGitCommit"
|
||||
@@ -986,66 +989,91 @@
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
|
||||
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
|
||||
Margin="48" Padding="16" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<DockPanel Margin="0,0,0,8">
|
||||
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
|
||||
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
|
||||
</DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Grid.Row="1" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<CheckBox Content="Show intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
|
||||
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
|
||||
</StackPanel>
|
||||
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
|
||||
<ProgressBar Grid.Row="3" Margin="0,0,0,8" IsIndeterminate="True"
|
||||
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Content="Mark as intentional" Margin="0,0,6,0"
|
||||
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
|
||||
<Button Content="Mark as accidental"
|
||||
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
|
||||
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding Summary, Mode=OneWay}"/>
|
||||
<Run Text=" · "/>
|
||||
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
</DockPanel>
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,2">
|
||||
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
|
||||
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<ListBox Grid.Row="4"
|
||||
ItemsSource="{Binding Duplicates.Groups}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.CanContentScroll="True"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Visible"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
VirtualizingPanel.ScrollUnit="Pixel">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<ContentPresenter HorizontalAlignment="Stretch"/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
|
||||
<DockPanel>
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Content="Mark as intentional" Margin="0,0,6,0"
|
||||
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
|
||||
<Button Content="Mark as accidental"
|
||||
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,2">
|
||||
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
|
||||
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -18,11 +18,14 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private DocumentationWindow? _docs;
|
||||
private GitChangesWindow? _gitChanges;
|
||||
private HostActivityMonitorWindow? _hostActivity;
|
||||
private DatabaseWindow? _database;
|
||||
private Point _dragStart;
|
||||
private bool _dragPending;
|
||||
private MouseButton _dragButton;
|
||||
private FolderItemViewModel? _dragItem;
|
||||
private ListView? _dragList;
|
||||
private bool _draggingFromHere;
|
||||
private FolderItemViewModel[] _dragSelection = [];
|
||||
private bool _dragFromMultiSelect;
|
||||
private bool _syncingSelection;
|
||||
@@ -661,7 +664,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
_suppressItemContextMenu = false;
|
||||
_dragStart = e.GetPosition(null);
|
||||
_dragStart = e.GetPosition(this);
|
||||
_dragPending = true;
|
||||
_dragButton = e.ChangedButton;
|
||||
_dragList = sender as ListView;
|
||||
@@ -739,11 +742,12 @@ public partial class MainWindow : Window
|
||||
&& dest is not null
|
||||
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
|
||||
&& !DragDropPolicy.IsInvalidTarget(files, dest)
|
||||
&& !IsRedundantLeftDrop(files, dest)
|
||||
? hover
|
||||
: null;
|
||||
SetListDropTarget(folderTarget);
|
||||
SetTreeDropTarget(null);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest) || IsRedundantLeftDrop(files, dest))
|
||||
{
|
||||
e.Effects = DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
@@ -786,6 +790,11 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsRedundantLeftDrop(files, dest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -863,9 +872,8 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = e.GetPosition(null);
|
||||
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
var pos = e.GetPosition(this);
|
||||
if (!DragMovedEnough(pos, DragDropPolicy.DragStartDistance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -887,10 +895,18 @@ public partial class MainWindow : Window
|
||||
_sourceRightDrag = _dragButton == MouseButton.Right;
|
||||
_suppressItemContextMenu = _sourceRightDrag;
|
||||
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
|
||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
||||
_sourceRightDrag = false;
|
||||
_incomingRightDrag = false;
|
||||
ClearDropTargets();
|
||||
_draggingFromHere = true;
|
||||
try
|
||||
{
|
||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_draggingFromHere = false;
|
||||
_sourceRightDrag = false;
|
||||
_incomingRightDrag = false;
|
||||
ClearDropTargets();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
|
||||
@@ -1017,16 +1033,58 @@ public partial class MainWindow : Window
|
||||
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
|
||||
{
|
||||
var item = HitTestFolderItem(list, e.GetPosition(list));
|
||||
if (item is { IsDirectory: true })
|
||||
if (!_draggingFromHere)
|
||||
{
|
||||
if (item is { IsDirectory: true })
|
||||
{
|
||||
return item.FullPath;
|
||||
}
|
||||
|
||||
return CurrentListDirectory(list);
|
||||
}
|
||||
|
||||
var pos = e.GetPosition(this);
|
||||
if (item is { IsDirectory: true }
|
||||
&& DragMovedEnough(pos, FolderDropDistance(list)))
|
||||
{
|
||||
return item.FullPath;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(list, _dragList)
|
||||
&& !DragMovedEnough(pos, DragDropPolicy.DropCrossViewDistance))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CurrentListDirectory(list);
|
||||
}
|
||||
|
||||
private string? CurrentListDirectory(ListView list)
|
||||
{
|
||||
var tab = Vm.ActiveTab;
|
||||
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
|
||||
return LocationRoots.IsVirtual(path) ? null : path;
|
||||
}
|
||||
|
||||
private double FolderDropDistance(ListView list)
|
||||
=> ReferenceEquals(list, _dragList)
|
||||
? DragDropPolicy.DropIntoItemDistance
|
||||
: DragDropPolicy.DropCrossViewDistance;
|
||||
|
||||
private bool DragMovedEnough(Point current, double preferred)
|
||||
=> DragDropPolicy.ExceedsDistance(
|
||||
current.X - _dragStart.X,
|
||||
current.Y - _dragStart.Y,
|
||||
Math.Max(preferred, SystemDragMinimum));
|
||||
|
||||
private static double SystemDragMinimum
|
||||
=> Math.Max(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);
|
||||
|
||||
private bool IsRedundantLeftDrop(IReadOnlyList<string> files, string dest)
|
||||
=> !_sourceRightDrag
|
||||
&& !_incomingRightDrag
|
||||
&& DragDropPolicy.AllAlreadyInDirectory(files, dest);
|
||||
|
||||
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
|
||||
{
|
||||
if (origin is not Visual visual)
|
||||
@@ -1178,6 +1236,11 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsRedundantLeftDrop(files, dest))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -1848,6 +1911,48 @@ public partial class MainWindow : Window
|
||||
private void OnAbout(object sender, RoutedEventArgs e)
|
||||
=> new AboutWindow { Owner = this }.ShowDialog();
|
||||
|
||||
private void OnHostActivity(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_hostActivity is { IsVisible: true })
|
||||
{
|
||||
_hostActivity.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = Vm.CreateHostActivityMonitorViewModel();
|
||||
if (vm is null)
|
||||
{
|
||||
MessageBox.Show(this, "Host activity is unavailable until the background host is connected.",
|
||||
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
_hostActivity = new HostActivityMonitorWindow(vm) { Owner = this };
|
||||
_hostActivity.Closed += (_, _) => _hostActivity = null;
|
||||
_hostActivity.Show();
|
||||
}
|
||||
|
||||
private void OnDatabase(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_database is { IsVisible: true })
|
||||
{
|
||||
_database.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = Vm.CreateDatabaseViewerViewModel();
|
||||
if (vm is null)
|
||||
{
|
||||
MessageBox.Show(this, "Database tools are unavailable.",
|
||||
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
_database = new DatabaseWindow(vm) { Owner = this };
|
||||
_database.Closed += (_, _) => _database = null;
|
||||
_database.Show();
|
||||
}
|
||||
|
||||
private async void OnGitChanges(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_gitChanges is { IsVisible: true })
|
||||
|
||||
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
|
||||
{
|
||||
bool IsPaused { get; }
|
||||
string? CurrentPath { get; }
|
||||
void Pause();
|
||||
void Resume();
|
||||
void BeginUserRequested();
|
||||
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IHistoryMaintenance
|
||||
|
||||
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<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
|
||||
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
@@ -168,6 +169,10 @@ public interface IHashStore
|
||||
Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
|
||||
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
|
||||
IReadOnlyList<byte[]> hashes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IFileRelationStore
|
||||
|
||||
@@ -54,4 +54,46 @@ public static class DragDropPolicy
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows SM_CXDRAG is typically 4 DIP — enough to turn a click into a drag.
|
||||
/// A real drag is still immediate; this only ignores click jitter.
|
||||
/// </summary>
|
||||
public const double DragStartDistance = 16;
|
||||
|
||||
/// <summary>Dropping into a neighboring folder row should need more than a row-edge slip.</summary>
|
||||
public const double DropIntoItemDistance = 28;
|
||||
|
||||
/// <summary>Crossing into the other pane or the tree should not happen at the splitter.</summary>
|
||||
public const double DropCrossViewDistance = 36;
|
||||
|
||||
public static bool ExceedsDistance(double deltaX, double deltaY, double minimum)
|
||||
{
|
||||
var threshold = Math.Max(minimum, 0);
|
||||
return (deltaX * deltaX) + (deltaY * deltaY) >= threshold * threshold;
|
||||
}
|
||||
|
||||
public static bool AllAlreadyInDirectory(IReadOnlyList<string> sources, string destinationDirectory)
|
||||
{
|
||||
if (sources.Count == 0 || string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dest = NormalizeDirectory(destinationDirectory);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var src = PathRules.FromExtended(source).TrimEnd('\\');
|
||||
var parent = Path.GetDirectoryName(src);
|
||||
if (parent is null || !NormalizeDirectory(parent).Equals(dest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeDirectory(string path)
|
||||
=> PathRules.FromExtended(path).TrimEnd('\\');
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ public static class ExplorerHostClientServices
|
||||
services.AddSingleton(connection);
|
||||
}
|
||||
|
||||
if (workbench is IHostActivity activity)
|
||||
{
|
||||
services.AddSingleton(activity);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<IHostActivity>(_ => NullHostActivity.Instance);
|
||||
}
|
||||
|
||||
services.AddExplorerClientRuntime();
|
||||
return services;
|
||||
}
|
||||
@@ -54,6 +63,7 @@ public static class ExplorerHostClientServices
|
||||
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
||||
return new SqliteIndexStore(env.DatabasePath, logger, readOnly: true);
|
||||
});
|
||||
services.AddSingleton<ISqliteDatabaseSessionFactory, SqliteDatabaseSessionFactory>();
|
||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
|
||||
services.AddSingleton<IMediaConversionProvider, FfmpegConversionExecutor>();
|
||||
|
||||
@@ -8,7 +8,7 @@ using Explorer.Plugin.Abstractions;
|
||||
|
||||
namespace Explorer.Hosting.Ipc;
|
||||
|
||||
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IAsyncDisposable
|
||||
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IHostActivity, IAsyncDisposable
|
||||
{
|
||||
private NamedPipeClientStream _pipe;
|
||||
private StreamWriter _writer;
|
||||
@@ -51,6 +51,18 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
|
||||
public void RunNow() => Call("Maintenance.RunNow");
|
||||
|
||||
public async Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reply = await CallAsync("Host.ActivitySnapshot", cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(reply.Payload))
|
||||
{
|
||||
return new HostActivitySnapshot { Maintenance = _maintenance };
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<HostActivitySnapshot>(reply.Payload, WorkbenchIpc.Json)
|
||||
?? new HostActivitySnapshot { Maintenance = _maintenance };
|
||||
}
|
||||
|
||||
public static async Task<WorkbenchPipeClient> ConnectAsync(
|
||||
WorkbenchIpcOptions options,
|
||||
TimeSpan timeout,
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
private readonly IHistoryMaintenance _history;
|
||||
private readonly IIndexStore _store;
|
||||
private readonly IVolumeService _volumes;
|
||||
private readonly IHostActivitySink _activity;
|
||||
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
|
||||
private readonly HashSet<long> _collisionEnqueued = [];
|
||||
private readonly HashSet<long> _idleScanQueued = [];
|
||||
@@ -42,7 +43,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
IHistoryMaintenance history,
|
||||
IIndexStore store,
|
||||
IVolumeService volumes,
|
||||
ILogger<BackgroundMaintenanceCoordinator> logger)
|
||||
ILogger<BackgroundMaintenanceCoordinator> logger,
|
||||
IHostActivitySink? activity = null)
|
||||
{
|
||||
_idle = idle;
|
||||
_power = power;
|
||||
@@ -54,6 +56,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
_store = store;
|
||||
_volumes = volumes;
|
||||
_logger = logger;
|
||||
_activity = activity ?? NullHostActivitySink.Instance;
|
||||
}
|
||||
|
||||
public event EventHandler<MaintenanceSnapshot>? Changed;
|
||||
@@ -149,10 +152,12 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
_hash.Pause();
|
||||
}
|
||||
|
||||
_workMessage = _indexing.HasIdleWork
|
||||
? (_workMessage ?? "Scanning")
|
||||
: "Idle maintenance";
|
||||
Publish(decision, _workMessage);
|
||||
// Keep the idle-maintenance caption only while idle work is still queued/running.
|
||||
// Watcher/user jobs must not keep showing a stale "checking External" line.
|
||||
var busyMessage = _indexing.HasIdleWork
|
||||
? (_workMessage ?? "Idle maintenance")
|
||||
: "";
|
||||
Publish(decision, busyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +172,8 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
|
||||
_idleVerified.Add(verify.Id);
|
||||
_indexing.EnqueueIdleVerify(verify.Id, "");
|
||||
_runNow = false;
|
||||
// Keep _runNow until the whole RunNow session finishes so user activity
|
||||
// does not cancel the work that was just started via the button.
|
||||
_workMessage = "Idle maintenance · checking " + verify.DisplayName;
|
||||
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
|
||||
Publish(decision, _workMessage);
|
||||
@@ -184,7 +190,6 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
|
||||
_idleScanQueued.Add(scan.Id);
|
||||
_indexing.EnqueueIdleFullScan(scan.Id);
|
||||
_runNow = false;
|
||||
_workMessage = "Scanning " + scan.DisplayName;
|
||||
_logger.LogDebug("maintenance started scan {Source}", scan.DisplayName);
|
||||
Publish(decision, _workMessage);
|
||||
@@ -194,16 +199,14 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
_hash.Resume();
|
||||
if (hashPending)
|
||||
{
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance";
|
||||
_workMessage = "Idle maintenance · hashing";
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance";
|
||||
_workMessage = "Idle maintenance · history";
|
||||
_logger.LogDebug("maintenance completed (history)");
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
@@ -214,7 +217,7 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
{
|
||||
_collisionEnqueued.Add(hashSource.Id);
|
||||
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
|
||||
_workMessage = "Idle maintenance";
|
||||
_workMessage = "Idle maintenance · hash queue";
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
@@ -261,6 +264,11 @@ public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackg
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
_activity.Record("Maintenance", message);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ public static class ExplorerHostServices
|
||||
|
||||
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IHostActivitySink, HostActivityLog>();
|
||||
services.AddSingleton<IndexingCoordinator>();
|
||||
services.AddSingleton<DirectoryWatcherHub>();
|
||||
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
|
||||
|
||||
@@ -317,6 +317,11 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
|
||||
return reply;
|
||||
}
|
||||
case "Host.ActivitySnapshot":
|
||||
{
|
||||
reply.Payload = JsonSerializer.Serialize(await BuildActivitySnapshotAsync().ConfigureAwait(false), WorkbenchIpc.Json);
|
||||
return reply;
|
||||
}
|
||||
case "Host.Shutdown":
|
||||
await RequestShutdownAsync().ConfigureAwait(false);
|
||||
return reply;
|
||||
@@ -536,6 +541,40 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HostActivitySnapshot> BuildActivitySnapshotAsync()
|
||||
{
|
||||
var maintenance = _services.GetService<IBackgroundMaintenance>()?.Snapshot ?? MaintenanceSnapshot.Empty;
|
||||
var indexing = _services.GetService<Indexing.IndexingCoordinator>();
|
||||
var hash = _services.GetService<IIdleHashWork>();
|
||||
var transfers = _services.GetService<ITransferHost>();
|
||||
var activity = _services.GetService<IHostActivitySink>();
|
||||
var jobs = transfers?.Snapshot() ?? [];
|
||||
var active = jobs.Count(j => j.Status is TransferStatus.Running or TransferStatus.Cancelling);
|
||||
var queued = jobs.Count(j => j.Status is TransferStatus.Waiting or TransferStatus.Paused);
|
||||
var running = jobs.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Cancelling);
|
||||
var last = indexing?.LastProgress;
|
||||
return new HostActivitySnapshot
|
||||
{
|
||||
Utc = DateTimeOffset.UtcNow,
|
||||
Maintenance = maintenance,
|
||||
IdleAllowed = indexing?.IdleAllowed ?? false,
|
||||
IdleQueued = indexing?.IdleQueued ?? 0,
|
||||
HashPaused = hash?.IsPaused ?? true,
|
||||
HashPending = hash is null ? 0 : await hash.CountPendingAsync().ConfigureAwait(false),
|
||||
HashCurrentPath = hash?.CurrentPath,
|
||||
TransfersPaused = transfers?.IsPaused ?? false,
|
||||
TransfersActive = active,
|
||||
TransfersQueued = queued,
|
||||
TransferCurrentPath = running?.CurrentPath ?? running?.SourcePath,
|
||||
LastIndexPath = last?.CurrentPath,
|
||||
LastIndexFilesDone = last?.FilesSeen ?? 0,
|
||||
LastIndexDirsDone = last?.DirsSeen ?? 0,
|
||||
LastIndexStatus = last?.Status.ToString(),
|
||||
IndexingJobs = indexing?.GetRunningJobs() ?? [],
|
||||
RecentEvents = activity?.TakeRecent(40) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
private static T Read<T>(string? payload)
|
||||
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
|
||||
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class FolderReconciler
|
||||
_preferences = preferences;
|
||||
}
|
||||
|
||||
public Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||
public Task<bool> ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||
=> ReconcileAsync(source, pathRel, cancellationToken, verifyChildren: false);
|
||||
|
||||
public Task<bool> ReconcileAsync(
|
||||
@@ -74,11 +74,25 @@ public sealed class FolderReconciler
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
|
||||
if (LocationClassifier.IsRecycleBinName(parent.Name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IndexedPathPresence.DirectoryExists(full))
|
||||
{
|
||||
if (string.IsNullOrEmpty(pathRel))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var gone = DateTimeOffset.UtcNow;
|
||||
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, gone, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await _store.Entries.TombstoneAsync(parent.Id, gone, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
||||
|
||||
@@ -16,12 +16,15 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
private readonly UsnChangeApplier _usn;
|
||||
private readonly IUsnJournal _journal;
|
||||
private readonly IVolumeService _volumes;
|
||||
private readonly IHostActivitySink _activity;
|
||||
private readonly ILogger<IndexingCoordinator> _logger;
|
||||
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
|
||||
private readonly Dictionary<long, RunningWork> _running = new();
|
||||
private readonly object _gate = new();
|
||||
private volatile bool _idleAllowed;
|
||||
private int _idleQueued;
|
||||
private ScanProgress? _lastProgress;
|
||||
private DateTimeOffset _lastActivityLogUtc;
|
||||
|
||||
public event EventHandler<ScanProgress>? ProgressChanged;
|
||||
|
||||
@@ -32,7 +35,8 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
UsnChangeApplier usn,
|
||||
IUsnJournal journal,
|
||||
IVolumeService volumes,
|
||||
ILogger<IndexingCoordinator> logger)
|
||||
ILogger<IndexingCoordinator> logger,
|
||||
IHostActivitySink? activity = null)
|
||||
{
|
||||
_store = store;
|
||||
_scanner = scanner;
|
||||
@@ -41,6 +45,7 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
_journal = journal;
|
||||
_volumes = volumes;
|
||||
_logger = logger;
|
||||
_activity = activity ?? NullHostActivitySink.Instance;
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
@@ -65,6 +70,36 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
}
|
||||
}
|
||||
|
||||
public int IdleQueued
|
||||
{
|
||||
get { lock (_gate) { return _idleQueued; } }
|
||||
}
|
||||
|
||||
public bool IdleAllowed => _idleAllowed;
|
||||
|
||||
public ScanProgress? LastProgress
|
||||
{
|
||||
get { lock (_gate) { return _lastProgress; } }
|
||||
}
|
||||
|
||||
public IReadOnlyList<HostActivityJob> GetRunningJobs()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _running.Values
|
||||
.Select(w => new HostActivityJob
|
||||
{
|
||||
SourceId = w.SourceId,
|
||||
SourceName = w.SourceName,
|
||||
Kind = w.Kind.ToString(),
|
||||
Origin = w.Origin.ToString(),
|
||||
PathRel = w.PathRel,
|
||||
VerifyChildren = w.VerifyChildren
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetIdleAllowed(bool allowed)
|
||||
{
|
||||
_idleAllowed = allowed;
|
||||
@@ -176,9 +211,22 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
lock (_gate)
|
||||
{
|
||||
_running[item.SourceId] = new RunningWork(linked, item.Origin);
|
||||
_running[item.SourceId] = new RunningWork(
|
||||
linked,
|
||||
item.Origin,
|
||||
item.Kind,
|
||||
item.SourceId,
|
||||
source.DisplayName,
|
||||
item.PathRel,
|
||||
item.VerifyChildren);
|
||||
}
|
||||
|
||||
_activity.Record(
|
||||
"Indexing",
|
||||
item.Kind + " · " + source.DisplayName
|
||||
+ (string.IsNullOrEmpty(item.PathRel) ? "" : " · " + item.PathRel)
|
||||
+ " (" + item.Origin + ")");
|
||||
|
||||
try
|
||||
{
|
||||
switch (item.Kind)
|
||||
@@ -189,7 +237,16 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
source,
|
||||
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
|
||||
item.PathRel,
|
||||
new Progress<ScanProgress>(p => ProgressChanged?.Invoke(this, p)),
|
||||
new Progress<ScanProgress>(p =>
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastProgress = p;
|
||||
}
|
||||
|
||||
NoteProgress(p);
|
||||
ProgressChanged?.Invoke(this, p);
|
||||
}),
|
||||
linked.Token).ConfigureAwait(false);
|
||||
EnqueueUsn(source.Id);
|
||||
break;
|
||||
@@ -204,13 +261,19 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
var full = source.LastRootPath is null
|
||||
? item.PathRel
|
||||
: PathRules.Combine(source.LastRootPath, item.PathRel);
|
||||
ProgressChanged?.Invoke(this, new ScanProgress
|
||||
var done = new ScanProgress
|
||||
{
|
||||
SourceId = source.Id,
|
||||
CurrentPath = full,
|
||||
Status = ScanJobStatus.Done,
|
||||
IndexRefresh = true
|
||||
});
|
||||
};
|
||||
lock (_gate)
|
||||
{
|
||||
_lastProgress = done;
|
||||
}
|
||||
|
||||
ProgressChanged?.Invoke(this, done);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +292,29 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
}
|
||||
}
|
||||
|
||||
private void NoteProgress(ScanProgress progress)
|
||||
{
|
||||
if (progress.IndexRefresh || string.IsNullOrWhiteSpace(progress.CurrentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
lock (_gate)
|
||||
{
|
||||
if ((now - _lastActivityLogUtc) < TimeSpan.FromSeconds(1.5))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastActivityLogUtc = now;
|
||||
}
|
||||
|
||||
_activity.Record(
|
||||
"Indexing",
|
||||
progress.FilesSeen + " files · " + progress.DirsSeen + " dirs · " + progress.CurrentPath);
|
||||
}
|
||||
|
||||
private async Task PeriodicAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
@@ -256,5 +342,12 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdl
|
||||
IndexWorkOrigin Origin,
|
||||
bool VerifyChildren = false);
|
||||
|
||||
private sealed record RunningWork(CancellationTokenSource Cts, IndexWorkOrigin Origin);
|
||||
private sealed record RunningWork(
|
||||
CancellationTokenSource Cts,
|
||||
IndexWorkOrigin Origin,
|
||||
WorkKind Kind,
|
||||
long SourceId,
|
||||
string SourceName,
|
||||
string? PathRel,
|
||||
bool VerifyChildren);
|
||||
}
|
||||
|
||||
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.Threading.Channels;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Presentation.ViewModels;
|
||||
|
||||
public sealed partial class DuplicateViewModel : ObservableObject
|
||||
{
|
||||
private readonly IIndexMutations _mutations;
|
||||
private readonly IIndexingHost _indexing;
|
||||
private readonly SourceManager _sources;
|
||||
private readonly AnalysisService _analysis;
|
||||
private CancellationTokenSource? _load;
|
||||
|
||||
[ObservableProperty] private bool _isOpen;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -20,9 +24,14 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _showIntentional;
|
||||
[ObservableProperty] private bool _showHardlinks;
|
||||
|
||||
public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
|
||||
public DuplicateViewModel(
|
||||
IIndexMutations mutations,
|
||||
IIndexingHost indexing,
|
||||
SourceManager sources,
|
||||
AnalysisService analysis)
|
||||
{
|
||||
_mutations = mutations;
|
||||
_indexing = indexing;
|
||||
_sources = sources;
|
||||
_analysis = analysis;
|
||||
Groups = [];
|
||||
@@ -35,6 +44,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
public Task CloseAsync()
|
||||
{
|
||||
_load?.Cancel();
|
||||
IsOpen = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -42,58 +52,230 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
public async Task OpenAsync()
|
||||
{
|
||||
_load?.Cancel();
|
||||
var cts = new CancellationTokenSource();
|
||||
_load = cts;
|
||||
var ct = cts.Token;
|
||||
|
||||
IsOpen = true;
|
||||
IsBusy = true;
|
||||
Status = "Finding size collisions…";
|
||||
Groups.Clear();
|
||||
var hiddenIntentional = 0;
|
||||
var hiddenHardlinks = 0;
|
||||
try
|
||||
{
|
||||
var groups = await Task.Run(async () =>
|
||||
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 () =>
|
||||
{
|
||||
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);
|
||||
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 hiddenIntentional = 0;
|
||||
var hiddenHardlinks = 0;
|
||||
foreach (var group in groups)
|
||||
var added = 0;
|
||||
await foreach (var classified in channel.Reader.ReadAllAsync(ct).ConfigureAwait(true))
|
||||
{
|
||||
if (group.Classification == DuplicateClass.Hardlink)
|
||||
ApplyGroup(classified, sources, ref hiddenIntentional, ref hiddenHardlinks);
|
||||
added++;
|
||||
if (added % 24 == 0)
|
||||
{
|
||||
if (!ShowHardlinks)
|
||||
{
|
||||
hiddenHardlinks++;
|
||||
continue;
|
||||
}
|
||||
await Task.Yield();
|
||||
}
|
||||
else if (DuplicateClassifier.IsIntentional(group.Classification))
|
||||
{
|
||||
if (!ShowIntentional)
|
||||
{
|
||||
hiddenIntentional++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Groups.Add(group);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Status = "Could not load duplicates.";
|
||||
if (IsOpen && ReferenceEquals(_load, cts))
|
||||
{
|
||||
Status = "Could not load duplicates.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
if (ReferenceEquals(_load, cts))
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyGroup(
|
||||
ClassifiedDuplicateGroup classified,
|
||||
IReadOnlyDictionary<long, Source> sources,
|
||||
ref int hiddenIntentional,
|
||||
ref int hiddenHardlinks)
|
||||
{
|
||||
var group = DuplicateGroupViewModel.From(classified, sources);
|
||||
if (group.Classification == DuplicateClass.Hardlink)
|
||||
{
|
||||
if (!ShowHardlinks)
|
||||
{
|
||||
hiddenHardlinks++;
|
||||
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (DuplicateClassifier.IsIntentional(group.Classification))
|
||||
{
|
||||
if (!ShowIntentional)
|
||||
{
|
||||
hiddenIntentional++;
|
||||
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Groups.Add(group);
|
||||
Status = LiveStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||
}
|
||||
|
||||
private async Task VerifyOnDiskAsync(
|
||||
IReadOnlyDictionary<long, Source> sources,
|
||||
CancellationTokenSource cts,
|
||||
int hiddenIntentional,
|
||||
int hiddenHardlinks)
|
||||
{
|
||||
var ct = cts.Token;
|
||||
List<DuplicateGroupViewModel> snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = Groups.ToList();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> changes;
|
||||
HashSet<(long SourceId, string PathRel)> reconcile;
|
||||
try
|
||||
{
|
||||
(changes, reconcile) = await Task.Run(
|
||||
() => ScanMissing(snapshot, sources, ct),
|
||||
ct)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsOpen || !ReferenceEquals(_load, cts) || ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (old, next) in changes)
|
||||
{
|
||||
var index = Groups.IndexOf(old);
|
||||
if (index < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
Groups.RemoveAt(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
Groups[index] = next;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (sourceId, pathRel) in reconcile)
|
||||
{
|
||||
_indexing.EnqueueReconcile(sourceId, pathRel);
|
||||
}
|
||||
|
||||
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
|
||||
}
|
||||
|
||||
private static (
|
||||
List<(DuplicateGroupViewModel Old, DuplicateGroupViewModel? Next)> Changes,
|
||||
HashSet<(long SourceId, string PathRel)> Reconcile)
|
||||
ScanMissing(
|
||||
IReadOnlyList<DuplicateGroupViewModel> groups,
|
||||
IReadOnlyDictionary<long, Source> sources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var changes = new List<(DuplicateGroupViewModel, DuplicateGroupViewModel?)>();
|
||||
var reconcile = new HashSet<(long, string)>();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
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]
|
||||
public async Task MarkIntentionalAsync(DuplicateGroupViewModel? group)
|
||||
{
|
||||
@@ -147,6 +329,19 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
private static string LiveStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
|
||||
{
|
||||
var extra = hiddenIntentional + hiddenHardlinks;
|
||||
if (visible == 0 && extra == 0)
|
||||
{
|
||||
return "Loading duplicate groups…";
|
||||
}
|
||||
|
||||
return extra == 0
|
||||
? $"{visible} duplicate groups so far…"
|
||||
: $"{visible} duplicate groups so far · {extra} hidden";
|
||||
}
|
||||
|
||||
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
|
||||
{
|
||||
if (visible > 0)
|
||||
@@ -164,12 +359,18 @@ public sealed partial class DuplicateViewModel : ObservableObject
|
||||
|
||||
return "No confirmed duplicates yet. Hashing continues in the background.";
|
||||
}
|
||||
|
||||
private sealed class DirectProgress<T>(Action<T> action) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => action(value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DuplicateGroupViewModel
|
||||
{
|
||||
public required IReadOnlyList<IndexEntry> Entries { get; init; }
|
||||
public DuplicateClass Classification { get; init; }
|
||||
public required string Header { get; init; }
|
||||
public required string ClassLabel { get; init; }
|
||||
public required string SizeLabel { get; init; }
|
||||
public required string Summary { get; init; }
|
||||
@@ -178,6 +379,30 @@ public sealed class DuplicateGroupViewModel
|
||||
public bool CanMarkAccidental { get; init; }
|
||||
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
|
||||
|
||||
public DuplicateGroupViewModel WithEntries(
|
||||
IReadOnlyList<IndexEntry> entries,
|
||||
IReadOnlyDictionary<long, Source> sources)
|
||||
{
|
||||
var unique = DuplicateClassifier.UniqueFileCount(entries);
|
||||
var size = entries.Count > 0 ? entries[0].SizeBytes : 0;
|
||||
return From(
|
||||
new ClassifiedDuplicateGroup
|
||||
{
|
||||
Group = new DuplicateGroup
|
||||
{
|
||||
SizeBytes = size,
|
||||
Entries = entries,
|
||||
SameFileId = DuplicateClassifier.IsHardlinkOnly(entries)
|
||||
},
|
||||
Classification = Classification,
|
||||
UniqueFileCount = unique,
|
||||
WastedBytes = Classification == DuplicateClass.Hardlink || unique <= 1
|
||||
? 0
|
||||
: size * (unique - 1)
|
||||
},
|
||||
sources);
|
||||
}
|
||||
|
||||
public static DuplicateGroupViewModel From(
|
||||
ClassifiedDuplicateGroup classified,
|
||||
IReadOnlyDictionary<long, Source> sources)
|
||||
@@ -197,16 +422,27 @@ public sealed class DuplicateGroupViewModel
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var classLabel = classified.Classification == DuplicateClass.Unknown
|
||||
? ""
|
||||
: DuplicateClassifier.Label(classified.Classification);
|
||||
var sizeLabel = Formatters.Size(classified.Group.SizeBytes);
|
||||
var summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names";
|
||||
var wasted = classified.WastedBytes > 0
|
||||
? Formatters.Size(classified.WastedBytes) + " wasted"
|
||||
: "No extra space";
|
||||
var header = string.IsNullOrEmpty(classLabel)
|
||||
? $"{sizeLabel} · {summary} · {wasted}"
|
||||
: $"{classLabel} · {sizeLabel} · {summary} · {wasted}";
|
||||
|
||||
return new DuplicateGroupViewModel
|
||||
{
|
||||
Entries = classified.Group.Entries,
|
||||
Classification = classified.Classification,
|
||||
ClassLabel = DuplicateClassifier.Label(classified.Classification),
|
||||
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
|
||||
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
|
||||
WastedLabel = classified.WastedBytes > 0
|
||||
? Formatters.Size(classified.WastedBytes) + " wasted"
|
||||
: "No extra space",
|
||||
Header = header,
|
||||
ClassLabel = classLabel,
|
||||
SizeLabel = sizeLabel,
|
||||
Summary = summary,
|
||||
WastedLabel = wasted,
|
||||
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
|
||||
&& classified.Classification != DuplicateClass.Intentional,
|
||||
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
|
||||
|
||||
@@ -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 IHostConnection? _host;
|
||||
private readonly IBackgroundMaintenance? _maintenance;
|
||||
private readonly ITransferHost _transferHost;
|
||||
private readonly IHostActivity? _hostActivity;
|
||||
private readonly ISqliteDatabaseSessionFactory? _databaseSessions;
|
||||
private readonly IAppEnvironment? _environment;
|
||||
private readonly IShellContextMenu? _shellMenu;
|
||||
private readonly IMediaTagService? _tags;
|
||||
private bool _hostStopped;
|
||||
private List<string> _clipboard = [];
|
||||
private bool _clipboardIsCut;
|
||||
private int _overlayDirty;
|
||||
private int _overlayRunning;
|
||||
private DateTimeOffset _lastOverlayRefreshUtc = DateTimeOffset.MinValue;
|
||||
|
||||
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
|
||||
[ObservableProperty] private string _pathText = "";
|
||||
@@ -104,12 +111,19 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IThumbnailService? thumbnails = null,
|
||||
IHostConnection? hostConnection = null,
|
||||
IBackgroundMaintenance? maintenance = null,
|
||||
IHostActivity? hostActivity = null,
|
||||
ISqliteDatabaseSessionFactory? databaseSessions = null,
|
||||
IAppEnvironment? environment = null,
|
||||
IShellContextMenu? shellMenu = null,
|
||||
IMediaTagService? tags = null)
|
||||
{
|
||||
_browse = browse;
|
||||
_ops = ops;
|
||||
_indexing = workbench.Indexing;
|
||||
_transferHost = workbench.Transfers;
|
||||
_hostActivity = hostActivity;
|
||||
_databaseSessions = databaseSessions;
|
||||
_environment = environment;
|
||||
_sources = sources;
|
||||
_pathHistory = pathHistory;
|
||||
_providers = providers;
|
||||
@@ -174,7 +188,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences, knownFolders);
|
||||
Search = new SearchViewModel(search, sources, volumes);
|
||||
Analysis = new AnalysisViewModel(analysis);
|
||||
Duplicates = new DuplicateViewModel(mutations, sources, analysis);
|
||||
Duplicates = new DuplicateViewModel(mutations, _indexing, sources, analysis);
|
||||
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
|
||||
_sources.PresenceChanged += (_, source) =>
|
||||
{
|
||||
@@ -207,16 +221,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
if (p.IndexRefresh)
|
||||
{
|
||||
void Refresh() => _ = RefreshIndexOverlayAsync();
|
||||
if (_ui is { } refreshCtx)
|
||||
{
|
||||
refreshCtx.Post(_ => Refresh(), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
ScheduleIndexOverlayRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,6 +248,53 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
};
|
||||
}
|
||||
|
||||
private void ScheduleIndexOverlayRefresh()
|
||||
{
|
||||
Interlocked.Exchange(ref _overlayDirty, 1);
|
||||
if (Interlocked.CompareExchange(ref _overlayRunning, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
void Start() => _ = RunIndexOverlayRefreshAsync();
|
||||
if (_ui is { } ctx)
|
||||
{
|
||||
ctx.Post(_ => Start(), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Start();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunIndexOverlayRefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
Interlocked.Exchange(ref _overlayDirty, 0);
|
||||
var wait = TimeSpan.FromSeconds(2.5) - (DateTimeOffset.UtcNow - _lastOverlayRefreshUtc);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(wait).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
_lastOverlayRefreshUtc = DateTimeOffset.UtcNow;
|
||||
await RefreshIndexOverlayAsync().ConfigureAwait(true);
|
||||
}
|
||||
while (Volatile.Read(ref _overlayDirty) == 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _overlayRunning, 0);
|
||||
if (Interlocked.Exchange(ref _overlayDirty, 0) == 1)
|
||||
{
|
||||
ScheduleIndexOverlayRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
|
||||
public ObservableCollection<string> PathHistory { get; }
|
||||
public NavigationTreeViewModel Tree { get; }
|
||||
@@ -688,6 +740,16 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
public GitChangesViewModel CreateGitChangesViewModel()
|
||||
=> new(_gitCommands, _workspace, _hydration);
|
||||
|
||||
public HostActivityMonitorViewModel? CreateHostActivityMonitorViewModel()
|
||||
=> _hostActivity is null
|
||||
? null
|
||||
: new HostActivityMonitorViewModel(_hostActivity, _maintenance, _indexing, _transferHost);
|
||||
|
||||
public DatabaseViewerViewModel? CreateDatabaseViewerViewModel()
|
||||
=> _databaseSessions is null || _environment is null
|
||||
? null
|
||||
: new DatabaseViewerViewModel(_databaseSessions, _environment);
|
||||
|
||||
public async Task<GitCommitViewModel?> CreateGitCommitViewModelAsync()
|
||||
{
|
||||
var path = GitWorkspacePath();
|
||||
|
||||
@@ -228,9 +228,16 @@ internal sealed class HashStore : IHashStore
|
||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
var hit = await conn.ExecuteScalarAsync<long?>(
|
||||
"SELECT 1 FROM hash_queue WHERE state IN ('Pending','PartialDone') LIMIT 1").ConfigureAwait(false);
|
||||
return hit is 1;
|
||||
}
|
||||
|
||||
public async Task<long> CountPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await conn.ExecuteScalarAsync<long>(
|
||||
"SELECT COUNT(*) FROM hash_queue WHERE state IN ('Pending','PartialDone')").ConfigureAwait(false);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||
@@ -306,36 +313,100 @@ internal sealed class HashStore : IHashStore
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hashes = await GetDuplicateHashesAsync(sourceId, pathPrefix, take, cancellationToken).ConfigureAwait(false);
|
||||
return await GetDuplicateGroupsByHashesAsync(hashes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(
|
||||
long? sourceId,
|
||||
string? pathPrefix,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var sql = """
|
||||
SELECT * FROM entries
|
||||
SELECT content_hash
|
||||
FROM entries
|
||||
WHERE is_dir=0 AND status=0 AND hash_state=2 AND content_hash IS NOT NULL
|
||||
""";
|
||||
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||
if (sourceId is not null)
|
||||
{
|
||||
sql += " AND source_id=@sourceId";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(pathPrefix))
|
||||
{
|
||||
sql += " AND (path_rel=@pathPrefix OR path_rel LIKE @like ESCAPE '\\')";
|
||||
sql += " ORDER BY content_hash, file_id";
|
||||
}
|
||||
|
||||
sql += """
|
||||
GROUP BY content_hash
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY MAX(size_bytes) DESC
|
||||
LIMIT @take
|
||||
""";
|
||||
var like = string.IsNullOrEmpty(pathPrefix) ? null : pathPrefix.Replace("\\", "\\\\") + "\\\\%";
|
||||
var rows = (await conn.QueryAsync<EntryRow>(sql, new { sourceId, pathPrefix, like }).ConfigureAwait(false))
|
||||
var rows = await conn.QueryAsync<HashBlobRow>(sql, new { sourceId, pathPrefix, like, take })
|
||||
.ConfigureAwait(false);
|
||||
return rows.Select(r => r.content_hash).Where(h => h.Length > 0).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
|
||||
IReadOnlyList<byte[]> hashes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var parameters = new DynamicParameters();
|
||||
var ors = new string[hashes.Count];
|
||||
for (var i = 0; i < hashes.Count; i++)
|
||||
{
|
||||
var name = "h" + i;
|
||||
ors[i] = "content_hash=@" + name;
|
||||
parameters.Add(name, hashes[i]);
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM entries
|
||||
WHERE is_dir=0 AND status=0 AND hash_state=2 AND ({string.Join(" OR ", ors)})
|
||||
ORDER BY content_hash, file_id
|
||||
""";
|
||||
var rows = (await conn.QueryAsync<EntryRow>(sql, parameters).ConfigureAwait(false))
|
||||
.Select(r => r.ToModel())
|
||||
.ToList();
|
||||
|
||||
return rows
|
||||
.GroupBy(e => Convert.ToHexString(e.ContentHash!))
|
||||
.Where(g => g.Count() > 1)
|
||||
.Take(take)
|
||||
.Select(g =>
|
||||
var byHash = rows
|
||||
.Where(e => e.ContentHash is { Length: > 0 })
|
||||
.GroupBy(e => Convert.ToHexString(e.ContentHash!), StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
|
||||
var result = new List<DuplicateGroup>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var hash in hashes)
|
||||
{
|
||||
var hex = Convert.ToHexString(hash);
|
||||
if (!seen.Add(hex) || !byHash.TryGetValue(hex, out var list) || list.Count < 2)
|
||||
{
|
||||
var list = g.ToList();
|
||||
return new DuplicateGroup
|
||||
{
|
||||
SizeBytes = list[0].SizeBytes,
|
||||
Hash = list[0].ContentHash,
|
||||
Entries = list,
|
||||
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new DuplicateGroup
|
||||
{
|
||||
SizeBytes = list[0].SizeBytes,
|
||||
Hash = list[0].ContentHash,
|
||||
Entries = list,
|
||||
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class HashBlobRow
|
||||
{
|
||||
public byte[] content_hash { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ internal static class SchemaScript
|
||||
state TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_hash_queue_state ON hash_queue(state, priority, size_bytes)",
|
||||
"""
|
||||
CREATE TABLE source_stats_history (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
||||
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'");
|
||||
SetUserVersion(conn, 9);
|
||||
_logger.LogInformation("Migrated SQLite schema to v9 (conversion profiles)");
|
||||
version = 9;
|
||||
}
|
||||
|
||||
if (version < 10)
|
||||
{
|
||||
using (var idx = conn.CreateCommand())
|
||||
{
|
||||
idx.CommandText =
|
||||
"CREATE INDEX IF NOT EXISTS ix_hash_queue_state ON hash_queue(state, priority, size_bytes)";
|
||||
idx.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
SetUserVersion(conn, 10);
|
||||
_logger.LogInformation("Migrated SQLite schema to v10 (hash queue state index)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,10 +178,44 @@ public class AnalysisTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_files_are_tombstoned_when_loading_duplicates()
|
||||
{
|
||||
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
|
||||
await using (store)
|
||||
{
|
||||
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
|
||||
var classified = await analysis.GetClassifiedDuplicatesAsync();
|
||||
Assert.Empty(classified);
|
||||
var gone = await store.Entries.GetByPathAsync(source.Id, "a.bin");
|
||||
Assert.Equal(EntryStatus.Deleted, gone!.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remaining_copies_still_show_when_one_duplicate_is_gone()
|
||||
{
|
||||
var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2);
|
||||
await using (store)
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(source.LastRootPath!, "c.bin"), new byte[64]);
|
||||
var root = await store.Entries.GetByPathAsync(source.Id, "");
|
||||
await store.Entries.UpsertAsync(Hashed("c.bin", 3, source.Id, root!.Id, Enumerable.Repeat((byte)7, 32).ToArray()));
|
||||
File.Delete(Path.Combine(source.LastRootPath!, "a.bin"));
|
||||
var classified = Assert.Single(await analysis.GetClassifiedDuplicatesAsync());
|
||||
Assert.Equal(2, classified.Group.Entries.Count);
|
||||
Assert.DoesNotContain(classified.Group.Entries, e => e.PathRel == "a.bin");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync(
|
||||
long fileIdA, long fileIdB)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var rootPath = Path.Combine(Path.GetTempPath(), "ew-dup-fs", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(rootPath);
|
||||
File.WriteAllBytes(Path.Combine(rootPath, "a.bin"), new byte[64]);
|
||||
File.WriteAllBytes(Path.Combine(rootPath, "b.bin"), new byte[64]);
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
var source = new Source
|
||||
@@ -189,7 +223,7 @@ public class AnalysisTests
|
||||
StableKey = "d",
|
||||
DisplayName = "D",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
LastRootPath = @"C:\d",
|
||||
LastRootPath = rootPath,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Click_jitter_does_not_start_a_drag()
|
||||
{
|
||||
Assert.False(DragDropPolicy.ExceedsDistance(4, 3, DragDropPolicy.DragStartDistance));
|
||||
Assert.False(DragDropPolicy.ExceedsDistance(12, 8, DragDropPolicy.DragStartDistance));
|
||||
Assert.True(DragDropPolicy.ExceedsDistance(16, 0, DragDropPolicy.DragStartDistance));
|
||||
Assert.True(DragDropPolicy.ExceedsDistance(12, 12, DragDropPolicy.DragStartDistance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_folder_drop_is_redundant()
|
||||
{
|
||||
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Docs\b.txt"], @"C:\Docs"));
|
||||
Assert.True(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Docs\"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt"], @"C:\Other"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([@"C:\Docs\a.txt", @"C:\Other\b.txt"], @"C:\Docs"));
|
||||
Assert.False(DragDropPolicy.AllAlreadyInDirectory([], @"C:\Docs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Same_volume_uses_drive_or_unc_share()
|
||||
{
|
||||
|
||||
@@ -120,6 +120,21 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_now_keeps_running_while_user_stays_active()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, sources: [StaleLocal()]);
|
||||
coordinator.RunNow();
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Contains("checking", coordinator.Snapshot.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(indexing.IdleAllowed);
|
||||
Assert.DoesNotContain("Paused because user is active", coordinator.Snapshot.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_verifies_a_fresh_online_source()
|
||||
{
|
||||
@@ -220,10 +235,12 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
{
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public string? CurrentPath => null;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public void BeginUserRequested() { }
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
}
|
||||
|
||||
private sealed class FakeHistory : IHistoryMaintenance
|
||||
@@ -303,6 +320,13 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
public Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<byte[]>>([]);
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
|
||||
IReadOnlyList<byte[]> hashes, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
}
|
||||
|
||||
private sealed class TempEnv : IAppEnvironment
|
||||
|
||||
@@ -327,6 +327,30 @@ public class ScannerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_directory_tree()
|
||||
{
|
||||
var root = CreateTree();
|
||||
try
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||
Directory.Delete(Path.Combine(root, "Movies"), recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
Assert.True(await reconciler.ReconcileAsync(source, "Movies", CancellationToken.None));
|
||||
var folder = await store.Entries.GetByPathAsync(source.Id, "Movies");
|
||||
var child = await store.Entries.GetByPathAsync(source.Id, @"Movies\b.txt");
|
||||
Assert.Equal(EntryStatus.Deleted, folder!.Status);
|
||||
Assert.Equal(EntryStatus.Deleted, child!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_file()
|
||||
{
|
||||
|
||||
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