Add settings, cloud places, and optional archive-content indexing.

Keep official clients in charge of sync while Explorer can group locations, persist UI prefs, and list zip/rar/7z members without extracting them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 12:16:09 +02:00
parent e9aba73552
commit 09a8cfafa3
57 changed files with 3130 additions and 119 deletions

View File

@@ -95,7 +95,9 @@ public class BrowseServiceTests
volumes,
store,
sources,
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
new CloudPlaceStore(env),
new UiPreferencesStore(env));
return (browse, store);
}
}

View File

@@ -1,6 +1,8 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Microsoft.Extensions.Logging.Abstractions;
@@ -104,6 +106,111 @@ public class CloudProviderTests
Assert.Equal("Dominique - Personal", merged[0].DisplayName);
Assert.Equal(@"C:\Users\x\OneDrive - Contoso", merged[1].Path);
}
[Fact]
public void Cloud_path_is_under_is_prefix_safe()
{
Assert.True(CloudPath.IsUnder(@"F:\OneDrive - wyniger", @"F:\OneDrive - wyniger\Obsidian\Notes"));
Assert.False(CloudPath.IsUnder(@"F:\OneDrive", @"F:\OneDrive - wyniger"));
Assert.True(CloudPath.IsUnder(@"J:\", @"J:\My Drive\Photos"));
Assert.Equal(@"J:\", CloudPath.NormalizePlace("j:"));
Assert.Equal(@"J:\", CloudPath.NormalizePlace(@"J:\"));
}
[Fact]
public void DriveFS_media_named_google_drive_becomes_a_place()
{
var places = DriveFsPreferenceReader.PlacesFromTables(
[
new("Games", @"D:\", 4),
new("Google Drive", @"J:\", 10),
new("MP3", @"G:\", 4)
],
[
new("My Drive", @"My Drive", @"J:\My Drive", true)
]);
Assert.Single(places);
Assert.Equal("googledrive", places[0].ProviderId);
Assert.Equal("Google Drive", places[0].DisplayName);
Assert.Equal(@"J:\", places[0].Path);
}
[Fact]
public void DriveFS_mirrored_folder_becomes_a_place_when_no_mount()
{
var places = DriveFsPreferenceReader.PlacesFromTables(
[new("Data", @"F:\", 4)],
[new("Photos", @"Photos", @"F:\Google Photos", false)]);
Assert.Single(places);
Assert.Equal("Google Drive - Photos", places[0].DisplayName);
Assert.Equal(@"F:\Google Photos", places[0].Path);
}
[Fact]
public void DriveFS_local_preference_db_exposes_configured_mount()
{
if (!File.Exists(DriveFsPreferenceReader.DefaultDatabasePath))
{
return;
}
var places = DriveFsPreferenceReader.ReadPlaces();
Assert.Contains(places, p => p.DisplayName == "Google Drive" && p.Path.Length >= 2);
}
[Theory]
[InlineData(@"C:\Users\x\My Drive", "Google Drive")]
[InlineData(@"G:\Shared drives", "Shared drives")]
[InlineData(@"D:\Work", "Google Drive - Work")]
public void Google_drive_place_label(string path, string expected)
=> Assert.Equal(expected, GoogleDrivePlaceDiscovery.BuildLabel(path, null));
[Fact]
public void Nextcloud_parses_account_folders_from_cfg()
{
const string cfg = """
[Accounts]
0\url=https://cloud.example.com
0\displayName=Dominique Wyniger
0\Folders\1\localPath=F:/OneDrive - wyniger/Obsidian/Notes/
0\Folders\1\targetPath=/Notizen/Notes
0\Folders\1\virtualFilesMode=off
""";
var places = NextcloudPlaceDiscovery.ParseCfg(cfg);
Assert.Single(places);
Assert.Equal("nextcloud", places[0].ProviderId);
Assert.Equal(@"F:\OneDrive - wyniger\Obsidian\Notes", places[0].Path);
Assert.Equal("Nextcloud - Notes", places[0].DisplayName);
}
[Fact]
public void OneDrive_does_not_claim_google_drive_paths()
{
Assert.False(OneDriveStorageProvider.LooksLikeOneDrive(@"G:\My Drive\Photos"));
Assert.True(GoogleDriveStorageProvider.LooksLikeGoogleDrive(@"G:\My Drive\Photos"));
}
[Fact]
public void Discover_skips_missing_default_profile_folders()
{
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var google = GoogleDrivePlaceDiscovery.Discover();
var nextcloud = NextcloudPlaceDiscovery.Discover();
AssertMissingGuess(google, Path.Combine(profile, "Google Drive"));
AssertMissingGuess(google, Path.Combine(profile, "My Drive"));
AssertMissingGuess(nextcloud, Path.Combine(profile, "Nextcloud"));
AssertMissingGuess(nextcloud, Path.Combine(profile, "ownCloud"));
}
private static void AssertMissingGuess(IReadOnlyList<ProviderPlace> places, string path)
{
if (Directory.Exists(path))
{
return;
}
Assert.DoesNotContain(places, p => p.Path.Equals(path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
}
}
file static class CloudFilesNativeStates

View File

@@ -14,6 +14,8 @@
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\..\src\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\..\src\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
<ProjectReference Include="..\..\src\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />

View File

@@ -90,6 +90,68 @@ public class SourceManagerTests
var found = await mgr.FindByPathAsync(@"Z:\");
Assert.Equal(source.Id, found!.Id);
}
[Fact]
public async Task Forget_removes_disconnected_source_and_index_rows()
{
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
await store.OpenAsync();
var volumes = new FakeVolumes
{
Online =
[
new VolumeFingerprint
{
Kind = SourceKind.Smb,
RootPath = @"Z:\",
DisplayName = "Z: (Network)",
Filesystem = "SMB"
}
]
};
var env = new FakeEnv(Path.GetDirectoryName(db)!);
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
await mgr.InitializeAsync();
var source = (await store.Sources.GetAllAsync()).Single();
await store.Entries.UpsertAsync(new IndexEntry
{
SourceId = source.Id,
Name = "Photos",
NameNorm = "photos",
IsDirectory = true,
PathRel = "Photos",
LastSeenUtc = DateTimeOffset.UtcNow
});
Assert.False(mgr.CanForget(source));
Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\"));
volumes.Online = [];
await mgr.RefreshOnlineStateAsync();
source = (await store.Sources.GetAllAsync()).Single();
Assert.True(mgr.CanForget(source));
Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\"));
Assert.Empty(await store.Sources.GetAllAsync());
Assert.Null(await store.Entries.GetRootAsync(source.Id));
}
[Fact]
public async Task Forget_removes_unc_from_recents()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"));
var db = Path.Combine(dir, "index.db");
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
var volumes = new FakeVolumes();
var env = new FakeEnv(dir);
File.WriteAllLines(Path.Combine(dir, "recents.txt"), [@"\\old-server\share"]);
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
await mgr.InitializeAsync();
Assert.Single(await store.Sources.GetAllAsync());
Assert.True(await mgr.ForgetDisconnectedAsync(@"\\old-server\share"));
Assert.Empty(await store.Sources.GetAllAsync());
Assert.Empty(File.ReadAllLines(Path.Combine(dir, "recents.txt")));
}
}
file sealed class FakeVolumes : IVolumeService

View File

@@ -0,0 +1,69 @@
using Explorer.Application;
using Explorer.Domain.Abstractions;
namespace Explorer.Application.Tests;
public class UiPreferencesStoreTests
{
[Fact]
public void Parse_reads_theme_and_independent_group_flags()
{
var prefs = UiPreferencesStore.Parse(
[
"theme=Light",
"group-network=true",
"group-cloud=false",
"index-archives=true"
]);
Assert.Equal("Light", prefs.Theme);
Assert.True(prefs.GroupNetworkPlaces);
Assert.False(prefs.GroupCloudPlaces);
Assert.True(prefs.IndexArchiveContents);
}
[Fact]
public void Parse_defaults_missing_keys()
{
var prefs = UiPreferencesStore.Parse(["theme=dark"]);
Assert.Equal("Dark", prefs.Theme);
Assert.False(prefs.GroupNetworkPlaces);
Assert.False(prefs.GroupCloudPlaces);
Assert.False(prefs.IndexArchiveContents);
}
[Fact]
public void Load_and_save_roundtrip()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-ui-prefs", Guid.NewGuid().ToString("N"));
try
{
var store = new UiPreferencesStore(new PrefsEnv(dir));
store.Save(new UiPreferences("Light", true, false, true));
var loaded = store.Load();
Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces);
Assert.False(loaded.GroupCloudPlaces);
Assert.True(loaded.IndexArchiveContents);
}
finally
{
try { Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
}
file sealed class PrefsEnv : IAppEnvironment
{
public PrefsEnv(string dir)
{
DataDirectory = dir;
Directory.CreateDirectory(dir);
DatabasePath = Path.Combine(dir, "index.db");
LogDirectory = Path.Combine(dir, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}

View File

@@ -43,6 +43,9 @@ public class PathRulesTests
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
Assert.Equal(@"pack.zip\docs", PathRules.RelativeParent(@"pack.zip\docs\a.txt"));
Assert.Equal("pack.zip", PathRules.RelativeParent(@"pack.zip\docs"));
Assert.Equal("", PathRules.RelativeParent("pack.zip"));
}
[Fact]
@@ -62,6 +65,31 @@ public class PathRulesTests
}
}
public class ArchiveFormatsTests
{
[Fact]
public void Detects_common_archive_extensions()
{
Assert.True(ArchiveFormats.IsArchive("pack.zip"));
Assert.True(ArchiveFormats.IsArchive("film.7z"));
Assert.True(ArchiveFormats.IsArchive("backup.rar"));
Assert.True(ArchiveFormats.IsArchive("src.tar.gz"));
Assert.True(ArchiveFormats.IsZipFamily("photos.cbz"));
Assert.False(ArchiveFormats.IsArchive("notes.txt"));
Assert.False(ArchiveFormats.IsArchive("report.docx"));
}
[Fact]
public void Rejects_unsafe_archive_entry_paths()
{
Assert.True(ArchiveFormats.TryNormalizeEntryPath("docs/a.txt", out var ok));
Assert.Equal(@"docs\a.txt", ok);
Assert.False(ArchiveFormats.TryNormalizeEntryPath(@"..\escape.txt", out _));
Assert.False(ArchiveFormats.TryNormalizeEntryPath(@"C:\abs.txt", out _));
Assert.False(ArchiveFormats.TryNormalizeEntryPath("/", out _));
}
}
public class VolumeIdentityTests
{
[Fact]

View File

@@ -161,6 +161,107 @@ public class ScannerTests
}
}
[Fact]
public async Task Full_scan_indexes_archive_members_without_double_counting_folder_size()
{
var root = CreateTree();
var zipPath = Path.Combine(root, "pack.zip");
CreateZip(zipPath, ("docs/inner.txt", new string('x', 40)));
try
{
await using var store = await OpenStore();
var source = await AddSource(store, root);
var indexer = CreateIndexer(store, enabled: true);
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance, indexer);
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
var inner = await store.Entries.GetByPathAsync(source.Id, @"pack.zip\docs\inner.txt");
Assert.NotNull(inner);
Assert.Equal(40, inner.SizeBytes);
Assert.False(inner.IsDirectory);
var zip = await store.Entries.GetByPathAsync(source.Id, "pack.zip");
Assert.NotNull(zip);
Assert.False(zip.IsDirectory);
Assert.Equal(new FileInfo(zipPath).Length, zip.SizeBytes);
var scanRoot = await store.Entries.GetRootAsync(source.Id);
Assert.Equal(100 + 50 + zip.SizeBytes, scanRoot!.AggregateSize);
}
finally
{
TryDelete(root);
}
}
[Fact]
public async Task Full_scan_skips_archive_contents_when_setting_is_off()
{
var root = CreateTree();
CreateZip(Path.Combine(root, "pack.zip"), ("inner.txt", "hello"));
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);
Assert.Null(await store.Entries.GetByPathAsync(source.Id, @"pack.zip\inner.txt"));
Assert.NotNull(await store.Entries.GetByPathAsync(source.Id, "pack.zip"));
}
finally
{
TryDelete(root);
}
}
[Fact]
public async Task Folder_reconcile_tombs_archive_members_when_zip_is_removed()
{
var root = CreateTree();
var zipPath = Path.Combine(root, "pack.zip");
CreateZip(zipPath, ("inner.txt", "hello"));
try
{
await using var store = await OpenStore();
var source = await AddSource(store, root);
var indexer = CreateIndexer(store, enabled: true);
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance, indexer);
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
File.Delete(zipPath);
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), indexer);
await reconciler.ReconcileAsync(source, "", CancellationToken.None);
var zip = await store.Entries.GetByPathAsync(source.Id, "pack.zip");
var inner = await store.Entries.GetByPathAsync(source.Id, @"pack.zip\inner.txt");
Assert.Equal(EntryStatus.Deleted, zip!.Status);
Assert.Equal(EntryStatus.Deleted, inner!.Status);
}
finally
{
TryDelete(root);
}
}
[Fact]
public void Archive_catalog_lists_zip_entry_sizes_without_extracting()
{
var dir = Path.Combine(Path.GetTempPath(), "ew-scan", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var zipPath = Path.Combine(dir, "pack.zip");
CreateZip(zipPath, ("docs/inner.txt", new string('z', 25)));
try
{
var members = new ArchiveCatalog().TryList(zipPath);
var inner = Assert.Single(members, m => m.RelativePath.Equals(@"docs\inner.txt", StringComparison.OrdinalIgnoreCase));
Assert.False(inner.IsDirectory);
Assert.Equal(25, inner.SizeBytes);
Assert.DoesNotContain(Directory.GetFiles(dir, "*", SearchOption.AllDirectories), p => p.EndsWith("inner.txt", StringComparison.OrdinalIgnoreCase) && !p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
}
finally
{
TryDelete(dir);
}
}
[Fact]
public async Task Folder_reconcile_tombs_removed_file()
{
@@ -218,4 +319,46 @@ public class ScannerTests
{
try { Directory.Delete(root, true); } catch { /* ignore */ }
}
private static void CreateZip(string zipPath, params (string Name, string Content)[] files)
{
using var zip = System.IO.Compression.ZipFile.Open(zipPath, System.IO.Compression.ZipArchiveMode.Create);
foreach (var (name, content) in files)
{
var entry = zip.CreateEntry(name);
using var stream = entry.Open();
using var writer = new StreamWriter(stream);
writer.Write(content);
}
}
private static ArchiveContentsIndexer CreateIndexer(IIndexStore store, bool enabled)
{
var dir = Path.Combine(Path.GetTempPath(), "ew-scan-prefs", Guid.NewGuid().ToString("N"));
var prefs = new UiPreferencesStore(new ScanPrefsEnv(dir));
prefs.Save(new UiPreferences("Dark", false, false, enabled));
return new ArchiveContentsIndexer(
store,
new IoEnumerator(),
new HydrationGuard(new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance)),
new ArchiveCatalog(),
prefs,
NullLogger<ArchiveContentsIndexer>.Instance);
}
}
file sealed class ScanPrefsEnv : IAppEnvironment
{
public ScanPrefsEnv(string dir)
{
DataDirectory = dir;
Directory.CreateDirectory(dir);
DatabasePath = Path.Combine(dir, "index.db");
LogDirectory = Path.Combine(dir, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}