diff --git a/.claude/rules/c-sharp-code-style.md b/.claude/rules/c-sharp-code-style.md index 72d76e2..3105975 100644 --- a/.claude/rules/c-sharp-code-style.md +++ b/.claude/rules/c-sharp-code-style.md @@ -35,6 +35,14 @@ Coding standards and style guidelines / preferences for C# files in this reposit - Use file-scoped namespaces. - Namespace names should follow the pattern: Company.Project.Module (e.g., Contoso.Sales.Reporting). +- Use `using` aliases to eliminate noise from verbose third-party type names: + ```csharp + // ✅ alias at the top of the file — use the alias throughout + using GraphClient = Microsoft.Graph.GraphServiceClient; + + // ❌ repeated fully-qualified name in every method signature + private static Task<...> FooAsync(Microsoft.Graph.GraphServiceClient client, ...) { } + ``` ## Folder Structure @@ -89,6 +97,32 @@ This applies to all new code. - Every `return` statement after a code block must be preceded by a blank line. `return` after an `if` must NOT be followed by a blank line or `{ return; }`. - Name for **meaning**: `customerId` not `id`, `isExpired` not `flag`. - Use builders for test setup / test data creation. +- Prefer a nullable accumulator parameter with `?? []` over an overload pair where one simply calls the other with an empty collection: + ```csharp + // ❌ overload pair — the first adds nothing + private static Task, E>> GetPagesAsync(Client c, Page page, CancellationToken ct) + => GetPagesAsync(c, page, [], ct); + private static Task, E>> GetPagesAsync(Client c, Page page, IReadOnlyCollection acc, CancellationToken ct) { ... } + + // ✅ single method with nullable accumulator + private static Task, E>> GetPagesAsync(Client c, Page page, IReadOnlyCollection? acc, CancellationToken ct) + { + var items = (acc ?? []).Concat(GetItemsFromPage(page)).ToList(); + // ... + } + ``` +- A private method whose entire body is a 1–2 combinator chain that forwards to another private method earns nothing — inline it at the call site: + ```csharp + // ❌ wrapper adds a layer for no reason + private static Task, GraphError>> GetDriveFoldersAsync(Client c, DriveFound d, RootFound r, CancellationToken ct) + => GetFirstPageAsync(c, d, r, ct).BindAsync(page => GetAllPagesAsync(c, d, r, page, ct)); + + // ✅ inline at the call site + private static Task, GraphError>> GetRootFoldersForDriveAsync(Client c, DriveFound d, CancellationToken ct) + => GetRootAsync(c, d, ct) + .BindAsync(root => GetFirstPageAsync(c, d, root, ct) + .BindAsync(page => GetAllPagesAsync(c, d, root, page, null, ct))); + ``` ## Primitive Obsession diff --git a/.claude/rules/c-sharp-testing.md b/.claude/rules/c-sharp-testing.md index 33901ca..021a0b8 100644 --- a/.claude/rules/c-sharp-testing.md +++ b/.claude/rules/c-sharp-testing.md @@ -36,7 +36,7 @@ collection.ShouldBeEmpty(); ```csharp // ✅ test code — pattern matching is fine -var result = await repository.GetByIdAsync(id, ct); +var result = await repository.GetByIdAsync(id, cancellationToken); result.ShouldBeOfType.Some>(); var some = (Option.Some)result; some.Value.Profile.Email.ShouldBe("test@example.com"); diff --git a/.claude/rules/onedrive-auth.md b/.claude/rules/onedrive-auth.md index f0a3015..69808a6 100644 --- a/.claude/rules/onedrive-auth.md +++ b/.claude/rules/onedrive-auth.md @@ -158,8 +158,8 @@ string email = result.ClaimsPrincipal?.FindFirst("preferred_username")?.Value ## IAuthService contract ```csharp -Task> SignInInteractiveAsync(CancellationToken ct = default); -Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default); -Task SignOutAsync(string accountId, CancellationToken ct = default); +Task> SignInInteractiveAsync(CancellationToken cancellationToken = default); +Task> AcquireTokenSilentAsync(string accountId, CancellationToken cancellationToken = default); +Task SignOutAsync(string accountId, CancellationToken cancellationToken = default); Task> GetCachedAccountIdsAsync(); ``` diff --git a/.claude/rules/onedrive-background.md b/.claude/rules/onedrive-background.md index c1c278c..0c350f2 100644 --- a/.claude/rules/onedrive-background.md +++ b/.claude/rules/onedrive-background.md @@ -13,7 +13,7 @@ scheduler.StopSync(); // pause without disposing scheduler.SetInterval(newInterval); // change interval mid-run await scheduler.TriggerNowAsync(ct); // immediate one-shot sync (all accounts) -await scheduler.TriggerAccountAsync(accountId, ct); // sync a single account +await scheduler.TriggerAccountAsync(accountId, cancellationToken); // sync a single account await scheduler.CancelAccountSyncAsync(accountId); // cancel in-flight sync for account ``` @@ -24,7 +24,7 @@ Use `Interlocked.Exchange` to prevent concurrent sync passes: ```csharp private long _runningFlag; -private async Task RunSyncPassAsync(CancellationToken ct) +private async Task RunSyncPassAsync(CancellationToken cancellationToken) { if (Interlocked.Exchange(ref _runningFlag, 1) == 1) return; try { /* sync all accounts */ } @@ -76,15 +76,15 @@ event EventHandler? SyncCompleted; void StartSync(TimeSpan? interval = null); void StopSync(); void SetInterval(TimeSpan interval); -Task TriggerNowAsync(CancellationToken ct = default); -Task TriggerAccountAsync(string accountId, CancellationToken ct = default); -Task TriggerAccountAsync(OneDriveAccount account, CancellationToken ct = default); +Task TriggerNowAsync(CancellationToken cancellationToken = default); +Task TriggerAccountAsync(string accountId, CancellationToken cancellationToken = default); +Task TriggerAccountAsync(OneDriveAccount account, CancellationToken cancellationToken = default); Task CancelAccountSyncAsync(string accountId); ``` ## CancellationToken rules -- **Every** public async method takes `CancellationToken ct = default` as the final parameter. +- **Every** public async method takes `CancellationToken cancellationToken = default` as the final parameter. - Propagate `ct` to every downstream `await` — never pass `CancellationToken.None` unless you are deliberately starting unlinked work (e.g. timer-tick root, post-cancellation cleanup). - Catch `OperationCanceledException` at the service boundary only — do not swallow it inside pipeline steps. - Use `CancellationTokenSource.CreateLinkedTokenSource(ct)` when you need per-account cancellation composable with an outer token. diff --git a/.claude/rules/onedrive-di.md b/.claude/rules/onedrive-di.md index f2c422c..1ecec43 100644 --- a/.claude/rules/onedrive-di.md +++ b/.claude/rules/onedrive-di.md @@ -67,10 +67,10 @@ services.AddDbContextFactory(options => // Repository usage — one context per async operation public class AccountRepository(IDbContextFactory dbFactory) : IAccountRepository { - public async Task> GetByIdAsync(AccountId id, CancellationToken ct) + public async Task> GetByIdAsync(AccountId id, CancellationToken cancellationToken) { await using var context = await dbFactory.CreateDbContextAsync(ct); - var entity = await context.Accounts.FindAsync([id], ct); + var entity = await context.Accounts.FindAsync([id], cancellationToken); return entity is null ? new None() : new Some(entity); } } diff --git a/.claude/rules/onedrive-graph.md b/.claude/rules/onedrive-graph.md index 2411e64..62afa7f 100644 --- a/.claude/rules/onedrive-graph.md +++ b/.claude/rules/onedrive-graph.md @@ -9,6 +9,14 @@ This repo uses `Microsoft.Graph` (SDK v5+) via a `GraphServiceClient` created pe ``` +## Type alias + +Always add a `using` alias at the top of any file that uses `GraphServiceClient` — the fully-qualified name is noisy in method signatures: + +```csharp +using GraphClient = Microsoft.Graph.GraphServiceClient; +``` + ## Creating a Graph client ```csharp @@ -19,7 +27,7 @@ public GraphServiceClient CreateClient(string accessToken) private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider { - public Task GetAuthorizationTokenAsync(Uri uri, ..., CancellationToken ct = default) + public Task GetAuthorizationTokenAsync(Uri uri, ..., CancellationToken cancellationToken = default) => Task.FromResult(token); public AllowedHostsValidator AllowedHostsValidator { get; } = new(["graph.microsoft.com"]); @@ -45,17 +53,39 @@ _cache[accountId] = new DriveContext(new DriveId(drive.Id), root.Id); ## Getting root folders +Use a single `GetFolderPageAsync` helper with `string? nextLink` — `null` fetches the first page (with `Select` query params); non-null fetches subsequent pages via `.WithUrl` (URL already contains params). This replaces two near-identical methods: + ```csharp -var response = await client - .Drives[driveId].Items[rootId].Children - .GetAsync(req => req.QueryParameters.Select = ["id", "name", "folder", "file", "size", - "lastModifiedDateTime", "parentReference", "eTag", "cTag", - "@microsoft.graph.downloadUrl"], ct); +private static async Task> GetFolderPageAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, string? nextLink, CancellationToken cancellationToken) +{ + var children = client.Drives[driveFound.Drive.Id].Items[rootFound.DriveItem.Id].Children; + var page = nextLink is null + ? await children.GetAsync(req => req.QueryParameters.Select = ChildrenSelect, cancellationToken: cancellationToken).ConfigureAwait(false) + : await children.WithUrl(nextLink).GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + return page is { } + ? new Ok(page) + : new Fail(GraphErrorFactory.Unexpected("Folder page was null.")); +} +``` + +Recursive page accumulation uses `IReadOnlyCollection?` with `?? []` — no overload pair needed: + +```csharp +private static Task, GraphError>> GetFoldersFromPagesAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, DriveItemCollectionResponse page, IReadOnlyCollection? foldersSoFar, CancellationToken cancellationToken) +{ + var folders = (foldersSoFar ?? []).Concat(GetFoldersFromPage(page)).ToList(); + + return page.OdataNextLink is null + ? Task.FromResult, GraphError>>(new Ok, GraphError>(folders)) + : GetFolderPageAsync(client, driveFound, rootFound, page.OdataNextLink, cancellationToken) + .BindAsync(nextPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, nextPage, folders, cancellationToken)); +} ``` - Filter to `item.Folder is not null` to get folders only. -- **Always** paginate: loop on `OdataNextLink` using `.WithUrl(nextLink).GetAsync(ct)`. -- Return `DriveFolder(Id, Name, ParentId)` records ordered by name. +- Pass `null` for `foldersSoFar` on the initial call; recursive calls pass the accumulated list. +- Return `DriveFolder(Id, Name, ParentId)` records. ## Getting child folders (lazy folder tree) @@ -66,7 +96,7 @@ var result = await client { req.QueryParameters.Select = ["id", "name", "folder", "parentReference"]; req.QueryParameters.Top = 100; - }, ct); + }, cancellationToken); ``` - Same pagination loop as root folders. @@ -77,7 +107,7 @@ var result = await client ```csharp var drive = await client .Drives[driveId] - .GetAsync(req => req.QueryParameters.Select = ["quota"], ct); + .GetAsync(req => req.QueryParameters.Select = ["quota"], cancellationToken); long total = drive?.Quota?.Total ?? 0L; long used = drive?.Quota?.Used ?? 0L; ``` @@ -88,12 +118,12 @@ Recursively enumerates a folder subtree. Use a `HashSet` of visited IDs ```csharp static async Task EnumerateSubFolderAsync(GraphServiceClient client, DriveId driveId, string parentId, - string relativePath, List items, HashSet visited, CancellationToken ct) + string relativePath, List items, HashSet visited, CancellationToken cancellationToken) { if (!visited.Add(parentId)) return; // cycle guard var page = await client.Drives[driveId.Value].Items[parentId].Children - .GetAsync(req => req.QueryParameters.Select = _childrenSelect, ct); + .GetAsync(req => req.QueryParameters.Select = _childrenSelect, cancellationToken); while (page?.Value is not null) { @@ -102,11 +132,11 @@ static async Task EnumerateSubFolderAsync(GraphServiceClient client, DriveId dri string itemPath = BuildRelativePath(relativePath, item); items.Add(MapToDeltaItem(item, itemPath)); if (item.Folder is not null && item.Id is not null) - await EnumerateSubFolderAsync(client, driveId, item.Id, itemPath, items, visited, ct); + await EnumerateSubFolderAsync(client, driveId, item.Id, itemPath, items, visited, cancellationToken); } if (page.OdataNextLink is null) break; page = await client.Drives[driveId.Value].Items[parentId].Children - .WithUrl(page.OdataNextLink).GetAsync(ct: ct); + .WithUrl(page.OdataNextLink).GetAsync(ct: cancellationToken); } } ``` @@ -118,7 +148,7 @@ Returns `Result, GraphError>`. ```csharp var item = await client .Drives[driveId].Items[$"root:/{remotePath}"] - .GetAsync(req => req.QueryParameters.Select = ["id"], ct); + .GetAsync(req => req.QueryParameters.Select = ["id"], cancellationToken); // Returns null on 404 — catch ApiException with ResponseStatusCode == 404 ``` @@ -128,7 +158,7 @@ Select `@microsoft.graph.downloadUrl` and read from `item.AdditionalData`: ```csharp var item = await client.Drives[driveId].Items[itemId] - .GetAsync(req => req.QueryParameters.Select = ["@microsoft.graph.downloadUrl"], ct); + .GetAsync(req => req.QueryParameters.Select = ["@microsoft.graph.downloadUrl"], cancellationToken); if (!item.AdditionalData.TryGetValue("@microsoft.graph.downloadUrl", out var url) || url is null) return new Result.Error(GraphErrorFactory.NotFound(itemId)); @@ -148,7 +178,7 @@ var session = await client .Drives[driveId].Items[parentFolderId] .ItemWithPath(remotePath) .CreateUploadSession - .PostAsync(requestBody, ct); + .PostAsync(requestBody, cancellationToken); // requestBody sets @microsoft.graph.conflictBehavior = "replace" // and fileSystemInfo.lastModifiedDateTime from local file's LastWriteTimeUtc @@ -161,7 +191,7 @@ See `onedrive-sync.md` for the full upload/retry protocol. ## Deleting an item ```csharp -await client.Drives[driveId].Items[itemId].DeleteAsync(ct: ct); +await client.Drives[driveId].Items[itemId].DeleteAsync(ct: cancellationToken); ``` Returns `Result`. @@ -188,15 +218,15 @@ var downloadUrl = ExtractDownloadUrl(item); // Option ## IGraphService contract ```csharp -Task> GetDriveIdAsync(string accountId, string accessToken, CancellationToken ct = default); -Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default); -Task, GraphError>> GetChildFoldersAsync(string accessToken, DriveId driveId, string parentFolderId, CancellationToken ct = default); -Task> GetQuotaAsync(string accountId, string accessToken, CancellationToken ct = default); -Task, GraphError>> EnumerateFolderAsync(string accessToken, DriveId driveId, string folderId, string remotePath, CancellationToken ct = default); -Task> GetFolderIdByPathAsync(string accessToken, DriveId driveId, string remotePath, CancellationToken ct = default); -Task> GetDownloadUrlAsync(string accountId, string accessToken, string itemId, CancellationToken ct = default); -Task> UploadFileAsync(string accountId, string accessToken, string localPath, string remotePath, string parentFolderId, CancellationToken ct = default); -Task> DeleteItemAsync(string accountId, string accessToken, string itemId, CancellationToken ct = default); +Task> GetDriveIdAsync(string accountId, string accessToken, CancellationToken cancellationToken = default); +Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken cancellationToken = default); +Task, GraphError>> GetChildFoldersAsync(string accessToken, DriveId driveId, string parentFolderId, CancellationToken cancellationToken = default); +Task> GetQuotaAsync(string accountId, string accessToken, CancellationToken cancellationToken = default); +Task, GraphError>> EnumerateFolderAsync(string accessToken, DriveId driveId, string folderId, string remotePath, CancellationToken cancellationToken = default); +Task> GetFolderIdByPathAsync(string accessToken, DriveId driveId, string remotePath, CancellationToken cancellationToken = default); +Task> GetDownloadUrlAsync(string accountId, string accessToken, string itemId, CancellationToken cancellationToken = default); +Task> UploadFileAsync(string accountId, string accessToken, string localPath, string remotePath, string parentFolderId, CancellationToken cancellationToken = default); +Task> DeleteItemAsync(string accountId, string accessToken, string itemId, CancellationToken cancellationToken = default); void EvictCachedDriveContext(string accountId); ``` diff --git a/.claude/rules/onedrive-onboarding.md b/.claude/rules/onedrive-onboarding.md index e3f4aae..24f3163 100644 --- a/.claude/rules/onedrive-onboarding.md +++ b/.claude/rules/onedrive-onboarding.md @@ -9,7 +9,7 @@ /// Persists a new account and its initial sync configuration after the wizard completes. /// Returns the finalised with all defaults applied. /// -Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default); +Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken cancellationToken = default); ``` ## Responsibilities (in order) @@ -56,7 +56,7 @@ The wizard ViewModel raises `Completed` with the draft `OneDriveAccount`. The ho ```csharp wizard.Completed += async (_, account) => { - await _onboardingService.CompleteOnboardingAsync(account, ct) + await _onboardingService.CompleteOnboardingAsync(account, cancellationToken) .MatchAsync( finalAccount => { /* add to accounts list, navigate away */ }, error => { HasError = true; ErrorMessage = error.Message; }); diff --git a/.claude/rules/onedrive-persistence.md b/.claude/rules/onedrive-persistence.md index 82dd1c3..2769fcd 100644 --- a/.claude/rules/onedrive-persistence.md +++ b/.claude/rules/onedrive-persistence.md @@ -190,11 +190,11 @@ IFileClassificationRuleRepository / FileClassificationRuleRepository All write methods return `Result`. Catch EF exceptions at the repository: ```csharp -public async Task> UpsertAsync(AccountEntity entity, CancellationToken ct) +public async Task> UpsertAsync(AccountEntity entity, CancellationToken cancellationToken) { try { - var existing = await context.Accounts.FindAsync([entity.Id], ct); + var existing = await context.Accounts.FindAsync([entity.Id], cancellationToken); if (existing is null) context.Accounts.Add(entity); else diff --git a/.claude/rules/onedrive-sync.md b/.claude/rules/onedrive-sync.md index 833e0b0..493a9ad 100644 --- a/.claude/rules/onedrive-sync.md +++ b/.claude/rules/onedrive-sync.md @@ -90,7 +90,7 @@ After writing the file: A discrete `ISyncedItemRegistrar` handles two registration tasks that occur during the build phase (not job execution): ```csharp -Task RegisterFolderAsync(AccountId accountId, FolderDeltaItem item, string remotePath, string localPath, Dictionary syncedItems, CancellationToken ct); +Task RegisterFolderAsync(AccountId accountId, FolderDeltaItem item, string remotePath, string localPath, Dictionary syncedItems, CancellationToken cancellationToken); ``` - Creates the local directory via `IFileSystem` if it does not exist. @@ -163,7 +163,7 @@ Set `fileSystemInfo.lastModifiedDateTime` from the local file's `LastWriteTimeUt - Returns `Result`. ```csharp -Task> DownloadAsync(string url, string localPath, DateTimeOffset remoteModified, IProgress? progress = null, CancellationToken ct = default); +Task> DownloadAsync(string url, string localPath, DateTimeOffset remoteModified, IProgress? progress = null, CancellationToken cancellationToken = default); ``` ## Conflict detection @@ -188,7 +188,7 @@ public enum ConflictPolicy ### Resolution flow 1. `ConflictResolver.Resolve(policy, localModified, remoteModified)` returns `ConflictOutcome`. -2. `ConflictApplier.ApplyAsync(conflict, outcome, accountId, accessToken, ct)` executes the chosen action via `IGraphService` or local file system. +2. `ConflictApplier.ApplyAsync(conflict, outcome, accountId, accessToken, cancellationToken)` executes the chosen action via `IGraphService` or local file system. 3. `syncRepository.ResolveConflictAsync(conflictId, policy)` marks the conflict resolved in the DB. 4. Unresolved conflicts surface in the UI via `ISyncService.ConflictDetected` event. @@ -216,7 +216,7 @@ record SyncConflict( ```csharp // ISyncPipeline -Task RunAsync(IEnumerable jobs, string accessToken, Action onProgress, Action onJobCompleted, string accountId, int workerCount, CancellationToken ct = default); +Task RunAsync(IEnumerable jobs, string accessToken, Action onProgress, Action onJobCompleted, string accountId, int workerCount, CancellationToken cancellationToken = default); ``` Never hard-code `workerCount` — always pass `account.SyncConfig.WorkerCount`. @@ -228,8 +228,8 @@ event EventHandler? SyncProgressChanged; event EventHandler? JobCompleted; event EventHandler? ConflictDetected; -Task> SyncAccountAsync(OneDriveAccount account, CancellationToken ct = default); -Task> ResolveConflictAsync(SyncConflict conflict, ConflictPolicy policy, CancellationToken ct = default); +Task> SyncAccountAsync(OneDriveAccount account, CancellationToken cancellationToken = default); +Task> ResolveConflictAsync(SyncConflict conflict, ConflictPolicy policy, CancellationToken cancellationToken = default); ``` ## SyncProgressEventArgs diff --git a/.claude/rules/onedrive-viewmodels.md b/.claude/rules/onedrive-viewmodels.md index 55c44c1..76c338f 100644 --- a/.claude/rules/onedrive-viewmodels.md +++ b/.claude/rules/onedrive-viewmodels.md @@ -193,9 +193,9 @@ public sealed class AddAccountWizardViewModel : ReactiveObject, IDisposable var canGoNext = this.WhenAnyValue(x => x.IsSignedIn, x => x.CurrentStep, (signedIn, step) => step == WizardStep.SignIn ? signedIn : true); - OpenBrowser = ReactiveCommand.CreateFromTask(ct => OpenBrowserAsync(authService, ct)); + OpenBrowser = ReactiveCommand.CreateFromTask(ct => OpenBrowserAsync(authService, cancellationToken)); Back = ReactiveCommand.Create(ExecuteBack); - Next = ReactiveCommand.CreateFromTask(ct => ExecuteNextAsync(graphService, ct), canGoNext); + Next = ReactiveCommand.CreateFromTask(ct => ExecuteNextAsync(graphService, cancellationToken), canGoNext); Cancel = ReactiveCommand.CreateFromTask(ExecuteCancelAsync); } diff --git a/.gitignore b/.gitignore index 378a541..98c206c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ mono_crash.* # graphify graphify-out/ +# JetBrains Rider +.idea/ + # Build results [Dd]ebug/ [Dd]ebugPublic/ diff --git a/.vscode/settings.json b/.vscode/settings.json index fc8800f..af4e2d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,9 +6,19 @@ }, "dotnet.defaultSolution": "AStar.Dev.OneDrive.Functional.slnx", "cSpell.words": [ + "appsettings", + "astar", "Avalonia", + "buildtransitive", + "centralise", + "cloudsync", + "contentfiles", "googledrive", + "Lucide", + "MSAL", "MVVM", - "onedrive" + "onedrive", + "Serilog", + "Testably" ] } \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj index 03230c3..81f2d6e 100644 --- a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj +++ b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj @@ -23,25 +23,26 @@ None All - - - - - - - - - - - - + + + + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + - + diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs index 8b80004..ac57dd2 100644 --- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; +using System.IO.Abstractions; +using Testably.Abstractions; using MELogLevel = Microsoft.Extensions.Logging.LogLevel; namespace AStar.Dev.CloudSyncFunctional; @@ -37,13 +39,11 @@ public override void OnFrameworkInitializationCompleted() ConfigureServices(services, configuration); _serviceProvider = services.BuildServiceProvider(); - ApplyDatabaseMigrations(_serviceProvider); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var viewModel = _serviceProvider.GetRequiredService(); desktop.MainWindow = new MainWindow(viewModel); - _ = viewModel.LoadPersistedAccountsAsync(CancellationToken.None); + _ = InitialiseAsync(_serviceProvider, viewModel); } base.OnFrameworkInitializationCompleted(); @@ -56,7 +56,7 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio var clientId = configuration["MicrosoftIdentity:ClientId"] ?? throw new InvalidOperationException("MicrosoftIdentity:ClientId is not configured. Set it in appsettings.json or user secrets."); - services.AddSingleton(_ => + services.AddSingleton(_ => PublicClientApplicationBuilder .Create(clientId) .WithAuthority("https://login.microsoftonline.com/consumers") @@ -68,7 +68,10 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio services.AddSingleton(); services.AddSingleton(); - var connectionString = $"DataSource={GetDatabasePath()}"; + var fileSystem = new RealFileSystem(); + services.AddSingleton(fileSystem); + + var connectionString = $"DataSource={GetDatabasePath(fileSystem)}"; services.AddDbContextFactory(options => options.UseSqlite(connectionString), ServiceLifetime.Singleton); @@ -84,19 +87,25 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio services.AddTransient(); } - private static void ApplyDatabaseMigrations(IServiceProvider serviceProvider) + private static async Task InitialiseAsync(IServiceProvider serviceProvider, WorkspaceViewModel viewModel) + { + await ApplyDatabaseMigrationsAsync(serviceProvider); + await viewModel.LoadPersistedAccountsAsync(CancellationToken.None); + } + + private static async Task ApplyDatabaseMigrationsAsync(IServiceProvider serviceProvider) { var dbContextFactory = serviceProvider.GetRequiredService>(); - using var startupContext = dbContextFactory.CreateDbContext(); - startupContext.Database.Migrate(); + await using var context = await dbContextFactory.CreateDbContextAsync(); + await context.Database.MigrateAsync(); } - private static string GetDatabasePath() + private static string GetDatabasePath(IFileSystem fileSystem) { var configDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var appDir = Path.Combine(configDir, "astar-dev-cloudsync"); - Directory.CreateDirectory(appDir); + var appDir = fileSystem.Path.Combine(configDir, "astar-dev-cloudsync"); + fileSystem.Directory.CreateDirectory(appDir); - return Path.Combine(appDir, "sync.db"); + return fileSystem.Path.Combine(appDir, "sync.db"); } } diff --git a/src/AStar.Dev.CloudSyncFunctional/Assets/astar.png b/src/AStar.Dev.CloudSyncFunctional/Assets/astar.png new file mode 100644 index 0000000..74b197e Binary files /dev/null and b/src/AStar.Dev.CloudSyncFunctional/Assets/astar.png differ diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AccountId.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountId.cs new file mode 100644 index 0000000..f90bcfa --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountId.cs @@ -0,0 +1,15 @@ +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// +/// Strongly-typed wrapper for an OneDrive account identifier. +/// +/// The account identifier. +public sealed record AccountId(string Value) +{ + /// + /// Factory method to create an AccountId from a string value. This can be extended in the future to include validation or transformation logic if needed. + /// + /// The string value to create the AccountId from. + /// The created AccountId. + public static AccountId Create(string value) => new(value); +} diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs index 4d9aba7..306b662 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AccountProfile.cs @@ -4,3 +4,14 @@ namespace AStar.Dev.CloudSyncFunctional.Auth; /// The user's display name. /// The user's email address. public sealed record AccountProfile(string DisplayName, string Email); + +public static class AccountProfileFactory +{ + /// + /// Factory method to create an AccountProfile from string values. This can be extended in the future to include validation or transformation logic if needed. + /// + /// The user's display name. + /// The user's email address. + /// An with the provided values. + public static AccountProfile Create(string displayName, string email) => new(displayName, email); +} \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs index e82aa9e..0a4c41d 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthError.cs @@ -35,6 +35,5 @@ public static class AuthErrorFactory /// Creates an with the given message. /// The error message; falls back to a default if null or whitespace. /// An error representing a failed authentication. - public static AuthError Failed(string? message) => new AuthFailedError( - string.IsNullOrWhiteSpace(message) ? "Authentication failed: unknown error." : message); + public static AuthError Failed(string? message) => new AuthFailedError(string.IsNullOrWhiteSpace(message) ? "Authentication failed: unknown error." : message); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs index 07c2e20..c2e4c97 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs @@ -11,7 +11,7 @@ public sealed partial class AuthService(IPublicClientApplication app, ILogger - public async Task> SignInInteractiveAsync(CancellationToken ct = default) + public async Task> SignInInteractiveAsync(CancellationToken cancellationToken = default) { try { @@ -19,7 +19,7 @@ public async Task> SignInInteractiveAsync(Cancella .AcquireTokenInteractive(Scopes) .WithPrompt(Prompt.SelectAccount) .WithUseEmbeddedWebView(false) - .ExecuteAsync(ct) + .ExecuteAsync(cancellationToken) .ConfigureAwait(false); return new Ok(BuildAuthResult(msalResult)); @@ -45,7 +45,7 @@ public async Task> SignInInteractiveAsync(Cancella } /// - public async Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default) + public async Task> AcquireTokenSilentAsync(string accountId, CancellationToken cancellationToken = default) { try { @@ -54,7 +54,7 @@ public async Task> AcquireTokenSilentAsync(string if (account is null) return new Fail(AuthErrorFactory.Failed("Account not found in token cache.")); - var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(ct).ConfigureAwait(false); + var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false); return new Ok(BuildAuthResult(msalResult)); } @@ -70,7 +70,7 @@ public async Task> AcquireTokenSilentAsync(string } /// - public async Task SignOutAsync(string accountId, CancellationToken ct = default) + public async Task SignOutAsync(string accountId, CancellationToken cancellationToken = default) { var accounts = await app.GetAccountsAsync().ConfigureAwait(false); var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId); @@ -83,7 +83,7 @@ public async Task> GetCachedAccountIdsAsync() { var accounts = await app.GetAccountsAsync().ConfigureAwait(false); - return accounts.Select(a => a.HomeAccountId.Identifier).ToList(); + return [.. accounts.Where(a => a.HomeAccountId is not null).Select(a => a.HomeAccountId!.Identifier)]; } private static AuthResult BuildAuthResult(AuthenticationResult result) @@ -96,7 +96,7 @@ private static AuthResult BuildAuthResult(AuthenticationResult result) return AuthResultFactory.Create( result.AccessToken, result.Account.HomeAccountId.Identifier, - new AccountProfile(displayName, email), + AccountProfileFactory.Create(displayName, email), result.ExpiresOn); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/DriveId.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/DriveId.cs new file mode 100644 index 0000000..cd6344e --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/DriveId.cs @@ -0,0 +1,15 @@ +namespace AStar.Dev.CloudSyncFunctional.Auth; + +/// +/// Strongly-typed wrapper for a Graph drive identifier, which is used to uniquely identify a OneDrive drive or folder across API calls and app sessions. +/// +/// The drive identifier. +public sealed record DriveId(string Value) +{ + /// + /// Creates a new instance with the specified value. + /// + /// The string value to create the DriveId from. + /// The created DriveId. + public static DriveId Create(string value) => new(value); +} \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs index aa12414..0f5fd96 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/IAuthService.cs @@ -6,21 +6,21 @@ namespace AStar.Dev.CloudSyncFunctional.Auth; public interface IAuthService { /// Opens an interactive browser sign-in and returns the authenticated result. - /// Token to cancel the operation. + /// Token to cancel the operation. /// An on success, or an on failure. - Task> SignInInteractiveAsync(CancellationToken ct = default); + Task> SignInInteractiveAsync(CancellationToken cancellationToken = default); /// Silently acquires a new access token for an existing cached account. /// The MSAL HomeAccountId identifier. - /// Token to cancel the operation. + /// Token to cancel the operation. /// An on success, or an on failure. - Task> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default); + Task> AcquireTokenSilentAsync(string accountId, CancellationToken cancellationToken = default); /// Signs out and removes the account from the token cache. /// The MSAL HomeAccountId identifier. - /// Token to cancel the operation. + /// Token to cancel the operation. /// A task that completes when sign-out is done. - Task SignOutAsync(string accountId, CancellationToken ct = default); + Task SignOutAsync(string accountId, CancellationToken cancellationToken = default); /// Returns the list of account IDs currently in the token cache. /// A read-only list of MSAL HomeAccountId identifiers. diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs index fa26a4c..8cc4393 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/ITokenCacheService.cs @@ -7,7 +7,7 @@ public interface ITokenCacheService { /// Registers the cache helper with the given MSAL application. /// The MSAL application whose token cache is registered. - /// Token to cancel the operation. + /// Token to cancel the operation. /// A task that completes when registration is done. - Task RegisterAsync(IPublicClientApplication app, CancellationToken ct = default); + Task RegisterAsync(IPublicClientApplication app, CancellationToken cancellationToken = default); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs b/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs index ecafc16..0346915 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Auth/TokenCacheService.cs @@ -15,7 +15,7 @@ public sealed partial class TokenCacheService(ILogger logger) private static readonly string CacheDir = BuildCacheDir(); /// - public async Task RegisterAsync(IPublicClientApplication app, CancellationToken ct = default) + public async Task RegisterAsync(IPublicClientApplication app, CancellationToken cancellationToken = default) { try { diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml index 59b31ff..f26c872 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml @@ -25,9 +25,9 @@ FontSize="12.5"/> - - - + + + diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml.cs index 95b7fea..dcddbbe 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountHeader.axaml.cs @@ -10,32 +10,25 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class AccountHeader : UserControl { /// Identifies the styled property. - public static readonly StyledProperty AccountNameProperty = - AvaloniaProperty.Register(nameof(AccountName), string.Empty); + public static readonly StyledProperty AccountNameProperty = AvaloniaProperty.Register(nameof(AccountName), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty EmailProperty = - AvaloniaProperty.Register(nameof(Email), string.Empty); + public static readonly StyledProperty EmailProperty = AvaloniaProperty.Register(nameof(Email), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty KindProperty = - AvaloniaProperty.Register(nameof(Kind)); + public static readonly StyledProperty KindProperty = AvaloniaProperty.Register(nameof(Kind)); /// Identifies the styled property. - public static readonly StyledProperty StatusProperty = - AvaloniaProperty.Register(nameof(Status)); + public static readonly StyledProperty StatusProperty = AvaloniaProperty.Register(nameof(Status)); /// Identifies the styled property. - public static readonly StyledProperty PauseCommandProperty = - AvaloniaProperty.Register(nameof(PauseCommand)); + public static readonly StyledProperty PauseCommandProperty = AvaloniaProperty.Register(nameof(PauseCommand)); /// Identifies the styled property. - public static readonly StyledProperty SettingsCommandProperty = - AvaloniaProperty.Register(nameof(SettingsCommand)); + public static readonly StyledProperty SettingsCommandProperty = AvaloniaProperty.Register(nameof(SettingsCommand)); /// Identifies the styled property. - public static readonly StyledProperty MoreCommandProperty = - AvaloniaProperty.Register(nameof(MoreCommand)); + public static readonly StyledProperty MoreCommandProperty = AvaloniaProperty.Register(nameof(MoreCommand)); /// Gets or sets the display name of the account. public string AccountName @@ -118,7 +111,7 @@ public static string GetProviderDisplayName(ProviderKind kind) => protected override void OnInitialized() { base.OnInitialized(); - updateBorder(); + UpdateBorder(); updateProviderMark(); updateNameText(); updateProviderNameText(); @@ -138,7 +131,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (change.Property == PauseCommandProperty || change.Property == SettingsCommandProperty || change.Property == MoreCommandProperty) updateButtons(); } - private void updateBorder() + private void UpdateBorder() { if (ContainerBorder is null) return; diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountListItem.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountListItem.axaml.cs index d5a5084..69ae648 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/AccountListItem.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/AccountListItem.axaml.cs @@ -9,36 +9,28 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class AccountListItem : UserControl { /// Identifies the styled property. - public static readonly StyledProperty AccountNameProperty = - AvaloniaProperty.Register(nameof(AccountName), string.Empty); + public static readonly StyledProperty AccountNameProperty = AvaloniaProperty.Register(nameof(AccountName), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty EmailProperty = - AvaloniaProperty.Register(nameof(Email), string.Empty); + public static readonly StyledProperty EmailProperty = AvaloniaProperty.Register(nameof(Email), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty KindProperty = - AvaloniaProperty.Register(nameof(Kind)); + public static readonly StyledProperty KindProperty = AvaloniaProperty.Register(nameof(Kind)); /// Identifies the styled property. - public static readonly StyledProperty UsedBytesProperty = - AvaloniaProperty.Register(nameof(UsedBytes)); + public static readonly StyledProperty UsedBytesProperty = AvaloniaProperty.Register(nameof(UsedBytes)); /// Identifies the styled property. - public static readonly StyledProperty TotalBytesProperty = - AvaloniaProperty.Register(nameof(TotalBytes)); + public static readonly StyledProperty TotalBytesProperty = AvaloniaProperty.Register(nameof(TotalBytes)); /// Identifies the styled property. - public static readonly StyledProperty FolderCountProperty = - AvaloniaProperty.Register(nameof(FolderCount)); + public static readonly StyledProperty FolderCountProperty = AvaloniaProperty.Register(nameof(FolderCount)); /// Identifies the styled property. - public static readonly StyledProperty StatusProperty = - AvaloniaProperty.Register(nameof(Status)); + public static readonly StyledProperty StatusProperty = AvaloniaProperty.Register(nameof(Status)); /// Identifies the styled property. - public static readonly StyledProperty IsSelectedProperty = - AvaloniaProperty.Register(nameof(IsSelected)); + public static readonly StyledProperty IsSelectedProperty = AvaloniaProperty.Register(nameof(IsSelected)); /// Gets or sets the display name of the account. public string AccountName diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/AppButton.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/AppButton.axaml.cs index 4698fee..d9a6d43 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/AppButton.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/AppButton.axaml.cs @@ -9,28 +9,22 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class AppButton : UserControl { /// Identifies the styled property. - public static readonly StyledProperty KindProperty = - AvaloniaProperty.Register(nameof(Kind), AppButtonKind.Primary); + public static readonly StyledProperty KindProperty = AvaloniaProperty.Register(nameof(Kind), AppButtonKind.Primary); /// Identifies the styled property. - public static readonly StyledProperty ButtonSizeProperty = - AvaloniaProperty.Register(nameof(ButtonSize), AppButtonSize.Md); + public static readonly StyledProperty ButtonSizeProperty = AvaloniaProperty.Register(nameof(ButtonSize), AppButtonSize.Medium); /// Identifies the styled property. - public static readonly StyledProperty LabelProperty = - AvaloniaProperty.Register(nameof(Label), string.Empty); + public static readonly StyledProperty LabelProperty = AvaloniaProperty.Register(nameof(Label), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty LeadingIconProperty = - AvaloniaProperty.Register(nameof(LeadingIcon)); + public static readonly StyledProperty LeadingIconProperty = AvaloniaProperty.Register(nameof(LeadingIcon)); /// Identifies the styled property. - public static readonly StyledProperty TrailingIconProperty = - AvaloniaProperty.Register(nameof(TrailingIcon)); + public static readonly StyledProperty TrailingIconProperty = AvaloniaProperty.Register(nameof(TrailingIcon)); /// Identifies the styled property. - public static readonly StyledProperty CommandProperty = - AvaloniaProperty.Register(nameof(Command)); + public static readonly StyledProperty CommandProperty = AvaloniaProperty.Register(nameof(Command)); /// Gets or sets the visual style variant. public AppButtonKind Kind @@ -132,15 +126,15 @@ private void ApplyKind() } } - private void SetButtonAppearance(string? bgKey, string? fgKey, string? borderKey, int borderThickness) + private void SetButtonAppearance(string? backgroundKey, string? foregroundKey, string? borderKey, int borderThickness) { if (InnerButton is null) return; - InnerButton.Background = bgKey is not null && this.TryFindResource(bgKey, out var bg) && bg is IBrush bgBrush + InnerButton.Background = backgroundKey is not null && this.TryFindResource(backgroundKey, out var bg) && bg is IBrush bgBrush ? bgBrush : Brushes.Transparent; - InnerButton.Foreground = fgKey is not null && this.TryFindResource(fgKey, out var fg) && fg is IBrush fgBrush + InnerButton.Foreground = foregroundKey is not null && this.TryFindResource(foregroundKey, out var fg) && fg is IBrush fgBrush ? fgBrush : Brushes.White; @@ -156,8 +150,8 @@ private void ApplySize() var (height, fontSize) = ButtonSize switch { - AppButtonSize.Sm => (26.0, 12.0), - AppButtonSize.Lg => (38.0, 13.0), + AppButtonSize.Small => (26.0, 12.0), + AppButtonSize.Large => (38.0, 13.0), _ => (32.0, 12.5) }; diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/AppButtonSize.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/AppButtonSize.cs index 3e9dfa8..7a963b3 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/AppButtonSize.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/AppButtonSize.cs @@ -1,4 +1,4 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; /// Height preset for . -public enum AppButtonSize { Sm, Md, Lg } +public enum AppButtonSize { Small, Medium, Large } diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml index 5e80e5c..4ddc8b9 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml @@ -13,8 +13,8 @@ VerticalAlignment="Center"/> - - + + diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml.cs index 7d6c407..c3339e7 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderToolbar.axaml.cs @@ -9,24 +9,19 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class FolderToolbar : UserControl { /// Identifies the styled property. - public static readonly StyledProperty BreadcrumbPathProperty = - AvaloniaProperty.Register(nameof(BreadcrumbPath), "~/AStar /"); + public static readonly StyledProperty BreadcrumbPathProperty = AvaloniaProperty.Register(nameof(BreadcrumbPath), "~/AStar /"); /// Identifies the styled property. - public static readonly StyledProperty SelectionSummaryProperty = - AvaloniaProperty.Register(nameof(SelectionSummary), string.Empty); + public static readonly StyledProperty SelectionSummaryProperty = AvaloniaProperty.Register(nameof(SelectionSummary), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty CanApplyChangesProperty = - AvaloniaProperty.Register(nameof(CanApplyChanges), false); + public static readonly StyledProperty CanApplyChangesProperty = AvaloniaProperty.Register(nameof(CanApplyChanges), false); /// Identifies the styled property. - public static readonly StyledProperty FilterCommandProperty = - AvaloniaProperty.Register(nameof(FilterCommand)); + public static readonly StyledProperty FilterCommandProperty = AvaloniaProperty.Register(nameof(FilterCommand)); /// Identifies the styled property. - public static readonly StyledProperty ApplyChangesCommandProperty = - AvaloniaProperty.Register(nameof(ApplyChangesCommand)); + public static readonly StyledProperty ApplyChangesCommandProperty = AvaloniaProperty.Register(nameof(ApplyChangesCommand)); /// Gets or sets the breadcrumb path text displayed on the left. public string BreadcrumbPath @@ -70,24 +65,24 @@ public ICommand? ApplyChangesCommand protected override void OnInitialized() { base.OnInitialized(); - updateBorder(); - updateBreadcrumb(); - updateSelectionPill(); - updateApplyButton(); - updateCommands(); + UpdateBorder(); + UpdateBreadcrumb(); + UpdateSelectionPill(); + UpdateApplyButton(); + UpdateCommands(); } /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); - if (change.Property == BreadcrumbPathProperty) updateBreadcrumb(); - if (change.Property == SelectionSummaryProperty) updateSelectionPill(); - if (change.Property == CanApplyChangesProperty) updateApplyButton(); - if (change.Property == FilterCommandProperty || change.Property == ApplyChangesCommandProperty) updateCommands(); + if (change.Property == BreadcrumbPathProperty) UpdateBreadcrumb(); + if (change.Property == SelectionSummaryProperty) UpdateSelectionPill(); + if (change.Property == CanApplyChangesProperty) UpdateApplyButton(); + if (change.Property == FilterCommandProperty || change.Property == ApplyChangesCommandProperty) UpdateCommands(); } - private void updateBorder() + private void UpdateBorder() { if (ContainerBorder is null) return; @@ -95,7 +90,7 @@ private void updateBorder() ContainerBorder.BorderBrush = borderBrush; } - private void updateBreadcrumb() + private void UpdateBreadcrumb() { if (BreadcrumbText is null) return; @@ -105,7 +100,7 @@ private void updateBreadcrumb() BreadcrumbText.Foreground = brush; } - private void updateSelectionPill() + private void UpdateSelectionPill() { if (SelectionPill is null) return; @@ -113,14 +108,14 @@ private void updateSelectionPill() SelectionPill.IsVisible = !string.IsNullOrEmpty(SelectionSummary); } - private void updateApplyButton() + private void UpdateApplyButton() { if (ApplyButton is null) return; ApplyButton.IsEnabled = CanApplyChanges; } - private void updateCommands() + private void UpdateCommands() { if (FilterButton is null || ApplyButton is null) return; diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderTreeRow.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderTreeRow.axaml.cs index 6d8de5d..1af55a2 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/FolderTreeRow.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/FolderTreeRow.axaml.cs @@ -14,40 +14,31 @@ public partial class FolderTreeRow : UserControl private const long MegaByte = 1_048_576L; /// Identifies the styled property. - public static readonly StyledProperty NodeNameProperty = - AvaloniaProperty.Register(nameof(NodeName), string.Empty); + public static readonly StyledProperty NodeNameProperty = AvaloniaProperty.Register(nameof(NodeName), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty DepthProperty = - AvaloniaProperty.Register(nameof(Depth)); + public static readonly StyledProperty DepthProperty = AvaloniaProperty.Register(nameof(Depth)); /// Identifies the styled property. - public static readonly StyledProperty ChildCountProperty = - AvaloniaProperty.Register(nameof(ChildCount)); + public static readonly StyledProperty ChildCountProperty = AvaloniaProperty.Register(nameof(ChildCount)); /// Identifies the styled property. - public static readonly StyledProperty SizeBytesProperty = - AvaloniaProperty.Register(nameof(SizeBytes)); + public static readonly StyledProperty SizeBytesProperty = AvaloniaProperty.Register(nameof(SizeBytes)); /// Identifies the styled property. - public static readonly StyledProperty LastSyncProperty = - AvaloniaProperty.Register(nameof(LastSync)); + public static readonly StyledProperty LastSyncProperty = AvaloniaProperty.Register(nameof(LastSync)); /// Identifies the styled property. - public static readonly StyledProperty SelectionStateProperty = - AvaloniaProperty.Register(nameof(SelectionState)); + public static readonly StyledProperty SelectionStateProperty = AvaloniaProperty.Register(nameof(SelectionState)); /// Identifies the styled property. - public static readonly StyledProperty IsExpandedProperty = - AvaloniaProperty.Register(nameof(IsExpanded)); + public static readonly StyledProperty IsExpandedProperty = AvaloniaProperty.Register(nameof(IsExpanded)); /// Identifies the styled property. - public static readonly StyledProperty IsSyncingProperty = - AvaloniaProperty.Register(nameof(IsSyncing)); + public static readonly StyledProperty IsSyncingProperty = AvaloniaProperty.Register(nameof(IsSyncing)); /// Identifies the styled property. - public static readonly StyledProperty HasChildrenProperty = - AvaloniaProperty.Register(nameof(HasChildren)); + public static readonly StyledProperty HasChildrenProperty = AvaloniaProperty.Register(nameof(HasChildren)); /// Gets or sets the display name of the folder. public string NodeName @@ -263,9 +254,9 @@ private void UpdateColors() if (ink3Brush is null) return; - if (ItemCountText is not null) ItemCountText.Foreground = ink3Brush; - if (SizeText is not null) SizeText.Foreground = ink3Brush; - if (UpdatedText is not null) UpdatedText.Foreground = ink3Brush; + ItemCountText?.Foreground = ink3Brush; + SizeText?.Foreground = ink3Brush; + UpdatedText?.Foreground = ink3Brush; } private void UpdateIndent() diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/ProviderMark.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/ProviderMark.axaml.cs index 759ff56..4187f8a 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/ProviderMark.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/ProviderMark.axaml.cs @@ -9,12 +9,10 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class ProviderMark : UserControl { /// Identifies the styled property. - public static readonly StyledProperty KindProperty = - AvaloniaProperty.Register(nameof(Kind)); + public static readonly StyledProperty KindProperty = AvaloniaProperty.Register(nameof(Kind)); /// Identifies the styled property. - public static readonly StyledProperty SizeProperty = - AvaloniaProperty.Register(nameof(Size), 26.0); + public static readonly StyledProperty SizeProperty = AvaloniaProperty.Register(nameof(Size), 26.0); /// Gets or sets the cloud provider this mark represents. public ProviderKind Kind diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusBar.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusBar.axaml.cs index 2616b3d..3e4c2d6 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusBar.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusBar.axaml.cs @@ -8,28 +8,22 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class StatusBar : UserControl { /// Identifies the styled property. - public static readonly StyledProperty HealthTextProperty = - AvaloniaProperty.Register(nameof(HealthText), "All accounts healthy"); + public static readonly StyledProperty HealthTextProperty = AvaloniaProperty.Register(nameof(HealthText), "All accounts healthy"); /// Identifies the styled property. - public static readonly StyledProperty UploadRateProperty = - AvaloniaProperty.Register(nameof(UploadRate), "↑ 0 B/s"); + public static readonly StyledProperty UploadRateProperty = AvaloniaProperty.Register(nameof(UploadRate), "↑ 0 B/s"); /// Identifies the styled property. - public static readonly StyledProperty DownloadRateProperty = - AvaloniaProperty.Register(nameof(DownloadRate), "↓ 0 B/s"); + public static readonly StyledProperty DownloadRateProperty = AvaloniaProperty.Register(nameof(DownloadRate), "↓ 0 B/s"); /// Identifies the styled property. - public static readonly StyledProperty QueueSummaryProperty = - AvaloniaProperty.Register(nameof(QueueSummary), string.Empty); + public static readonly StyledProperty QueueSummaryProperty = AvaloniaProperty.Register(nameof(QueueSummary), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty VersionProperty = - AvaloniaProperty.Register(nameof(Version), string.Empty); + public static readonly StyledProperty VersionProperty = AvaloniaProperty.Register(nameof(Version), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty IsHealthyProperty = - AvaloniaProperty.Register(nameof(IsHealthy), true); + public static readonly StyledProperty IsHealthyProperty = AvaloniaProperty.Register(nameof(IsHealthy), true); /// Gets or sets the health status text. public string HealthText @@ -80,29 +74,29 @@ public bool IsHealthy protected override void OnInitialized() { base.OnInitialized(); - updateBackground(); - updateBorder(); - updateHealthDot(); - updateHealthText(); - updateUploadText(); - updateDownloadText(); - updateQueueText(); - updateVersionText(); + UpdateBackground(); + UpdateBorder(); + UpdateHealthDot(); + UpdateHealthText(); + UpdateUploadText(); + UpdateDownloadText(); + UpdateQueueText(); + UpdateVersionText(); } /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); - if (change.Property == IsHealthyProperty) updateHealthDot(); - if (change.Property == HealthTextProperty) updateHealthText(); - if (change.Property == UploadRateProperty) updateUploadText(); - if (change.Property == DownloadRateProperty) updateDownloadText(); - if (change.Property == QueueSummaryProperty) updateQueueText(); - if (change.Property == VersionProperty) updateVersionText(); + if (change.Property == IsHealthyProperty) UpdateHealthDot(); + if (change.Property == HealthTextProperty) UpdateHealthText(); + if (change.Property == UploadRateProperty) UpdateUploadText(); + if (change.Property == DownloadRateProperty) UpdateDownloadText(); + if (change.Property == QueueSummaryProperty) UpdateQueueText(); + if (change.Property == VersionProperty) UpdateVersionText(); } - private void updateBackground() + private void UpdateBackground() { if (ContainerBorder is null) return; @@ -110,7 +104,7 @@ private void updateBackground() ContainerBorder.Background = brush; } - private void updateBorder() + private void UpdateBorder() { if (ContainerBorder is null) return; @@ -118,7 +112,7 @@ private void updateBorder() ContainerBorder.BorderBrush = borderBrush; } - private void updateHealthDot() + private void UpdateHealthDot() { if (HealthDot is null) return; @@ -129,7 +123,7 @@ private void updateHealthDot() : null; } - private void updateHealthText() + private void UpdateHealthText() { if (HealthTextBlock is null) return; @@ -139,7 +133,7 @@ private void updateHealthText() HealthTextBlock.Foreground = brush; } - private void updateUploadText() + private void UpdateUploadText() { if (UploadText is null) return; @@ -149,7 +143,7 @@ private void updateUploadText() UploadText.Foreground = brush; } - private void updateDownloadText() + private void UpdateDownloadText() { if (DownloadText is null) return; @@ -159,7 +153,7 @@ private void updateDownloadText() DownloadText.Foreground = brush; } - private void updateQueueText() + private void UpdateQueueText() { if (QueueText is null) return; @@ -169,7 +163,7 @@ private void updateQueueText() QueueText.Foreground = brush; } - private void updateVersionText() + private void UpdateVersionText() { if (VersionText is null) return; diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusDot.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusDot.axaml.cs index 4f1b618..7c386c6 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusDot.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusDot.axaml.cs @@ -8,12 +8,10 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class StatusDot : UserControl { /// Identifies the styled property. - public static readonly StyledProperty FillProperty = - AvaloniaProperty.Register(nameof(Fill)); + public static readonly StyledProperty FillProperty = AvaloniaProperty.Register(nameof(Fill)); /// Identifies the styled property. - public static readonly StyledProperty IsPulsingProperty = - AvaloniaProperty.Register(nameof(IsPulsing)); + public static readonly StyledProperty IsPulsingProperty = AvaloniaProperty.Register(nameof(IsPulsing)); /// Gets or sets the fill brush for the dot. public IBrush? Fill diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusPill.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusPill.axaml.cs index 706d094..41462a5 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/StatusPill.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/StatusPill.axaml.cs @@ -8,12 +8,10 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class StatusPill : UserControl { /// Identifies the styled property. - public static readonly StyledProperty LabelProperty = - AvaloniaProperty.Register(nameof(Label), string.Empty); + public static readonly StyledProperty LabelProperty = AvaloniaProperty.Register(nameof(Label), string.Empty); /// Identifies the styled property. - public static readonly StyledProperty ToneProperty = - AvaloniaProperty.Register(nameof(Tone), Tone.Neutral); + public static readonly StyledProperty ToneProperty = AvaloniaProperty.Register(nameof(Tone), Tone.Neutral); /// Gets or sets the display text for the pill. public string Label diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/StorageBar.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/StorageBar.axaml.cs index 202c39e..52ab170 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/StorageBar.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/StorageBar.axaml.cs @@ -9,16 +9,13 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class StorageBar : UserControl { /// Identifies the styled property. - public static readonly StyledProperty UsedProperty = - AvaloniaProperty.Register(nameof(Used)); + public static readonly StyledProperty UsedProperty = AvaloniaProperty.Register(nameof(Used)); /// Identifies the styled property. - public static readonly StyledProperty TotalProperty = - AvaloniaProperty.Register(nameof(Total)); + public static readonly StyledProperty TotalProperty = AvaloniaProperty.Register(nameof(Total)); /// Identifies the styled property. - public static readonly StyledProperty BarColorProperty = - AvaloniaProperty.Register(nameof(BarColor)); + public static readonly StyledProperty BarColorProperty = AvaloniaProperty.Register(nameof(BarColor)); /// Gets or sets the bytes currently used. public double Used diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/SubTabBar.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/SubTabBar.axaml.cs index ac2fc3c..f5786ee 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/SubTabBar.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/SubTabBar.axaml.cs @@ -9,12 +9,10 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class SubTabBar : UserControl { /// Identifies the styled property. - public static readonly StyledProperty SelectedTabProperty = - AvaloniaProperty.Register(nameof(SelectedTab), SubTab.SyncFolders); + public static readonly StyledProperty SelectedTabProperty = AvaloniaProperty.Register(nameof(SelectedTab), SubTab.SyncFolders); /// Identifies the styled property. - public static readonly StyledProperty ConflictCountProperty = - AvaloniaProperty.Register(nameof(ConflictCount), 0); + public static readonly StyledProperty ConflictCountProperty = AvaloniaProperty.Register(nameof(ConflictCount), 0); /// Gets or sets the currently active tab. public SubTab SelectedTab @@ -57,21 +55,21 @@ public static string GetTabLabel(SubTab tab) => protected override void OnInitialized() { base.OnInitialized(); - updateBorder(); - updateTabLabels(); - updateTabAppearances(); - updateConflictsPill(); + UpdateBorder(); + UpdateTabLabels(); + UpdateTabAppearances(); + UpdateConflictsPill(); } /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); - if (change.Property == SelectedTabProperty) updateTabAppearances(); - if (change.Property == ConflictCountProperty) updateConflictsPill(); + if (change.Property == SelectedTabProperty) UpdateTabAppearances(); + if (change.Property == ConflictCountProperty) UpdateConflictsPill(); } - private void updateBorder() + private void UpdateBorder() { if (ContainerBorder is null) return; @@ -79,7 +77,7 @@ private void updateBorder() ContainerBorder.BorderBrush = borderBrush; } - private void updateTabLabels() + private void UpdateTabLabels() { if (SyncFoldersLabel is null || ActivityLabel is null || ConflictsLabel is null || SettingsLabel is null) return; @@ -89,18 +87,18 @@ private void updateTabLabels() SettingsLabel.Text = GetTabLabel(SubTab.Settings); } - private void updateTabAppearances() + private void UpdateTabAppearances() { if (SyncFoldersLabel is null || ActivityLabel is null || ConflictsLabel is null || SettingsLabel is null) return; if (SyncFoldersUnderline is null || ActivityUnderline is null || ConflictsUnderline is null || SettingsUnderline is null) return; - applyTabStyle(SyncFoldersLabel, SyncFoldersUnderline, SelectedTab == SubTab.SyncFolders); - applyTabStyle(ActivityLabel, ActivityUnderline, SelectedTab == SubTab.Activity); - applyTabStyle(ConflictsLabel, ConflictsUnderline, SelectedTab == SubTab.Conflicts); - applyTabStyle(SettingsLabel, SettingsUnderline, SelectedTab == SubTab.Settings); + ApplyTabStyle(SyncFoldersLabel, SyncFoldersUnderline, SelectedTab == SubTab.SyncFolders); + ApplyTabStyle(ActivityLabel, ActivityUnderline, SelectedTab == SubTab.Activity); + ApplyTabStyle(ConflictsLabel, ConflictsUnderline, SelectedTab == SubTab.Conflicts); + ApplyTabStyle(SettingsLabel, SettingsUnderline, SelectedTab == SubTab.Settings); } - private void applyTabStyle(TextBlock label, Border underline, bool isActive) + private void ApplyTabStyle(TextBlock label, Border underline, bool isActive) { if (isActive) { @@ -123,7 +121,7 @@ private void applyTabStyle(TextBlock label, Border underline, bool isActive) } } - private void updateConflictsPill() + private void UpdateConflictsPill() { if (ConflictsPill is null) return; diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/ThroughputCard.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/ThroughputCard.axaml.cs index d0aa76f..87f375b 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/ThroughputCard.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/ThroughputCard.axaml.cs @@ -17,20 +17,16 @@ public partial class ThroughputCard : UserControl private const int HistoryBucketCount = 20; /// Identifies the styled property. - public static readonly StyledProperty BucketsProperty = - AvaloniaProperty.Register(nameof(Buckets), new int[BucketCount]); + public static readonly StyledProperty BucketsProperty = AvaloniaProperty.Register(nameof(Buckets), new int[BucketCount]); /// Identifies the styled property. - public static readonly StyledProperty TodayGbProperty = - AvaloniaProperty.Register(nameof(TodayGb)); + public static readonly StyledProperty TodayGbProperty = AvaloniaProperty.Register(nameof(TodayGb)); /// Identifies the styled property. - public static readonly StyledProperty FileCountProperty = - AvaloniaProperty.Register(nameof(FileCount)); + public static readonly StyledProperty FileCountProperty = AvaloniaProperty.Register(nameof(FileCount)); /// Identifies the styled property. - public static readonly StyledProperty CurrentRateProperty = - AvaloniaProperty.Register(nameof(CurrentRate), string.Empty); + public static readonly StyledProperty CurrentRateProperty = AvaloniaProperty.Register(nameof(CurrentRate), string.Empty); private IBrush? _ink3Brush; private IBrush? _primaryBrush; @@ -135,21 +131,21 @@ private void UpdateColors() _ink3Brush = ink3Brush; _ink3BrushSemiTransparent = new SolidColorBrush(((SolidColorBrush)ink3Brush).Color, 0.15); - if (TodayLabel is not null) TodayLabel.Foreground = ink3Brush; - if (AxisStartLabel is not null) AxisStartLabel.Foreground = ink3Brush; - if (AxisMidLabel is not null) AxisMidLabel.Foreground = ink3Brush; - if (AxisEndLabel is not null) AxisEndLabel.Foreground = ink3Brush; + TodayLabel?.Foreground = ink3Brush; + AxisStartLabel?.Foreground = ink3Brush; + AxisMidLabel?.Foreground = ink3Brush; + AxisEndLabel?.Foreground = ink3Brush; } if (this.TryFindResource("Primary", out var primaryRes) && primaryRes is IBrush primaryBrush) { _primaryBrush = primaryBrush; - if (GbText is not null) GbText.Foreground = primaryBrush; + GbText?.Foreground = primaryBrush; } if (this.TryFindResource("Ink2", out var ink2Res) && ink2Res is IBrush ink2Brush) - if (FilesText is not null) FilesText.Foreground = ink2Brush; + FilesText?.Foreground = ink2Brush; } private void BuildHistogram() diff --git a/src/AStar.Dev.CloudSyncFunctional/Controls/TriStateCheckbox.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/Controls/TriStateCheckbox.axaml.cs index 3d8a5c3..cdb0392 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Controls/TriStateCheckbox.axaml.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Controls/TriStateCheckbox.axaml.cs @@ -9,8 +9,7 @@ namespace AStar.Dev.CloudSyncFunctional.Controls; public partial class TriStateCheckbox : UserControl { /// Identifies the styled property. - public static readonly StyledProperty StateProperty = - AvaloniaProperty.Register(nameof(State), CheckState.Off); + public static readonly StyledProperty StateProperty = AvaloniaProperty.Register(nameof(State), CheckState.Off); /// Gets or sets the current check state. public CheckState State diff --git a/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs index 98718c0..2e3d4c6 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Domain/OneDriveAccount.cs @@ -6,7 +6,7 @@ namespace AStar.Dev.CloudSyncFunctional.Domain; public sealed class OneDriveAccount { /// Gets the MSAL HomeAccountId identifier. - public string AccountId { get; init; } = string.Empty; + public AccountId AccountId { get; init; } = AccountId.Create(string.Empty); /// Gets the account's display name and email. public AccountProfile Profile { get; init; } = new(string.Empty, string.Empty); diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFound.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFound.cs new file mode 100644 index 0000000..d973e64 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/DriveFound.cs @@ -0,0 +1,7 @@ +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// +/// Represents a drive that was found for a given account, containing the Graph drive information. +/// +/// The Graph drive information. +public sealed record DriveFound(Microsoft.Graph.Models.Drive Drive); \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs index 12a9079..3c4a809 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs @@ -1,3 +1,4 @@ +using AStar.Dev.FunctionalParadigm; using Microsoft.Graph; using Microsoft.Kiota.Abstractions.Authentication; @@ -7,8 +8,14 @@ namespace AStar.Dev.CloudSyncFunctional.Graph; public sealed class GraphClientFactory : IGraphClientFactory { /// - public GraphServiceClient CreateClient(string accessToken) => - new(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken))); + public Result CreateClient(string accessToken) + { + if (string.IsNullOrWhiteSpace(accessToken)) + return new Fail(GraphErrorFactory.Unexpected("Access token must not be null or whitespace.")); + + return new Ok( + new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken)))); + } private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider { diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs index 4d4f892..b3ee247 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs @@ -1,5 +1,8 @@ using AStar.Dev.FunctionalParadigm; using Microsoft.Extensions.Logging; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using GraphClient = Microsoft.Graph.GraphServiceClient; namespace AStar.Dev.CloudSyncFunctional.Graph; @@ -9,47 +12,77 @@ public sealed partial class GraphService(IGraphClientFactory clientFactory, ILog private static readonly string[] ChildrenSelect = ["id", "name", "folder", "parentReference"]; /// - public async Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default) + public Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken cancellationToken = default) + => ExecuteGraphOperationAsync( + accountId, + () => GetClientAndDriveAsync(clientFactory, accessToken, cancellationToken) + .BindAsync(clientAndDrive => GetRootFoldersForDriveAsync(clientAndDrive.Client, clientAndDrive.DriveFound, cancellationToken))); + + private async Task> ExecuteGraphOperationAsync(string accountId, Func>> operation) { try { - var client = clientFactory.CreateClient(accessToken); - var drive = await client.Me.Drive.GetAsync(cancellationToken: ct).ConfigureAwait(false); - if (drive?.Id is null) - return new Fail, GraphError>(GraphErrorFactory.Unexpected("Drive ID was null.")); - - var root = await client.Drives[drive.Id].Root.GetAsync(cancellationToken: ct).ConfigureAwait(false); - if (root?.Id is null) - return new Fail, GraphError>(GraphErrorFactory.Unexpected("Root item ID was null.")); - - var page = await client.Drives[drive.Id].Items[root.Id].Children - .GetAsync(req => req.QueryParameters.Select = ChildrenSelect, ct) - .ConfigureAwait(false); - - var folders = new List(); - while (page?.Value is not null) - { - foreach (var item in page.Value.Where(i => i.Folder is not null)) - folders.Add(new DriveFolder(item.Id!, item.Name!, item.ParentReference?.Id)); - - if (page.OdataNextLink is null) - break; - - page = await client.Drives[drive.Id].Items[root.Id].Children - .WithUrl(page.OdataNextLink).GetAsync(cancellationToken: ct) - .ConfigureAwait(false); - } - - return new Ok, GraphError>(folders); + return await operation().ConfigureAwait(false); } catch (Exception ex) { LogGraphFailed(logger, accountId, ex.Message); - return new Fail, GraphError>(GraphErrorFactory.Unexpected(ex.Message)); + return new Fail(GraphErrorFactory.Unexpected(ex.Message)); } } + private static Task> GetClientAndDriveAsync(IGraphClientFactory clientFactory, string accessToken, CancellationToken cancellationToken) + => clientFactory.CreateClient(accessToken) + .BindAsync(client => GetDriveAsync(client, cancellationToken) + .MapAsync(driveFound => new ClientAndDriveFound(client, driveFound))); + + private static Task, GraphError>> GetRootFoldersForDriveAsync(GraphClient client, DriveFound driveFound, CancellationToken cancellationToken) + => GetRootAsync(client, driveFound, cancellationToken) + .BindAsync(rootFound => GetFolderPageAsync(client, driveFound, rootFound, null, cancellationToken) + .BindAsync(firstPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, firstPage, null, cancellationToken))); + + private static async Task> GetFolderPageAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, string? nextLink, CancellationToken cancellationToken) + { + var children = client.Drives[driveFound.Drive.Id].Items[rootFound.DriveItem.Id].Children; + var page = nextLink is null + ? await children.GetAsync(req => req.QueryParameters.Select = ChildrenSelect, cancellationToken: cancellationToken).ConfigureAwait(false) + : await children.WithUrl(nextLink).GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + return page is { } + ? new Ok(page) + : new Fail(GraphErrorFactory.Unexpected("Folder page was null.")); + } + + private static Task, GraphError>> GetFoldersFromPagesAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, DriveItemCollectionResponse page, IReadOnlyCollection? foldersSoFar, CancellationToken cancellationToken) + { + var folders = (foldersSoFar ?? []).Concat(GetFoldersFromPage(page)).ToList(); + + return page.OdataNextLink is null + ? Task.FromResult, GraphError>>(new Ok, GraphError>(folders)) + : GetFolderPageAsync(client, driveFound, rootFound, page.OdataNextLink, cancellationToken) + .BindAsync(nextPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, nextPage, folders, cancellationToken)); + } + + private static IEnumerable GetFoldersFromPage(DriveItemCollectionResponse page) + => page.Value? + .Where(item => item.Folder is not null) + .Where(item => item.Id is not null && item.Name is not null) + .Select(item => new DriveFolder(item.Id!, item.Name!, item.ParentReference?.Id)) + ?? []; + + private static async Task> GetRootAsync(GraphClient client, DriveFound driveFound, CancellationToken cancellationToken) + => (await client.Drives[driveFound.Drive.Id].Root.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is DriveItem { Id: not null } root + ? new Ok(new RootFound(root)) + : new Fail(GraphErrorFactory.Unexpected("Root item was null.")); + + private static async Task> GetDriveAsync(GraphClient client, CancellationToken cancellationToken) + => (await client.Me.Drive.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) is { Id: not null } drive + ? new Ok(new DriveFound(drive)) + : new Fail(GraphErrorFactory.Unexpected("Drive was null.")); + [LoggerMessage(Level = LogLevel.Error, Message = "Graph API call failed for account {AccountId}: {ErrorMessage}")] private static partial void LogGraphFailed(ILogger logger, string accountId, string errorMessage); + + private sealed record ClientAndDriveFound(GraphClient Client, DriveFound DriveFound); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs index bae0a82..ad4877c 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphClientFactory.cs @@ -1,3 +1,4 @@ +using AStar.Dev.FunctionalParadigm; using Microsoft.Graph; namespace AStar.Dev.CloudSyncFunctional.Graph; @@ -7,6 +8,6 @@ public interface IGraphClientFactory { /// Creates a new authenticated with the given bearer token. /// The OAuth2 bearer token. - /// A new instance. - GraphServiceClient CreateClient(string accessToken); -} + /// A result containing a new instance, or a graph error. + Result CreateClient(string accessToken); +} \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs index 99f7465..be2e2ce 100644 --- a/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/IGraphService.cs @@ -8,7 +8,7 @@ public interface IGraphService /// Returns the root-level folders for the given account's OneDrive. /// The MSAL HomeAccountId identifier. /// The OAuth2 bearer token for Graph API calls. - /// Token to cancel the operation. + /// Token to cancel the operation. /// A list of root folders, or a on failure. - Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default); + Task, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken cancellationToken = default); } diff --git a/src/AStar.Dev.CloudSyncFunctional/Graph/RootFound.cs b/src/AStar.Dev.CloudSyncFunctional/Graph/RootFound.cs new file mode 100644 index 0000000..aea0359 --- /dev/null +++ b/src/AStar.Dev.CloudSyncFunctional/Graph/RootFound.cs @@ -0,0 +1,9 @@ +using Microsoft.Graph.Models; + +namespace AStar.Dev.CloudSyncFunctional.Graph; + +/// +/// Represents a root folder that was found for a given drive, containing the Graph drive item information. +/// +/// The Graph drive item information. +public sealed record RootFound(DriveItem DriveItem); \ No newline at end of file diff --git a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml index 9b173d9..208e037 100644 --- a/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml +++ b/src/AStar.Dev.CloudSyncFunctional/MainWindow.axaml @@ -15,6 +15,7 @@ Width="1280" Height="820" MinWidth="960" MinHeight="600" ExtendClientAreaToDecorationsHint="True" + Icon="avares://AStar.Dev.CloudSyncFunctional/Assets/astar.png" Background="{DynamicResource Chrome}"> @@ -219,7 +220,7 @@ Padding="14,8">