Cut over the window to Explorer.Host.exe so only the host writes the index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 17:39:13 +02:00
parent 7c23bc2474
commit 48d03f794f
26 changed files with 774 additions and 59 deletions

View File

@@ -1,5 +1,7 @@
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using Explorer.Hosting;
using Explorer.Hosting.Ipc;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -10,6 +12,7 @@ namespace Explorer.App;
public partial class App : System.Windows.Application public partial class App : System.Windows.Application
{ {
private IHost? _host; private IHost? _host;
private WorkbenchPipeClient? _workbenchClient;
protected override async void OnStartup(StartupEventArgs e) protected override async void OnStartup(StartupEventArgs e)
{ {
@@ -29,9 +32,35 @@ public partial class App : System.Windows.Application
retainedFileCountLimit: 14) retainedFileCountLimit: 14)
.CreateLogger(); .CreateLogger();
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
TimeSpan.FromSeconds(12),
logger: null).ConfigureAwait(true);
var hostExe = HostLogonAutostart.FindHostExecutable();
if (_workbenchClient is null && hostExe is not null)
{
MessageBox.Show(
"Explorer.Host.exe is present but the window could not connect to it. See logs.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(-1);
return;
}
_host = Host.CreateDefaultBuilder() _host = Host.CreateDefaultBuilder()
.UseSerilog() .UseSerilog()
.ConfigureServices((_, services) => services.AddExplorer()) .ConfigureServices((_, services) =>
{
if (_workbenchClient is not null)
{
services.AddExplorerClient(_workbenchClient);
services.AddExplorerUi();
}
else
{
services.AddExplorer();
}
})
.Build(); .Build();
var vm = _host.Services.GetRequiredService<MainViewModel>(); var vm = _host.Services.GetRequiredService<MainViewModel>();
@@ -60,6 +89,11 @@ public partial class App : System.Windows.Application
_host.Dispose(); _host.Dispose();
} }
if (_workbenchClient is not null)
{
await _workbenchClient.DisposeAsync().ConfigureAwait(true);
}
Log.CloseAndFlush(); Log.CloseAndFlush();
base.OnExit(e); base.OnExit(e);
} }

View File

@@ -70,7 +70,7 @@
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6" <CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
Content="Start Explorer.Host.exe at Windows sign-in"/> Content="Start Explorer.Host.exe at Windows sign-in"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12" <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="Registers a per-user logon task. The window still owns indexing in this version. If the host starts while this window is open, it exits because the index is already in use."/> Text="Registers a per-user logon task. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write."/>
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/> <TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12" <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,6 +13,7 @@ public sealed class SourceManager
private readonly IAppEnvironment _env; private readonly IAppEnvironment _env;
private readonly IClock _clock; private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger; private readonly ILogger<SourceManager> _logger;
private readonly ISourceHost? _remote;
private readonly object _refreshLock = new(); private readonly object _refreshLock = new();
private Task<IReadOnlyList<Source>>? _refreshInFlight; private Task<IReadOnlyList<Source>>? _refreshInFlight;
@@ -23,18 +25,31 @@ public sealed class SourceManager
IVolumeService volumes, IVolumeService volumes,
IAppEnvironment env, IAppEnvironment env,
IClock clock, IClock clock,
ILogger<SourceManager> logger) ILogger<SourceManager> logger,
ISourceHost? remote = null)
{ {
_store = store; _store = store;
_volumes = volumes; _volumes = volumes;
_env = env; _env = env;
_clock = clock; _clock = clock;
_logger = logger; _logger = logger;
_remote = remote;
} }
public async Task InitializeAsync(CancellationToken cancellationToken = default) public async Task InitializeAsync(CancellationToken cancellationToken = default)
{ {
await _store.OpenAsync(cancellationToken).ConfigureAwait(false); await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
InvalidateRefreshCache();
return;
}
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false); await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false); await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false); await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
@@ -70,6 +85,22 @@ public sealed class SourceManager
private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken) private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken)
{ {
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
var snapshot = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
lock (_refreshLock)
{
_refreshCacheTimestamp = Stopwatch.GetTimestamp();
}
return snapshot;
}
var started = Stopwatch.GetTimestamp(); var started = Stopwatch.GetTimestamp();
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList(); var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes(); var online = _volumes.EnumerateOnlineVolumes();
@@ -201,6 +232,18 @@ public sealed class SourceManager
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default) public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
{ {
if (!_store.CanWrite)
{
if (_remote is null)
{
throw new InvalidOperationException("Cannot add a network location while the index is read-only.");
}
var added = await _remote.AddUncAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return added;
}
var root = PathRules.CanonicalUncRoot(path); var root = PathRules.CanonicalUncRoot(path);
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false); var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var fp = new VolumeFingerprint var fp = new VolumeFingerprint
@@ -269,6 +312,18 @@ public sealed class SourceManager
public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default) public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default)
{ {
if (!_store.CanWrite)
{
if (_remote is null)
{
return false;
}
var forgotten = await _remote.ForgetAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return forgotten;
}
var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false); var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false);
if (source is null || !CanForget(source)) if (source is null || !CanForget(source))
{ {
@@ -324,6 +379,18 @@ public sealed class SourceManager
return null; return null;
} }
if (!_store.CanWrite)
{
if (_remote is null)
{
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
}
var ensured = await _remote.EnsureForPathAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return ensured;
}
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false); var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (existing is not null) if (existing is not null)
{ {

View File

@@ -1,15 +1,58 @@
using Explorer.Contracts; using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application; namespace Explorer.Application;
public sealed class WorkbenchHost : IWorkbenchHost public sealed class WorkbenchHost : IWorkbenchHost
{ {
public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers) public WorkbenchHost(IIndexingHost indexing, ITransferHost transfers, ISourceHost sources, IIndexMutations mutations)
{ {
Indexing = indexing; Indexing = indexing;
Transfers = transfers; Transfers = transfers;
Sources = sources;
Mutations = mutations;
} }
public IIndexingHost Indexing { get; } public IIndexingHost Indexing { get; }
public ITransferHost Transfers { get; } public ITransferHost Transfers { get; }
public ISourceHost Sources { get; }
public IIndexMutations Mutations { get; }
}
public sealed class LocalSourceHost : ISourceHost
{
private readonly SourceManager _sources;
public LocalSourceHost(SourceManager sources) => _sources = sources;
public Task RefreshAsync(CancellationToken cancellationToken = default)
=> _sources.RefreshOnlineStateAsync(forceRefresh: true, cancellationToken);
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> _sources.AddUncAsync(path, cancellationToken);
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> _sources.EnsureForPathAsync(path, cancellationToken);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> _sources.ForgetDisconnectedAsync(path, cancellationToken);
}
public sealed class LocalIndexMutations : IIndexMutations
{
private readonly IIndexStore _store;
public LocalIndexMutations(IIndexStore store) => _store = store;
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.UpsertAsync(profile, cancellationToken);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken);
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.UpsertAsync(profile, cancellationToken);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken);
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> _store.RenameBatches.CreateAsync(items, cancellationToken);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _store.RenameBatches.MarkUndoneAsync(id, cancellationToken);
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> _store.Hashes.EnqueueSizeCollisionsAsync(sourceId, cancellationToken);
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _store.Relations.UpsertAsync(relation, cancellationToken);
} }

View File

@@ -6,6 +6,8 @@ public interface IWorkbenchHost
{ {
IIndexingHost Indexing { get; } IIndexingHost Indexing { get; }
ITransferHost Transfers { get; } ITransferHost Transfers { get; }
ISourceHost Sources { get; }
IIndexMutations Mutations { get; }
} }
public interface IIndexingHost public interface IIndexingHost
@@ -33,4 +35,42 @@ public interface ITransferHost
void ClearFinished(); void ClearFinished();
bool MoveUp(long jobId); bool MoveUp(long jobId);
bool MoveDown(long jobId); bool MoveDown(long jobId);
Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
public interface ISourceHost
{
Task RefreshAsync(CancellationToken cancellationToken = default);
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
}
public interface IIndexMutations
{
Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default);
Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default);
Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default);
Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default);
Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default);
Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default);
} }

View File

@@ -20,6 +20,8 @@ public interface IIndexStore
ISyncProfileStore SyncProfiles { get; } ISyncProfileStore SyncProfiles { get; }
IOperationProfileStore OperationProfiles { get; } IOperationProfileStore OperationProfiles { get; }
bool CanWrite { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default); Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default); Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
} }

View File

@@ -1,16 +1,16 @@
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
namespace Explorer.FileOperations; namespace Explorer.FileOperations;
public sealed class FileOperationService public sealed class FileOperationService
{ {
private readonly TransferQueue _queue; private readonly ITransferHost _queue;
private readonly IShellFileOperations _shell; private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator; private readonly IFileSystemEnumerator _enumerator;
public FileOperationService(TransferQueue queue, IShellFileOperations shell, IFileSystemEnumerator enumerator) public FileOperationService(ITransferHost queue, IShellFileOperations shell, IFileSystemEnumerator enumerator)
{ {
_queue = queue; _queue = queue;
_shell = shell; _shell = shell;

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -9,6 +10,7 @@ public sealed class FolderSyncService
{ {
private readonly FolderSyncPlanner _planner; private readonly FolderSyncPlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
private readonly IVolumeService _volumes; private readonly IVolumeService _volumes;
@@ -20,6 +22,7 @@ public sealed class FolderSyncService
public FolderSyncService( public FolderSyncService(
FolderSyncPlanner planner, FolderSyncPlanner planner,
IIndexStore store, IIndexStore store,
IIndexMutations mutations,
SourceManager sources, SourceManager sources,
FileOperationService ops, FileOperationService ops,
IVolumeService volumes, IVolumeService volumes,
@@ -28,6 +31,7 @@ public sealed class FolderSyncService
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_sources = sources; _sources = sources;
_ops = ops; _ops = ops;
_volumes = volumes; _volumes = volumes;
@@ -46,12 +50,12 @@ public sealed class FolderSyncService
} }
AttachVolumeGuids(profile); AttachVolumeGuids(profile);
profile.Id = await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); profile.Id = await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id; return profile.Id;
} }
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken); => _mutations.DeleteSyncProfileAsync(id, cancellationToken);
public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default) public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{ {
@@ -97,7 +101,7 @@ public sealed class FolderSyncService
profile.LastStatus = deletes > 0 profile.LastStatus = deletes > 0
? $"Queued {copies} copy, {deletes} delete" ? $"Queued {copies} copy, {deletes} delete"
: $"Queued {copies} copy"; : $"Queued {copies} copy";
await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); await _mutations.UpsertSyncProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
@@ -171,7 +175,7 @@ public sealed class FolderSyncService
return; return;
} }
await _store.Relations.UpsertAsync(new FileRelation await _mutations.UpsertRelationAsync(new FileRelation
{ {
LeftEntryId = left.Id, LeftEntryId = left.Id,
RightEntryId = right.Id, RightEntryId = right.Id,

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -9,6 +10,7 @@ public sealed class OperationProfileService
{ {
private readonly FileOperationProfilePlanner _planner; private readonly FileOperationProfilePlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
private readonly RenameBatchService _renames; private readonly RenameBatchService _renames;
private readonly IVolumeService _volumes; private readonly IVolumeService _volumes;
@@ -21,6 +23,7 @@ public sealed class OperationProfileService
public OperationProfileService( public OperationProfileService(
FileOperationProfilePlanner planner, FileOperationProfilePlanner planner,
IIndexStore store, IIndexStore store,
IIndexMutations mutations,
FileOperationService ops, FileOperationService ops,
RenameBatchService renames, RenameBatchService renames,
IVolumeService volumes, IVolumeService volumes,
@@ -31,6 +34,7 @@ public sealed class OperationProfileService
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_ops = ops; _ops = ops;
_renames = renames; _renames = renames;
_volumes = volumes; _volumes = volumes;
@@ -55,12 +59,12 @@ public sealed class OperationProfileService
AttachVolumeGuids(profile); AttachVolumeGuids(profile);
profile.AutoRun = profile.CanAutoRun; profile.AutoRun = profile.CanAutoRun;
profile.Id = await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); profile.Id = await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id; return profile.Id;
} }
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken); => _mutations.DeleteOperationProfileAsync(id, cancellationToken);
public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default) public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{ {
@@ -150,7 +154,7 @@ public sealed class OperationProfileService
var renamed = renames.Count; var renamed = renames.Count;
profile.LastRunUtc = DateTimeOffset.UtcNow; profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename"; profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename";
await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false); await _mutations.UpsertOperationProfileAsync(profile, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
@@ -206,7 +210,7 @@ public sealed class OperationProfileService
} }
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
await _store.OperationProfiles.UpsertAsync(new OperationProfile await _mutations.UpsertOperationProfileAsync(new OperationProfile
{ {
Name = "Archive folder", Name = "Archive folder",
RequireGitClean = true, RequireGitClean = true,
@@ -216,7 +220,7 @@ public sealed class OperationProfileService
IsBuiltIn = true, IsBuiltIn = true,
CreatedUtc = now CreatedUtc = now
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
await _store.OperationProfiles.UpsertAsync(new OperationProfile await _mutations.UpsertOperationProfileAsync(new OperationProfile
{ {
Name = "Copy to destination", Name = "Copy to destination",
DoCopy = true, DoCopy = true,

View File

@@ -1,4 +1,5 @@
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -8,12 +9,14 @@ public sealed class RenameBatchService
{ {
private readonly RenamePlanner _planner; private readonly RenamePlanner _planner;
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly IIndexMutations _mutations;
private readonly FileOperationService _ops; private readonly FileOperationService _ops;
public RenameBatchService(RenamePlanner planner, IIndexStore store, FileOperationService ops) public RenameBatchService(RenamePlanner planner, IIndexStore store, IIndexMutations mutations, FileOperationService ops)
{ {
_planner = planner; _planner = planner;
_store = store; _store = store;
_mutations = mutations;
_ops = ops; _ops = ops;
} }
@@ -36,7 +39,7 @@ public sealed class RenameBatchService
var items = plan.Operations var items = plan.Operations
.Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i)) .Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i))
.ToList(); .ToList();
var batchId = await _store.RenameBatches.CreateAsync(items, cancellationToken).ConfigureAwait(false); var batchId = await _mutations.CreateRenameBatchAsync(items, cancellationToken).ConfigureAwait(false);
await _ops.EnqueueRenameAsync( await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken) cancellationToken)
@@ -63,7 +66,7 @@ public sealed class RenameBatchService
{ {
if (!plan.HasErrors) if (!plan.HasErrors)
{ {
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
} }
return plan; return plan;
@@ -73,7 +76,7 @@ public sealed class RenameBatchService
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(), plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false); await _mutations.MarkRenameBatchUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
return plan; return plan;
} }
} }

View File

@@ -23,6 +23,14 @@ namespace Explorer.Hosting;
public static class ExplorerHostServices public static class ExplorerHostServices
{ {
public static IServiceCollection AddExplorerCore(this IServiceCollection services) public static IServiceCollection AddExplorerCore(this IServiceCollection services)
{
services.AddExplorerShared(readOnly: false);
services.AddExplorerWorkers();
services.AddExplorerOperations();
return services;
}
public static IServiceCollection AddExplorerShared(this IServiceCollection services, bool readOnly)
{ {
services.TryAddSingleton<IClock, SystemClock>(); services.TryAddSingleton<IClock, SystemClock>();
services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>(); services.TryAddSingleton<IAppEnvironment, WindowsAppEnvironment>();
@@ -34,7 +42,7 @@ public static class ExplorerHostServices
{ {
var env = sp.GetRequiredService<IAppEnvironment>(); var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>(); var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger); return new SqliteIndexStore(env.DatabasePath, logger, readOnly);
}); });
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>(); services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>(); services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
@@ -47,7 +55,13 @@ public static class ExplorerHostServices
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>()); services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>(); services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>(); services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>(); services.AddSingleton(sp => new SourceManager(
sp.GetRequiredService<IIndexStore>(),
sp.GetRequiredService<IVolumeService>(),
sp.GetRequiredService<IAppEnvironment>(),
sp.GetRequiredService<IClock>(),
sp.GetRequiredService<ILogger<SourceManager>>(),
sp.GetService<ISourceHost>()));
services.AddSingleton<PathHistoryStore>(); services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>(); services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>(); services.AddSingleton<UiPreferencesStore>();
@@ -57,24 +71,26 @@ public static class ExplorerHostServices
services.AddSingleton<FilesystemScanner>(); services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>(); services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>(); services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>(); services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>(); services.AddSingleton<AnalysisService>();
services.AddSingleton<RenamePlanner>();
return services;
}
public static IServiceCollection AddExplorerWorkers(this IServiceCollection services)
{
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>(); services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>(); services.AddSingleton<TransferQueue>();
services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>()); services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>());
services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>()); services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>());
services.AddSingleton<IWorkbenchHost, WorkbenchHost>(); services.AddSingleton<IIndexMutations>(sp => new LocalIndexMutations(sp.GetRequiredService<IIndexStore>()));
services.AddSingleton<FileOperationService>(); services.AddSingleton<IWorkbenchHost>(sp => new WorkbenchHost(
services.AddSingleton<RenamePlanner>(); sp.GetRequiredService<IIndexingHost>(),
services.AddSingleton<RenameBatchService>(); sp.GetRequiredService<ITransferHost>(),
services.AddSingleton<FolderSyncPlanner>(); new LocalSourceHost(sp.GetRequiredService<SourceManager>()),
services.AddSingleton<FolderSyncService>(); sp.GetRequiredService<IIndexMutations>()));
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<DuplicateHashWorker>(); services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>(); services.AddSingleton<HistoryRollupService>();
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>()); services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
@@ -85,10 +101,32 @@ public static class ExplorerHostServices
return services; return services;
} }
/// <summary> public static IServiceCollection AddExplorerOperations(this IServiceCollection services)
/// Registers the background host process: open the store first, then Core workers, then the named pipe. {
/// Do not call this from the GUI while the GUI still opens the index for write. services.AddSingleton<FileOperationService>();
/// </summary> services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
return services;
}
public static IServiceCollection AddExplorerClient(this IServiceCollection services, IWorkbenchHost workbench)
{
services.AddSingleton(workbench);
services.AddSingleton(workbench.Indexing);
services.AddSingleton(workbench.Transfers);
services.AddSingleton(workbench.Sources);
services.AddSingleton(workbench.Mutations);
services.AddExplorerShared(readOnly: true);
services.AddExplorerOperations();
services.AddHostedService<IndexStoreLifetime>();
return services;
}
public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services) public static IServiceCollection AddExplorerHostProcess(this IServiceCollection services)
{ {
services.TryAddSingleton<WorkbenchIpcOptions>(); services.TryAddSingleton<WorkbenchIpcOptions>();

View File

@@ -1,3 +1,4 @@
using Explorer.Application;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -6,10 +7,19 @@ namespace Explorer.Hosting;
public sealed class IndexStoreLifetime : IHostedService public sealed class IndexStoreLifetime : IHostedService
{ {
private readonly IIndexStore _store; private readonly IIndexStore _store;
private readonly SourceManager _sources;
public IndexStoreLifetime(IIndexStore store) => _store = store; public IndexStoreLifetime(IIndexStore store, SourceManager sources)
{
_store = store;
_sources = sources;
}
public Task StartAsync(CancellationToken cancellationToken) => _store.OpenAsync(cancellationToken); public async Task StartAsync(CancellationToken cancellationToken)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync(); public Task StopAsync(CancellationToken cancellationToken) => _store.CloseAsync();
} }

View File

@@ -42,7 +42,11 @@ internal sealed class IpcEnvelope
public string? S { get; set; } public string? S { get; set; }
public bool? Flag { get; set; } public bool? Flag { get; set; }
public bool? Paused { get; set; } public bool? Paused { get; set; }
public string? Dest { get; set; }
public string[]? Paths { get; set; }
public string? Payload { get; set; }
public ScanProgress? Progress { get; set; } public ScanProgress? Progress { get; set; }
public TransferJob? Job { get; set; } public TransferJob? Job { get; set; }
public TransferJob[]? Jobs { get; set; } public TransferJob[]? Jobs { get; set; }
public Source? Source { get; set; }
} }

View File

@@ -18,6 +18,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
private readonly Task _readLoop; private readonly Task _readLoop;
private readonly IndexingProxy _indexing; private readonly IndexingProxy _indexing;
private readonly TransferProxy _transfers; private readonly TransferProxy _transfers;
private readonly SourceProxy _sources;
private readonly MutationProxy _mutations;
private WorkbenchPipeClient(NamedPipeClientStream pipe) private WorkbenchPipeClient(NamedPipeClientStream pipe)
{ {
@@ -26,11 +28,15 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
_reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); _reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
_indexing = new IndexingProxy(this); _indexing = new IndexingProxy(this);
_transfers = new TransferProxy(this); _transfers = new TransferProxy(this);
_sources = new SourceProxy(this);
_mutations = new MutationProxy(this);
_readLoop = ReadLoopAsync(_cts.Token); _readLoop = ReadLoopAsync(_cts.Token);
} }
public IIndexingHost Indexing => _indexing; public IIndexingHost Indexing => _indexing;
public ITransferHost Transfers => _transfers; public ITransferHost Transfers => _transfers;
public ISourceHost Sources => _sources;
public IIndexMutations Mutations => _mutations;
public static async Task<WorkbenchPipeClient> ConnectAsync( public static async Task<WorkbenchPipeClient> ConnectAsync(
WorkbenchIpcOptions options, WorkbenchIpcOptions options,
@@ -86,12 +92,27 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
string op, string op,
CancellationToken cancellationToken, CancellationToken cancellationToken,
long? n = null, long? n = null,
string? s = null) string? s = null,
string? dest = null,
string[]? paths = null,
bool? flag = null,
string? payload = null)
{ {
var id = Guid.NewGuid().ToString("N"); var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = tcs; _pending[id] = tcs;
var request = new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Id = id, Op = op, N = n, S = s }; var request = new IpcEnvelope
{
V = WorkbenchIpc.ProtocolVersion,
Id = id,
Op = op,
N = n,
S = s,
Dest = dest,
Paths = paths,
Flag = flag,
Payload = payload
};
var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json); var json = JsonSerializer.Serialize(request, WorkbenchIpc.Json);
await _send.WaitAsync(cancellationToken).ConfigureAwait(false); await _send.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
@@ -270,7 +291,69 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, IAsyncDisposable
public void ClearFinished() => _client.Call("Transfers.ClearFinished"); public void ClearFinished() => _client.Call("Transfers.ClearFinished");
public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true; public bool MoveUp(long jobId) => _client.Call("Transfers.MoveUp", jobId).Flag == true;
public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true; public bool MoveDown(long jobId) => _client.Call("Transfers.MoveDown", jobId).Flag == true;
public Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCopy", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueMove", cancellationToken, dest: destinationDirectory, paths: sources.ToArray());
public Task EnqueueDeleteAsync(IReadOnlyList<string> paths, bool permanent = false, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueDelete", cancellationToken, paths: paths.ToArray(), flag: permanent);
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueRename", cancellationToken, s: path, dest: newName);
public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueEmptyRecycleBin", cancellationToken);
public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueExtract", cancellationToken, s: archivePath, dest: destinationDirectory);
public Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueCompress", cancellationToken, dest: archivePath, paths: sources.ToArray());
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueAddToArchive", cancellationToken, dest: archivePath, paths: sources.ToArray());
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _client.CallAsync("Transfers.EnqueueVerifyArchive", cancellationToken, s: archivePath);
public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); public void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job); public void RaiseFinished(TransferJob job) => JobFinished?.Invoke(this, job);
} }
private sealed class SourceProxy : ISourceHost
{
private readonly WorkbenchPipeClient _client;
public SourceProxy(WorkbenchPipeClient client) => _client = client;
public Task RefreshAsync(CancellationToken cancellationToken = default)
=> _client.CallAsync("Sources.Refresh", cancellationToken);
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Sources.AddUnc", cancellationToken, s: path).ConfigureAwait(false)).Source
?? throw new InvalidOperationException("Host did not return a source.");
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
}
private sealed class MutationProxy : IIndexMutations
{
private readonly WorkbenchPipeClient _client;
public MutationProxy(WorkbenchPipeClient client) => _client = client;
public async Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.UpsertSyncProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.DeleteSyncProfile", cancellationToken, n: id);
public async Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.UpsertOperationProfile", cancellationToken, payload: Json(profile)).ConfigureAwait(false)).N ?? 0;
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.DeleteOperationProfile", cancellationToken, n: id);
public async Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> (await _client.CallAsync("Mutations.CreateRenameBatch", cancellationToken, payload: Json(items)).ConfigureAwait(false)).N ?? 0;
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.MarkRenameBatchUndone", cancellationToken, n: id);
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.EnqueueHashCollisions", cancellationToken, n: sourceId ?? 0);
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _client.CallAsync("Mutations.UpsertRelation", cancellationToken, payload: Json(relation));
private static string Json<T>(T value) => JsonSerializer.Serialize(value, WorkbenchIpc.Json);
}
} }

View File

@@ -126,7 +126,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
continue; continue;
} }
var response = Handle(request); var response = await HandleAsync(request).ConfigureAwait(false);
await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false); await WriteAsync(writer, response, stoppingToken).ConfigureAwait(false);
} }
} }
@@ -139,6 +139,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
} }
internal IpcEnvelope Handle(IpcEnvelope request) internal IpcEnvelope Handle(IpcEnvelope request)
=> HandleAsync(request).GetAwaiter().GetResult();
internal async Task<IpcEnvelope> HandleAsync(IpcEnvelope request)
{ {
var reply = new IpcEnvelope { Id = request.Id, Ok = true }; var reply = new IpcEnvelope { Id = request.Id, Ok = true };
if (request.V != WorkbenchIpc.ProtocolVersion) if (request.V != WorkbenchIpc.ProtocolVersion)
@@ -203,6 +206,69 @@ public sealed class WorkbenchPipeServer : BackgroundService
case "Transfers.MoveDown": case "Transfers.MoveDown":
reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0); reply.Flag = _workbench.Transfers.MoveDown(request.N ?? 0);
return reply; return reply;
case "Transfers.EnqueueCopy":
await _workbench.Transfers.EnqueueCopyAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueMove":
await _workbench.Transfers.EnqueueMoveAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueDelete":
await _workbench.Transfers.EnqueueDeleteAsync(request.Paths ?? [], request.Flag == true).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueRename":
await _workbench.Transfers.EnqueueRenameAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueEmptyRecycleBin":
await _workbench.Transfers.EnqueueEmptyRecycleBinAsync().ConfigureAwait(false);
return reply;
case "Transfers.EnqueueExtract":
await _workbench.Transfers.EnqueueExtractAsync(request.S ?? "", request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueCompress":
await _workbench.Transfers.EnqueueCompressAsync(request.Paths ?? [], request.Dest ?? "").ConfigureAwait(false);
return reply;
case "Transfers.EnqueueAddToArchive":
await _workbench.Transfers.EnqueueAddToArchiveAsync(request.Dest ?? "", request.Paths ?? []).ConfigureAwait(false);
return reply;
case "Transfers.EnqueueVerifyArchive":
await _workbench.Transfers.EnqueueVerifyArchiveAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.Refresh":
await _workbench.Sources.RefreshAsync().ConfigureAwait(false);
return reply;
case "Sources.AddUnc":
reply.Source = await _workbench.Sources.AddUncAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.EnsureForPath":
reply.Source = await _workbench.Sources.EnsureForPathAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Sources.Forget":
reply.Flag = await _workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
return reply;
case "Mutations.UpsertSyncProfile":
reply.N = await _workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
return reply;
case "Mutations.DeleteSyncProfile":
await _workbench.Mutations.DeleteSyncProfileAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.UpsertOperationProfile":
reply.N = await _workbench.Mutations.UpsertOperationProfileAsync(Read<OperationProfile>(request.Payload)).ConfigureAwait(false);
return reply;
case "Mutations.DeleteOperationProfile":
await _workbench.Mutations.DeleteOperationProfileAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.CreateRenameBatch":
reply.N = await _workbench.Mutations.CreateRenameBatchAsync(Read<RenameBatchItem[]>(request.Payload) ?? []).ConfigureAwait(false);
return reply;
case "Mutations.MarkRenameBatchUndone":
await _workbench.Mutations.MarkRenameBatchUndoneAsync(request.N ?? 0).ConfigureAwait(false);
return reply;
case "Mutations.EnqueueHashCollisions":
await _workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
return reply;
case "Mutations.UpsertRelation":
await _workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
return reply;
default: default:
reply.Ok = false; reply.Ok = false;
reply.Error = "Unknown op " + request.Op; reply.Error = "Unknown op " + request.Op;
@@ -217,6 +283,10 @@ public sealed class WorkbenchPipeServer : BackgroundService
} }
} }
private static T Read<T>(string? payload)
=> JsonSerializer.Deserialize<T>(payload ?? "null", WorkbenchIpc.Json)
?? throw new InvalidOperationException("Missing payload for " + typeof(T).Name);
private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken) private async Task WriteAsync(StreamWriter writer, IpcEnvelope envelope, CancellationToken cancellationToken)
{ {
var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json); var json = JsonSerializer.Serialize(envelope, WorkbenchIpc.Json);

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using Explorer.Hosting.Ipc;
using Microsoft.Extensions.Logging;
namespace Explorer.Hosting;
public static class WorkbenchHostConnector
{
public static async Task<WorkbenchPipeClient?> ConnectOrStartAsync(
TimeSpan timeout,
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var options = new WorkbenchIpcOptions();
try
{
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(1), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogDebug(ex, "Background host was not listening yet");
}
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
logger?.LogInformation("Explorer.Host.exe is not beside the window; using in-process core");
return null;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(exe)
});
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Could not start Explorer.Host.exe");
return null;
}
try
{
return await WorkbenchPipeClient.ConnectAsync(options, timeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
return null;
}
}
}

View File

@@ -3,14 +3,14 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis; using Explorer.Analysis;
using Explorer.Application; using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Presentation.ViewModels; namespace Explorer.Presentation.ViewModels;
public sealed partial class DuplicateViewModel : ObservableObject public sealed partial class DuplicateViewModel : ObservableObject
{ {
private readonly IIndexStore _store; private readonly IIndexMutations _mutations;
private readonly SourceManager _sources; private readonly SourceManager _sources;
private readonly AnalysisService _analysis; private readonly AnalysisService _analysis;
@@ -20,9 +20,9 @@ public sealed partial class DuplicateViewModel : ObservableObject
[ObservableProperty] private bool _showIntentional; [ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks; [ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis) public DuplicateViewModel(IIndexMutations mutations, SourceManager sources, AnalysisService analysis)
{ {
_store = store; _mutations = mutations;
_sources = sources; _sources = sources;
_analysis = analysis; _analysis = analysis;
Groups = []; Groups = [];
@@ -50,7 +50,7 @@ public sealed partial class DuplicateViewModel : ObservableObject
{ {
var groups = await Task.Run(async () => var groups = await Task.Run(async () =>
{ {
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false); await _mutations.EnqueueHashCollisionsAsync(null).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false); var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id); var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
return classified return classified

View File

@@ -66,7 +66,7 @@ public sealed partial class MainViewModel : ObservableObject
SourceManager sources, SourceManager sources,
SearchService search, SearchService search,
AnalysisService analysis, AnalysisService analysis,
IIndexStore store, IIndexMutations mutations,
IWorkbenchHost workbench, IWorkbenchHost workbench,
IOsClipboard clipboard, IOsClipboard clipboard,
PathHistoryStore pathHistory, PathHistoryStore pathHistory,
@@ -109,7 +109,7 @@ public sealed partial class MainViewModel : ObservableObject
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences); Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources, volumes); Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis); Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources, analysis); Duplicates = new DuplicateViewModel(mutations, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path); Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(workbench.Transfers, preferences); Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
Tabs = []; Tabs = [];

View File

@@ -10,6 +10,7 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
{ {
private readonly string _path; private readonly string _path;
private readonly ILogger<SqliteIndexStore> _logger; private readonly ILogger<SqliteIndexStore> _logger;
private readonly bool _readOnly;
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly AsyncLocal<int> _writeDepth = new(); private readonly AsyncLocal<int> _writeDepth = new();
private SqliteConnection? _write; private SqliteConnection? _write;
@@ -17,10 +18,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
private bool _opened; private bool _opened;
private int _analysisIndexesReady; private int _analysisIndexesReady;
public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> logger) public SqliteIndexStore(string databasePath, ILogger<SqliteIndexStore> logger, bool readOnly = false)
{ {
_path = databasePath; _path = databasePath;
_logger = logger; _logger = logger;
_readOnly = readOnly;
Sources = new SourceStore(this); Sources = new SourceStore(this);
Entries = new EntryStore(this); Entries = new EntryStore(this);
Excludes = new ExcludeStore(this); Excludes = new ExcludeStore(this);
@@ -50,6 +52,8 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public ISyncProfileStore SyncProfiles { get; } public ISyncProfileStore SyncProfiles { get; }
public IOperationProfileStore OperationProfiles { get; } public IOperationProfileStore OperationProfiles { get; }
public bool CanWrite => !_readOnly;
internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open."); internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open.");
public async Task OpenAsync(CancellationToken cancellationToken = default) public async Task OpenAsync(CancellationToken cancellationToken = default)
@@ -59,6 +63,12 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return; return;
} }
if (_readOnly)
{
await OpenReadOnlyAsync(cancellationToken).ConfigureAwait(false);
return;
}
_lock = IndexStoreLock.Acquire(_path); _lock = IndexStoreLock.Acquire(_path);
try try
{ {
@@ -84,6 +94,48 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
} }
} }
private async Task OpenReadOnlyAsync(CancellationToken cancellationToken)
{
DapperSetup.Ensure();
Exception? last = null;
for (var attempt = 0; attempt < 40; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!File.Exists(_path))
{
throw new InvalidOperationException("Index database is not ready yet.");
}
_write = new SqliteConnection(BuildConnectionString(_path, readOnly: true));
await _write.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(_write);
if (IndexExists(_write, "ix_entries_dir_agg_all"))
{
Volatile.Write(ref _analysisIndexesReady, 1);
}
_opened = true;
_logger.LogInformation("Opened index database read-only at {Path}", _path);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
last = ex;
if (_write is not null)
{
await _write.DisposeAsync().ConfigureAwait(false);
_write = null;
}
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
}
}
throw new InvalidOperationException("Could not open the index for read. Is Explorer.Host.exe running?", last);
}
public async Task CloseAsync() public async Task CloseAsync()
{ {
try try
@@ -114,6 +166,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public async Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default) public async Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default)
{ {
if (_readOnly)
{
throw new InvalidOperationException("Index store is open read-only.");
}
await EnterWriteAsync(cancellationToken).ConfigureAwait(false); await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
var outermost = _writeDepth.Value == 1; var outermost = _writeDepth.Value == 1;
SqliteTransaction? tx = null; SqliteTransaction? tx = null;
@@ -165,6 +222,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
throw new InvalidOperationException("Store is not open."); throw new InvalidOperationException("Store is not open.");
} }
if (_readOnly)
{
throw new InvalidOperationException("Index store is open read-only.");
}
await EnterWriteAsync(cancellationToken).ConfigureAwait(false); await EnterWriteAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -211,17 +273,17 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
internal async Task<SqliteConnection> OpenReadAsync(CancellationToken cancellationToken) internal async Task<SqliteConnection> OpenReadAsync(CancellationToken cancellationToken)
{ {
var conn = new SqliteConnection(BuildConnectionString(_path)); var conn = new SqliteConnection(BuildConnectionString(_path, _readOnly));
await conn.OpenAsync(cancellationToken).ConfigureAwait(false); await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
ApplyReadPragmas(conn); ApplyReadPragmas(conn);
return conn; return conn;
} }
internal static string BuildConnectionString(string path) internal static string BuildConnectionString(string path, bool readOnly = false)
=> new SqliteConnectionStringBuilder => new SqliteConnectionStringBuilder
{ {
DataSource = path, DataSource = path,
Mode = SqliteOpenMode.ReadWriteCreate, Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate,
Pooling = false Pooling = false
}.ToString(); }.ToString();
@@ -249,6 +311,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
if (_readOnly)
{
return Task.CompletedTask;
}
return WriteAsync(conn => return WriteAsync(conn =>
{ {
EnsureAnalysisIndexes(conn); EnsureAnalysisIndexes(conn);

View File

@@ -11,9 +11,13 @@ public class WorkbenchHostTests
{ {
var indexing = new FakeIndexing(); var indexing = new FakeIndexing();
var transfers = new FakeTransfers(); var transfers = new FakeTransfers();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers); var sources = new StubSources();
var mutations = new StubMutations();
IWorkbenchHost host = new WorkbenchHost(indexing, transfers, sources, mutations);
Assert.Same(indexing, host.Indexing); Assert.Same(indexing, host.Indexing);
Assert.Same(transfers, host.Transfers); Assert.Same(transfers, host.Transfers);
Assert.Same(sources, host.Sources);
Assert.Same(mutations, host.Mutations);
host.Indexing.EnqueueFullScan(7); host.Indexing.EnqueueFullScan(7);
host.Transfers.PauseAll(); host.Transfers.PauseAll();
@@ -57,4 +61,30 @@ public class WorkbenchHostTests
public bool MoveUp(long jobId) => false; public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false; public bool MoveDown(long jobId) => false;
} }
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
} }

View File

@@ -109,12 +109,14 @@ public class FolderSyncServiceTests
store, store,
volumes, volumes,
NullLogger<TransferQueue>.Instance); NullLogger<TransferQueue>.Instance);
var mutations = new LocalIndexMutations(store);
var ops = new FileOperationService(queue, shell, enumerator); var ops = new FileOperationService(queue, shell, enumerator);
var env = new SyncEnv(root); var env = new SyncEnv(root);
var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
var sync = new FolderSyncService( var sync = new FolderSyncService(
new FolderSyncPlanner(), new FolderSyncPlanner(),
store, store,
mutations,
sources, sources,
ops, ops,
volumes, volumes,

View File

@@ -107,10 +107,12 @@ public class OperationProfileServiceTests
var ops = new FileOperationService(queue, shell, enumerator); var ops = new FileOperationService(queue, shell, enumerator);
var git = new StubGit(); var git = new StubGit();
var planner = new FileOperationProfilePlanner(new RenamePlanner()); var planner = new FileOperationProfilePlanner(new RenamePlanner());
var renames = new RenameBatchService(new RenamePlanner(), store, ops); var mutations = new LocalIndexMutations(store);
var renames = new RenameBatchService(new RenamePlanner(), store, mutations, ops);
var profiles = new OperationProfileService( var profiles = new OperationProfileService(
planner, planner,
store, store,
mutations,
ops, ops,
renames, renames,
volumes, volumes,

View File

@@ -316,7 +316,7 @@ public class TransferQueueTests
{ {
await using var ctx = await Harness.CreateAsync(); await using var ctx = await Harness.CreateAsync();
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum()); var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, ops); var batches = new RenameBatchService(new RenamePlanner(), ctx.Store, new LocalIndexMutations(ctx.Store), ops);
await ctx.Queue.StartAsync(CancellationToken.None); await ctx.Queue.StartAsync(CancellationToken.None);
var subjects = new[] var subjects = new[]
{ {

View File

@@ -3,6 +3,7 @@ using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Explorer.Hosting; using Explorer.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Explorer.Hosting.Tests; namespace Explorer.Hosting.Tests;
@@ -23,6 +24,7 @@ public class CoreRegistrationTests
Assert.NotNull(sp.GetService<IWorkbenchHost>()); Assert.NotNull(sp.GetService<IWorkbenchHost>());
Assert.NotNull(sp.GetService<IIndexingHost>()); Assert.NotNull(sp.GetService<IIndexingHost>());
Assert.NotNull(sp.GetService<ITransferHost>()); Assert.NotNull(sp.GetService<ITransferHost>());
Assert.NotNull(sp.GetService<IIndexMutations>());
Assert.Null(sp.GetService<IOsClipboard>()); Assert.Null(sp.GetService<IOsClipboard>());
} }
finally finally
@@ -31,6 +33,35 @@ public class CoreRegistrationTests
} }
} }
[Fact]
public async Task AddExplorerClient_uses_read_only_store_without_indexing_workers()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-hosting", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var workbench = new StubWorkbench();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IAppEnvironment>(new TempEnv(dir));
services.AddExplorerClient(workbench);
await using var sp = services.BuildServiceProvider();
Assert.Same(workbench, sp.GetService<IWorkbenchHost>());
Assert.Same(workbench.Indexing, sp.GetService<IIndexingHost>());
Assert.Same(workbench.Transfers, sp.GetService<ITransferHost>());
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
var hosted = sp.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is IndexStoreLifetime);
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService");
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
private sealed class TempEnv : IAppEnvironment private sealed class TempEnv : IAppEnvironment
{ {
public TempEnv(string dir) public TempEnv(string dir)
@@ -45,4 +76,65 @@ public class CoreRegistrationTests
public string DatabasePath { get; } public string DatabasePath { get; }
public string LogDirectory { get; } public string LogDirectory { get; }
} }
private sealed class StubWorkbench : IWorkbenchHost
{
public IIndexingHost Indexing { get; } = new StubIndexing();
public ITransferHost Transfers { get; } = new StubTransfers();
public ISourceHost Sources { get; } = new StubSources();
public IIndexMutations Mutations { get; } = new StubMutations();
}
private sealed class StubIndexing : IIndexingHost
{
public event EventHandler<ScanProgress>? ProgressChanged = delegate { };
public void EnqueueFullScan(long sourceId) { }
public void EnqueueFolderScan(long sourceId, string pathRel) { }
public void EnqueueReconcile(long sourceId, string pathRel) { }
public void Cancel(long sourceId) { }
}
private sealed class StubTransfers : ITransferHost
{
public event EventHandler? Changed = delegate { };
public event EventHandler<TransferJob>? JobFinished = delegate { };
public bool IsPaused => false;
public IReadOnlyList<TransferJob> Snapshot() => [];
public void PauseAll() { }
public void ResumeAll() { }
public void Pause(long jobId) { }
public void Resume(long jobId) { }
public void Retry(long jobId) { }
public void Cancel(long jobId) { }
public void Dismiss(long jobId) { }
public void ClearFinished() { }
public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false;
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
} }

View File

@@ -14,7 +14,7 @@ public class WorkbenchPipeTests
var indexing = new FakeIndexing(); var indexing = new FakeIndexing();
var transfers = new FakeTransfers(); var transfers = new FakeTransfers();
var server = new WorkbenchPipeServer( var server = new WorkbenchPipeServer(
new WorkbenchHost(indexing, transfers), new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()),
new WorkbenchIpcOptions { PipeName = "ew-test" }, new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance); NullLogger<WorkbenchPipeServer>.Instance);
@@ -31,13 +31,23 @@ public class WorkbenchPipeTests
var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" }); var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" });
Assert.True(paused.Paused); Assert.True(paused.Paused);
var copy = server.Handle(new IpcEnvelope
{
V = WorkbenchIpc.ProtocolVersion,
Op = "Transfers.EnqueueCopy",
Paths = ["C:\\a.txt"],
Dest = "D:\\"
});
Assert.True(copy.Ok);
Assert.Equal(@"D:\", transfers.CopiedDest);
} }
[Fact] [Fact]
public void Handle_rejects_other_protocol_versions() public void Handle_rejects_other_protocol_versions()
{ {
var server = new WorkbenchPipeServer( var server = new WorkbenchPipeServer(
new WorkbenchHost(new FakeIndexing(), new FakeTransfers()), new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()),
new WorkbenchIpcOptions(), new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance); NullLogger<WorkbenchPipeServer>.Instance);
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" }); var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
@@ -72,5 +82,37 @@ public class WorkbenchPipeTests
public void ClearFinished() { } public void ClearFinished() { }
public bool MoveUp(long jobId) => false; public bool MoveUp(long jobId) => false;
public bool MoveDown(long jobId) => false; public bool MoveDown(long jobId) => false;
public string? CopiedDest { get; private set; }
public Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
{
CopiedDest = destinationDirectory;
return Task.CompletedTask;
}
}
private sealed class StubSources : ISourceHost
{
public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(new Source { StableKey = "x", DisplayName = path });
public Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<Source?>(null);
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
private sealed class StubMutations : IIndexMutations
{
public Task<long> UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> CreateRenameBatchAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> Task.FromResult(1L);
public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask;
} }
} }

View File

@@ -451,4 +451,24 @@ public class IndexStoreLockTests
Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path)); Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path));
Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path)); Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path));
} }
[Fact]
public async Task Read_only_store_opens_while_writer_holds_the_mutex()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
await writer.AddSourceAsync(@"C:\data");
await using var reader = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance, readOnly: true);
await reader.OpenAsync();
Assert.False(reader.CanWrite);
var sources = await reader.Sources.GetAllAsync();
Assert.Single(sources);
Assert.Equal(@"C:\data", sources[0].LastRootPath);
var write = await Assert.ThrowsAsync<InvalidOperationException>(
() => reader.RunWriteAsync(_ => Task.CompletedTask));
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
}
} }