diff --git a/Atypical.VirtualFileSystem.sln b/Atypical.VirtualFileSystem.sln index 8b83d8c..fea8114 100644 --- a/Atypical.VirtualFileSystem.sln +++ b/Atypical.VirtualFileSystem.sln @@ -23,6 +23,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.GitHub.Tests", "tests\Atypical.VirtualFileSystem.GitHub.Tests\Atypical.VirtualFileSystem.GitHub.Tests.csproj", "{31971414-5519-4149-B5C0-C88454EBE7AD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions", "src\Atypical.VirtualFileSystem.Providers.Abstractions\Atypical.VirtualFileSystem.Providers.Abstractions.csproj", "{B2E9D21C-6C01-4F28-AF7C-2EF16148E964}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Providers.Abstractions.Tests", "tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests\Atypical.VirtualFileSystem.Providers.Abstractions.Tests.csproj", "{2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp", "src\Atypical.VirtualFileSystem.Ftp\Atypical.VirtualFileSystem.Ftp.csproj", "{9601A06C-4C43-4E68-8576-F92BBFFEA73B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atypical.VirtualFileSystem.Ftp.Tests", "tests\Atypical.VirtualFileSystem.Ftp.Tests\Atypical.VirtualFileSystem.Ftp.Tests.csproj", "{37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +125,54 @@ Global {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x64.Build.0 = Release|Any CPU {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.ActiveCfg = Release|Any CPU {31971414-5519-4149-B5C0-C88454EBE7AD}.Release|x86.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x64.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Debug|x86.Build.0 = Debug|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|Any CPU.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x64.Build.0 = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.ActiveCfg = Release|Any CPU + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964}.Release|x86.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x64.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Debug|x86.Build.0 = Debug|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x64.Build.0 = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.ActiveCfg = Release|Any CPU + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0}.Release|x86.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x64.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Debug|x86.Build.0 = Debug|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|Any CPU.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x64.Build.0 = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.ActiveCfg = Release|Any CPU + {9601A06C-4C43-4E68-8576-F92BBFFEA73B}.Release|x86.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x64.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Debug|x86.Build.0 = Debug|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|Any CPU.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x64.Build.0 = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.ActiveCfg = Release|Any CPU + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,5 +185,9 @@ Global {A1B2C3D4-E5F6-7890-1234-567890ABCDEF} = {10E2E10B-AB65-4800-93ED-CDB737917154} {0F803EA9-AD74-46E3-9404-5D536E0F262C} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} {31971414-5519-4149-B5C0-C88454EBE7AD} = {10E2E10B-AB65-4800-93ED-CDB737917154} + {B2E9D21C-6C01-4F28-AF7C-2EF16148E964} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} + {2E8D3BE3-F419-46B1-BBD3-FC5F753261D0} = {10E2E10B-AB65-4800-93ED-CDB737917154} + {9601A06C-4C43-4E68-8576-F92BBFFEA73B} = {BA11C821-EFDE-4714-A0DE-B7D4D93CE336} + {37D1D6B7-C4E5-44EC-96C1-C2C6CDB900C5} = {10E2E10B-AB65-4800-93ED-CDB737917154} EndGlobalSection EndGlobal diff --git a/CLAUDE.md b/CLAUDE.md index e33bc00..6e04b21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,13 @@ IRootNode (vfs://) └── IFileNode (contains Content string) ``` +### Storage Providers +External backends are plugged in behind a provider abstraction (the `Core` VFS itself stays storage-agnostic): +- `Atypical.VirtualFileSystem.Providers.Abstractions` defines `IStorageProvider` (`ImportAsync`/`ReadFileAsync`/`WriteChangesAsync`/`GetAccountInfoAsync`), `IStorageProviderAuth`, `ProviderCapabilities`, and neutral records (`ProviderLoadOptions/Result`, `ProviderFileChange`, `CommitContext`, `ProviderFileMetadata`, `AuthRequest/Result`). +- Branch/PR/fork are NOT base-interface methods — they are `ProviderCapabilities` flags plus `CommitContext` fields, so capable providers (GitHub) honor them and others (FTP) ignore them. +- Implementations: `Atypical.VirtualFileSystem.GitHub` (`GitHubStorageProvider`, wraps the existing Octokit loader/write service via adapters — Token auth, supports branches/PR/fork) and `Atypical.VirtualFileSystem.Ftp` (`FtpStorageProvider` over FluentFTP — Credentials auth, read+write/overwrite, no branches/PR). +- The Blazor app selects the active provider via `IStorageProviderRegistry` and drives its UI (import dialog fields, "submit changes") from `ProviderCapabilities`; FTP credentials and the GitHub PAT are persisted encrypted via `ProtectedLocalStorage`. + ### Path System - `VFSRootPath` - Root directory (vfs://) - `VFSDirectoryPath` - Directory paths with case-insensitive comparison diff --git a/docs/superpowers/plans/2026-06-02-storage-providers-foundation-ftp.md b/docs/superpowers/plans/2026-06-02-storage-providers-foundation-ftp.md index 5c50669..f85f96e 100644 --- a/docs/superpowers/plans/2026-06-02-storage-providers-foundation-ftp.md +++ b/docs/superpowers/plans/2026-06-02-storage-providers-foundation-ftp.md @@ -740,7 +740,7 @@ public sealed class GitHubStorageProvider : IStorageProvider public async Task ImportAsync(IVirtualFileSystem vfs, ProviderLoadOptions options, CancellationToken cancellationToken = default) { - // RemoteRoot for GitHub is "owner/repo[/subpath]"; parse owner/repo and pass subpath via TargetPath. + // RemoteRoot for GitHub is "owner/repo[/subpath]"; parse owner/repo and pass subpath via the loader's SubPath (the remote filter), NOT TargetPath (the VFS destination). var (owner, repo, subPath) = ParseRemoteRoot(options.RemoteRoot); var ghOptions = GitHubProviderAdapters.ToGitHubLoaderOptions(options with { RemoteRoot = subPath }, _auth.Token); var result = await _loader.LoadRepositoryAsync(vfs, owner, repo, ghOptions, cancellationToken); diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj index a45cd7e..6265c0f 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Atypical.VirtualFileSystem.DemoBlazorApp.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/CreatePullRequestDialog.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/CreatePullRequestDialog.razor index b40a72d..b5689d0 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/CreatePullRequestDialog.razor +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/CreatePullRequestDialog.razor @@ -1,10 +1,12 @@ -@* Create Pull Request Dialog *@ +@* Capability-driven "Submit changes" dialog (PR for GitHub, upload for FTP) *@ @inject GitHubPendingChangesService PendingChangesService @inject GitHubAuthService AuthService @inject ToastService Toast +@inject StoragePendingChangesService StorageSubmit +@inject IStorageProviderRegistry Registry @implements IDisposable - + @if (_result is not null && _result.Success) { @@ -93,6 +95,51 @@ } + else if (!SupportsPullRequests) + { + @* Non-PR provider (e.g. FTP): simple upload confirmation *@ +
+
+
+ + + + + @Registry.Active.DisplayName uploads changes directly to the remote — no pull request is created. + +
+
+ + @if (_uploadChanges.Count == 0) + { +
+
+ + + + + No tracked changes are available to upload for this provider yet. + +
+
+ } + else + { + +
+ @foreach (var change in _uploadChanges) + { +
+ @change.RemotePath + @change.Kind +
+ } +
+ } +
+ } else { @* Input Form *@ @@ -223,6 +270,16 @@ { Creating pull request... } + else if (!SupportsPullRequests) + { + + + } else { @@ -255,6 +312,15 @@ private (string Owner, string Repository)? _repositoryInfo; private bool _wasOpen; + // Non-PR (upload) provider state. + private List _uploadChanges = new(); + + // Capability-driven view selectors. + private bool SupportsPullRequests => StorageSubmit.SupportsPullRequests; + private string DialogTitle => SupportsPullRequests + ? "Create Pull Request" + : $"Upload changes to {Registry.Active.DisplayName}"; + private bool CanCreate => AuthService.IsAuthenticated && !string.IsNullOrWhiteSpace(_title) && !string.IsNullOrWhiteSpace(_branchName) @@ -264,6 +330,7 @@ { PendingChangesService.OnPendingChangesChanged += LoadChanges; AuthService.OnCredentialsChanged += OnCredentialsChanged; + Registry.OnActiveProviderChanged += OnCredentialsChanged; } private void OnCredentialsChanged() => InvokeAsync(StateHasChanged); @@ -396,6 +463,41 @@ } } + // Non-PR providers (e.g. FTP): upload the tracked changes directly via the active provider. + // NOTE: FTP change-set tracking is not yet wired (see concern in the report); _uploadChanges + // is empty today, so this path is reached only once tracking is implemented. + private async Task UploadChanges() + { + if (_uploadChanges.Count == 0) return; + + _isCreating = true; + StateHasChanged(); + + try + { + var writeResult = await StorageSubmit.SubmitAsync(_uploadChanges, CommitContext.Empty); + if (writeResult.Success) + { + Toast.ShowSuccess($"Uploaded {writeResult.FileResults.Count} file(s) successfully."); + _uploadChanges = new(); + await Close(); + } + else + { + Toast.ShowError(writeResult.Message ?? "One or more files failed to upload."); + } + } + catch (Exception ex) + { + Toast.ShowError($"Upload failed: {ex.Message}"); + } + finally + { + _isCreating = false; + StateHasChanged(); + } + } + private void ResetDialog() { _result = null; @@ -470,5 +572,6 @@ { PendingChangesService.OnPendingChangesChanged -= LoadChanges; AuthService.OnCredentialsChanged -= OnCredentialsChanged; + Registry.OnActiveProviderChanged -= OnCredentialsChanged; } } diff --git a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/GitHubImportDialog.razor b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/GitHubImportDialog.razor index 1362a98..64beb19 100644 --- a/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/GitHubImportDialog.razor +++ b/src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Dialogs/GitHubImportDialog.razor @@ -1,15 +1,18 @@ -@* GitHub Repository Import Dialog *@ +@* Capability-driven storage import dialog (GitHub, FTP, ...) *@ @inject IVirtualFileSystem VFS @inject GitHubImportService ImportService @inject VFSStateService StateService @inject ToastService Toast @inject NavigationManager Navigation @inject GitHubAuthService AuthService +@inject IStorageProviderRegistry Registry +@inject StorageImportService StorageImport +@inject StorageCredentialStore CredentialStore @implements IDisposable - + - @if (ImportService.State.IsImporting) + @if (IsGitHub && ImportService.State.IsImporting) { @* Loading State *@
@@ -37,7 +40,7 @@
} - else if (!string.IsNullOrEmpty(ImportService.State.ErrorMessage)) + else if (IsGitHub && !string.IsNullOrEmpty(ImportService.State.ErrorMessage)) { @* Error State *@
@@ -59,6 +62,20 @@ { @* Input Form *@
+ @* Provider Picker (capability-driven UI) *@ +
+ + +
+ + @if (IsGitHub) + { + @* ===== GitHub field set (SupportsBranches) ===== *@ @* Authentication Status *@
@@ -200,18 +217,107 @@
} + } + else if (IsCredentials) + { + @* ===== Credentials field set (e.g. FTP) ===== *@ + @if (_ftpBusy) + { +
+
+ @_ftpBusyMessage +
+ } + else + { + @if (!string.IsNullOrEmpty(_ftpError)) + { +
+
+ + + +
+

Import Failed

+

@_ftpError

+
+
+
+ } + + @if (_ftpResult is not null) + { +
+
+ + + +
+

Import Complete

+

+ Imported @_ftpResult.FilesLoaded files (@_ftpResult.Skipped.Count skipped) from @_ftpResult.RemoteRoot +

+
+
+
+ } + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +

Remote directory to import from (defaults to /)

+
+ } + }
}